feat(stream-header): rebuild Direct/Channels/Bots header as a curtain layered above tabs with peek chips, inline forms, and VisualViewport keyboard compensation

This commit is contained in:
v.lagerev 2026-05-15 23:05:34 +03:00
parent ffd9cad2ad
commit 015ae06aa1
25 changed files with 1741 additions and 523 deletions

View file

@ -392,7 +392,8 @@
"e2e_encryption": "End-to-End Encryption",
"e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.",
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
"create": "Create"
"create": "Create",
"close": "Close"
},
"Channels": {
"no_spaces_title": "No communities yet",

View file

@ -394,7 +394,8 @@
"e2e_encryption": "Сквозное шифрование",
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
"create": "Создать"
"create": "Создать",
"close": "Закрыть"
},
"Channels": {
"no_spaces_title": "Пока нет сообществ",

View file

@ -0,0 +1,32 @@
import React from 'react';
import { Icon, IconSrc } from 'folds';
import * as css from './StreamHeader.css';
type ChipProps = {
iconSrc: IconSrc;
label: string;
onClick: () => void;
// When the curtain covers the chip its row is `height: 0` /
// `overflow: hidden`. We also flip `tabIndex` so keyboard users
// can't focus the invisible button on desktop (where peek-drag is
// unavailable). Re-enabled when the row is revealed.
hidden: boolean;
};
// Pill-shaped reveal button shown when the user drags the curtain down
// to a peek stage. Same geometry as the inline form's input bar so the
// transition chip → input feels like a content swap, not a layout move.
export function Chip({ iconSrc, label, onClick, hidden }: ChipProps) {
return (
<button
type="button"
onClick={onClick}
className={css.chip}
tabIndex={hidden ? -1 : 0}
aria-hidden={hidden || undefined}
>
<Icon size="50" src={iconSrc} />
<span className={css.chipPlaceholder}>{label}</span>
</button>
);
}

View file

@ -0,0 +1,28 @@
import React, { forwardRef } from 'react';
import * as css from './StreamHeader.css';
type SegmentProps = {
active: boolean;
disabled?: boolean;
label: string;
onClick?: () => void;
};
// Tab segment for the StreamHeader row. Active state is communicated
// by a violet dot (folds `Primary.Main`) and a heavier font weight.
export const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
({ active, disabled, label, onClick }, ref) => (
<button
ref={ref}
type="button"
onClick={disabled ? undefined : onClick}
aria-pressed={active}
aria-disabled={disabled || undefined}
className={css.segment({ active, disabled })}
>
<span aria-hidden className={css.segmentDot({ active })} />
{label}
</button>
)
);
Segment.displayName = 'Segment';

View file

@ -0,0 +1,233 @@
import { style } from '@vanilla-extract/css';
import { recipe } from '@vanilla-extract/recipes';
import { color, toRem } from 'folds';
import {
CHIP_GAP_PX,
CURTAIN_BREATHER_PX,
CURTAIN_RADIUS_PX,
CURTAIN_SNAP_EASING,
CURTAIN_SNAP_MS,
TABS_ROW_PX,
} from './geometry';
// Stage. Position-relative anchor. The header itself paints the
// light-blue backdrop; the curtain is layered ABOVE it via z-index.
export const stage = style({
position: 'relative',
flex: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
});
// Header — always-rendered strip carrying tabs row + (optional) chip
// reveal area + (optional) active form. The curtain slides on top of
// the area BELOW the tabs row to cover/reveal those children.
export const header = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
display: 'flex',
flexDirection: 'column',
// Higher than `stage`, lower than `curtain` so the curtain occludes
// everything below the tabs row when raised.
zIndex: 1,
backgroundColor: color.SurfaceVariant.Container,
});
// Tabs row. Stays fully visible regardless of curtain position
// because the curtain's `top` floor equals `TABS_ROW_PX`.
export const tabsRow = style({
flexShrink: 0,
height: toRem(TABS_ROW_PX),
display: 'flex',
alignItems: 'center',
padding: `0 ${toRem(8)}`,
});
export const tabsCluster = style({
display: 'flex',
alignItems: 'center',
gap: toRem(4),
alignSelf: 'stretch',
});
export const iconsCluster = style({
display: 'flex',
alignItems: 'center',
gap: toRem(4),
flexShrink: 0,
});
// Curtain. Layered above the header (z-index higher). Its top edge
// moves with the snap state (and live finger drag); its bottom edge
// is anchored to the stage bottom so the curtain's `bottomPinned`
// child (DirectSelfRow / ChannelCreateRow / WorkspaceFooter) stays
// glued to the visible viewport bottom regardless of where the
// curtain's top is.
//
// Only the TOP corners are rounded: the bottom is meant to read as
// continuous with the always-visible bottomPinned row (DirectSelfRow
// is the curtain's last flex child) — adding `borderBottomRadius`
// would crop the row's corners against the curtain's
// `overflow: hidden`, which visually reads as «a light-blue strip
// cuts into the row».
//
// Live finger tracking and snap commits both flow through React state
// updates to `top` so the transition is always coordinated with the
// rendered position — disabled during drag, restored on commit.
export const curtain = style({
position: 'absolute',
left: 0,
right: 0,
bottom: 0,
zIndex: 2,
display: 'flex',
flexDirection: 'column',
minHeight: 0,
overflow: 'hidden',
backgroundColor: color.Background.Container,
borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX),
borderTopRightRadius: toRem(CURTAIN_RADIUS_PX),
transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`,
// Hint the compositor while the curtain is moving. Cheap since the
// curtain is the only element in this stacking context that animates.
willChange: 'top',
});
// Wrapper around `bottomPinned` inside the curtain. Anchored to the
// curtain's flex-bottom by virtue of being the last child. The TSX
// applies a `transform: translateY(keyboardH)` to this element when
// the on-screen keyboard rises (via `VisualViewport.height` shrink)
// so the row stays at its ORIGINAL viewport-bottom position — under
// the keyboard, clipped by the curtain's `overflow: hidden`. Without
// this compensation, `interactive-widget=resizes-content` (global
// meta — load-bearing for the room composer) shrinks the layout
// viewport, dragging every `bottom: 0` element up over the inline
// form. The DirectSelfRow ending up immediately above the keyboard
// blocks the user's view of the form they're typing into.
export const bottomPinnedSlot = style({
flexShrink: 0,
// Compositor hint — the transform is applied/cleared on every
// VisualViewport resize while a keyboard is open.
willChange: 'transform',
});
// Segment button (Direct / Channels / Bots).
export const segment = recipe({
base: {
appearance: 'none',
border: 'none',
background: 'transparent',
color: color.Background.OnContainer,
cursor: 'pointer',
padding: `${toRem(8)} ${toRem(10)}`,
borderRadius: toRem(8),
font: 'inherit',
fontSize: toRem(14),
lineHeight: 1.2,
display: 'inline-flex',
alignItems: 'center',
gap: toRem(8),
whiteSpace: 'nowrap',
fontWeight: 500,
WebkitAppearance: 'none',
},
variants: {
active: {
true: { fontWeight: 600 },
},
disabled: {
true: { opacity: 0.45, cursor: 'default' },
},
},
});
// Active-state dot inside each segment.
export const segmentDot = recipe({
base: {
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
flexShrink: 0,
},
variants: {
active: {
true: { backgroundColor: color.Primary.Main },
false: { backgroundColor: 'transparent' },
},
},
});
// Chip row — outer clip-strip. Each row reveals from underneath the
// curtain when the user drags down to the corresponding peek stage.
//
// The `marginBottom` math is load-bearing for the snap-top
// calculation: the resting `top` of `peek1`/`peek2` lands the curtain
// exactly where the next row would have begun, so the breather never
// "steals" pixels from the next chip's paddingTop. Two different
// values:
// - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two
// pills read as a related pair when both are revealed at peek2.
// - `:last-child` (chip-to-curtain): `CURTAIN_BREATHER_PX` — wider,
// so the curtain's rounded top has comfortable air above the
// chip pill it lands above.
export const chipRow = style({
height: toRem(56),
marginBottom: toRem(CHIP_GAP_PX),
paddingLeft: toRem(24),
paddingRight: toRem(24),
paddingTop: toRem(8),
display: 'flex',
alignItems: 'flex-start',
selectors: {
'&:last-child': {
marginBottom: toRem(CURTAIN_BREATHER_PX),
},
},
});
// The chip pill itself.
export const chip = style({
appearance: 'none',
border: 'none',
display: 'flex',
alignItems: 'center',
gap: toRem(10),
width: '100%',
height: toRem(48),
padding: `${toRem(8)} ${toRem(14)}`,
borderRadius: toRem(20),
font: 'inherit',
fontSize: toRem(14),
textAlign: 'left',
cursor: 'pointer',
backgroundColor: color.Background.Container,
color: color.Background.OnContainer,
WebkitAppearance: 'none',
});
export const chipPlaceholder = style({
opacity: 0.65,
whiteSpace: 'nowrap',
});
// Active form area in the header. Outer is `position: relative`; the
// inner mounted form fills it with `top: 0` so the form's first
// element (input bar) sits flush at the line where chips would
// otherwise live.
export const formArea = style({
position: 'relative',
flexShrink: 0,
overflow: 'hidden',
});
export const formInner = style({
position: 'absolute',
top: 0,
left: 0,
right: 0,
padding: `${toRem(8)} ${toRem(24)} ${toRem(12)}`,
});

View file

@ -0,0 +1,291 @@
import React, {
MutableRefObject,
ReactNode,
TransitionEvent,
useCallback,
useEffect,
useMemo,
useState,
} from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { Box, Icon, IconButton, Icons, toRem } from 'folds';
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor';
import { useBotPresets } from '../../features/bots/catalog';
import * as css from './StreamHeader.css';
import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry';
import { Segment } from './Segment';
import { Chip } from './Chip';
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainGesture } from './useCurtainGesture';
import { InlineNewChatForm } from './forms/InlineNewChatForm';
import { InlineRoomSearch } from './forms/InlineRoomSearch';
const INLINE_FORM_ID = 'stream-header-inline-form';
type StreamHeaderProps = {
// Scroll viewport that hosts the chat list under the curtain. The
// curtain's children (`children` prop) render inside an element that
// receives `scrollRef` automatically — the parent doesn't need to
// wire it. The ref is used by the touch gesture to recognise list
// scrollTop=0 and engage the peek-reveal.
scrollRef: MutableRefObject<HTMLDivElement | null>;
// Curtain contents — the chat list. The list is rendered inside an
// `overflow: auto` div that the gesture hook listens to.
children: ReactNode;
// Optional row(s) pinned to the bottom of the curtain (DirectSelfRow,
// ChannelCreateRow, WorkspaceFooter). Hidden while a form is active
// so the on-screen keyboard's viewport resize doesn't push them up
// over the form (see commit 14ed080).
bottomPinned?: ReactNode;
};
export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeaderProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const bots = useBotPresets();
const navOpts = useMemo(() => ({ replace: isNativePlatform() }), []);
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false });
const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false });
const showBotsSegment = bots.length > 0 || !!botsMatch;
const curtain = useCurtainState();
useCurtainGesture({
scrollRef,
snap: curtain.snap,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
});
const isActive = isFormSnap(curtain.snap);
const openSearch = useCallback(() => curtain.open('search'), [curtain]);
const openChat = useCallback(() => curtain.open('chat'), [curtain]);
const { close } = curtain;
// Curtain's `top` is the resting snap position plus the live drag
// delta. React-driven (no inline DOM writes), so finger-tracking and
// commit happen in the same render pipeline and there's no
// intermediate "snap back, then animate" flash on release.
const curtainTop = snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own
// transitions bubbling up) don't drop the form mid-animation.
const onCurtainTransitionEnd = useCallback(
(evt: TransitionEvent<HTMLDivElement>) => {
if (evt.target !== evt.currentTarget) return;
if (evt.propertyName !== 'top') return;
curtain.acknowledgeClosed();
},
[curtain]
);
// On-screen keyboard detection via VisualViewport API. Global
// viewport-meta is `interactive-widget=resizes-content` (load-
// bearing for the room composer's keyboard-follow behaviour), which
// shrinks the layout viewport when a soft keyboard appears. Any
// `bottom: 0` child — including DirectSelfRow inside the curtain —
// rises with the shrunken viewport and ends up sitting RIGHT ABOVE
// the keyboard, blocking the inline form the user is typing into.
//
// Fix: when the keyboard is up, collapse the `bottomPinned` slot
// to zero height so it neither claims flex space at the curtain
// bottom nor renders above the keyboard. The user perceives the
// keyboard as overlaying everything below the form (matching their
// mental model: "клавиатура рисуется поверх кнопок и чатов, кнопка
// настройки остаётся прибитой снизу"). The row reappears the moment
// the keyboard retracts.
//
// The reference-height tracking mirrors `AuthLayout.tsx`: bump the
// reference upward on every grow (so rotation / keyboard-close
// events stay self-correcting) and treat a meaningful shrink (>=
// KEYBOARD_PROBE_PX) as «keyboard is up». The probe avoids
// spurious flips on small browser-chrome animations.
const [keyboardOpen, setKeyboardOpen] = useState(false);
useEffect(() => {
const vv = window.visualViewport;
if (!vv) return undefined;
const KEYBOARD_PROBE_PX = 100;
let referenceH = vv.height;
let rafId: number | null = null;
const apply = () => {
rafId = null;
if (vv.height > referenceH) referenceH = vv.height;
setKeyboardOpen(referenceH - vv.height >= KEYBOARD_PROBE_PX);
};
const onResize = () => {
if (rafId === null) rafId = requestAnimationFrame(apply);
};
apply();
vv.addEventListener('resize', onResize);
return () => {
if (rafId !== null) cancelAnimationFrame(rafId);
vv.removeEventListener('resize', onResize);
};
}, []);
return (
<div className={css.stage}>
<header className={css.header}>
{/* ── Tabs row + action icons (always visible) ─────────── */}
<div className={css.tabsRow}>
<div className={css.tabsCluster}>
<Segment
active={!!directMatch}
label={t('Direct.segment_dm')}
onClick={() => navigate(DIRECT_PATH, navOpts)}
/>
<Segment
active={!!channelsMatch}
label={t('Direct.segment_channels')}
onClick={() => navigate(CHANNELS_PATH, navOpts)}
/>
{showBotsSegment && (
<Segment
active={!!botsMatch}
label={t('Direct.segment_bots')}
onClick={() => navigate(BOTS_PATH, navOpts)}
/>
)}
</div>
<Box grow="Yes" />
{isActive ? (
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={close}
aria-label={t('Direct.close')}
aria-controls={INLINE_FORM_ID}
aria-expanded
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
) : (
<div className={css.iconsCluster}>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={openChat}
aria-label={t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={Icons.Plus} />
</IconButton>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={openSearch}
aria-label={t('Search.search')}
aria-controls={INLINE_FORM_ID}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={Icons.Search} />
</IconButton>
</div>
)}
</div>
{/* Chips vs form
Mutually exclusive. While a form is mounted (including the
curtain's close-snap window before `acknowledgeClosed`), the
chips stay unrendered so the form doesn't visually jump
from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX
mid-animation.
When chips are rendered: always present in their fixed
header positions; the curtain occludes them by z-stacking.
As the user drags the curtain down, the chips reveal from
underneath naturally. `Chip.hidden` only controls keyboard
focus (the chip paints normally; the curtain's z-index does
the visual hiding). */}
{curtain.activeForm ? (
<div
id={INLINE_FORM_ID}
role="region"
aria-label={
curtain.activeForm === 'search'
? t('Search.search')
: t('Direct.create_chat_subtitle')
}
className={css.formArea}
style={{
height: toRem(curtain.formHeightPx ?? 0),
}}
>
<div ref={curtain.formMeasureRef} className={css.formInner}>
{curtain.activeForm === 'search' && <InlineRoomSearch onClose={close} />}
{curtain.activeForm === 'chat' && <InlineNewChatForm onClose={close} />}
</div>
</div>
) : (
<>
<div className={css.chipRow}>
<Chip
iconSrc={Icons.Search}
label={t('Search.search')}
onClick={openSearch}
hidden={curtain.snap === 'closed'}
/>
</div>
<div className={css.chipRow}>
<Chip
iconSrc={Icons.Plus}
label={t('Direct.create_chat')}
onClick={openChat}
hidden={curtain.snap !== 'peek2'}
/>
</div>
</>
)}
</header>
{/* Curtain layer
Renders ABOVE the header (z-index higher). `top` combines the
snap-derived resting position with the live finger drag one
React-controlled inline style, no ref-based DOM writes. The
transition is disabled during the drag and restored on commit
so the snap commit animates smoothly without an intermediate
"snap back then animate forward" flash. */}
<div
className={css.curtain}
style={{
top: toRem(curtainTop),
transition: curtain.isDragging ? 'none' : undefined,
}}
onTransitionEnd={onCurtainTransitionEnd}
>
{children}
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is
kept mounted across snaps so the curtain reads as a self-
contained "screen" with its bottom row always pinned to
the stage bottom. While the on-screen keyboard is up the
slot collapses to `height: 0` so it neither paints nor
claims flex space above the keyboard (see the
`keyboardOpen` effect above for the rationale). */}
{bottomPinned && (
<div
className={css.bottomPinnedSlot}
style={keyboardOpen ? { height: 0, overflow: 'hidden' } : undefined}
>
{bottomPinned}
</div>
)}
</div>
</div>
);
}
export { TABS_ROW_PX, CHIP_ROW_PX };

View file

@ -0,0 +1,18 @@
import React from 'react';
import { CreateChat } from '../../../features/create-chat';
type Props = {
// Called after the form successfully creates or navigates to an
// existing DM. The StreamHeader uses this to close the curtain over
// the form.
onClose: () => void;
};
// Thin shell around the shared `CreateChat` form. The legacy
// `/direct/_create` route renders the same component with a Page/
// PageHero shell; here we only feed it the `onClose` callback and the
// tighter `gap='400'` rhythm so the form fits comfortably under the
// header.
export function InlineNewChatForm({ onClose }: Props) {
return <CreateChat gap="400" onCreated={onClose} />;
}

View file

@ -0,0 +1,244 @@
import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Box, Icon, Icons, Scroll, Text, color, toRem } from 'folds';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
import { getAllParents, getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent } from '../../../utils/room';
import { nameInitials } from '../../../utils/common';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { highlightText } from '../../../plugins/react-custom-html-parser';
import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch';
import { SEARCH_FORM_BASE_PX } from '../geometry';
type Props = {
onClose: () => void;
};
// Inline search panel mounted in the StreamHeader. Shares all search
// logic with the global Search modal via `useRoomSearch`; only the
// presentation chrome differs (no Modal/Overlay/FocusTrap, a custom
// row layout that matches the inline aesthetic).
export function InlineRoomSearch({ onClose }: Props) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const { navigateRoom, navigateSpace } = useRoomNavigate();
const openRoomId = useCallback(
(roomId: string, isSpace: boolean) => {
if (isSpace) navigateSpace(roomId);
else navigateRoom(roomId);
onClose();
},
[navigateRoom, navigateSpace, onClose]
);
const {
inputRef,
scrollRef,
roomsToRender,
result,
listFocus,
queryHighlightRegex,
handleInputChange,
handleInputKeyDown,
handleRoomClick,
getRoom,
mDirects,
orphanSpaces,
roomToParents,
roomToUnread,
myUserId,
} = useRoomSearch({ onOpenRoomId: openRoomId });
// Focus the input on mount. Inline form opens via an explicit user
// action (chip tap or icon click), so this is request-initiated
// focus rather than ambient `autoFocus` — keeps screen readers
// happy.
useEffect(() => {
inputRef.current?.focus();
}, [inputRef]);
return (
<Box direction="Column" gap="200" style={{ height: toRem(SEARCH_FORM_BASE_PX - 40) }}>
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14)
so the chip input morph reads as a content crossfade. */}
<Box
alignItems="Center"
style={{
backgroundColor: color.Background.Container,
borderRadius: toRem(20),
padding: `${toRem(8)} ${toRem(14)}`,
height: toRem(48),
gap: toRem(10),
}}
>
<Icon size="50" src={Icons.Search} />
<input
ref={inputRef}
type="text"
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
placeholder={t('Search.search')}
autoComplete="off"
style={{
flex: 1,
appearance: 'none',
border: 'none',
outline: 'none',
background: 'transparent',
font: 'inherit',
fontSize: toRem(14),
color: color.Background.OnContainer,
minWidth: 0,
}}
/>
</Box>
{/* ── Result list ────────────────────────────────────── */}
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
{roomsToRender.length === 0 && (
<Box
grow="Yes"
alignItems="Center"
justifyContent="Center"
direction="Column"
gap="100"
style={{ paddingTop: toRem(40) }}
>
<Text size="H6" align="Center">
{result ? t('Search.no_match_found') : t('Search.no_rooms')}
</Text>
<Text size="T200" align="Center" priority="300">
{result
? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')}
</Text>
</Box>
)}
{roomsToRender.length > 0 && (
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 0` }}>
{roomsToRender.map((roomId, index) => {
const room = getRoom(roomId);
if (!room) return null;
const dm = mDirects.has(roomId);
const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined;
const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined;
const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined;
const allParents = getAllParents(roomToParents, roomId);
const orphanParents = allParents
? orphanSpaces.filter((o) => allParents.has(o))
: undefined;
const perfectOrphanParent =
orphanParents && guessPerfectParent(mx, roomId, orphanParents);
const exactParents = roomToParents.get(roomId);
const perfectParent =
exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents));
const unread = roomToUnread.get(roomId);
const focused = listFocus.index === index;
return (
<button
key={roomId}
type="button"
data-focus-index={index}
data-room-id={roomId}
data-space={room.isSpaceRoom()}
onClick={handleRoomClick}
aria-pressed={focused}
style={{
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
backgroundColor: focused ? color.Primary.Main : 'transparent',
color: focused ? color.Primary.OnMain : color.Background.OnContainer,
borderRadius: toRem(12),
padding: `${toRem(8)} ${toRem(10)}`,
width: '100%',
display: 'flex',
alignItems: 'center',
gap: toRem(10),
cursor: 'pointer',
textAlign: 'left',
font: 'inherit',
}}
>
<Avatar size="200" radii={dm ? '400' : '300'}>
{dm || room.isSpaceRoom() ? (
<RoomAvatar
roomId={room.roomId}
src={
dm
? getDirectRoomAvatarUrl(mx, room, 32, useAuthentication)
: getRoomAvatarUrl(mx, room, 32, useAuthentication)
}
alt={room.name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(room.name)}
</Text>
)}
/>
) : (
<RoomIcon
size="100"
joinRule={room.getJoinRule()}
roomType={room.getType()}
/>
)}
</Avatar>
<Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text size="T400" truncate>
{queryHighlightRegex
? highlightText(queryHighlightRegex, [room.name])
: room.name}
</Text>
{dmUsername && (
<Text as="span" size="T200" priority="300" truncate>
@
{queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername])
: dmUsername}
</Text>
)}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
{getRoom(perfectParent)?.name ?? perfectParent}
</Text>
)}
</Box>
<Box gap="100" alignItems="Center" shrink="No">
{dmUserServer && (
<Text size="T200" priority="300" truncate>
<b>{dmUserServer}</b>
</Text>
)}
{!dm && perfectOrphanParent && (
<Text size="T200" priority="300" truncate>
<b>{getRoom(perfectOrphanParent)?.name ?? perfectOrphanParent}</b>
</Text>
)}
{unread && (
<UnreadBadgeCenter>
<UnreadBadge highlight={unread.highlight > 0} count={unread.total} />
</UnreadBadgeCenter>
)}
</Box>
</button>
);
})}
</Box>
)}
</Scroll>
</Box>
</Box>
);
}

View file

@ -0,0 +1,62 @@
// ────────────────────────────────────────────────────────────────────
// StreamHeader geometry — shared constants for the curtain layout.
//
// Mental model: the chats card is a curtain layered ABOVE the header
// (z-index higher). The curtain's `top` is the visible part of the
// header below the always-pinned tabs row. When the curtain is fully
// closed it sits flush under the tabs row (covering chips + form area
// beneath). Dragging it DOWN reveals more of the header from underneath.
// Dragging UP raises the curtain back over the header.
//
// Snap stops (curtain.top, px):
// closed = TABS_ROW_PX
// peek1 = TABS_ROW_PX + CHIP_ROW_PX
// peek2 = TABS_ROW_PX + CHIP_ROW_PX * 2
// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX
// ────────────────────────────────────────────────────────────────────
// Tabs row height. Always visible above the curtain.
export const TABS_ROW_PX = 64;
// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather.
export const CHIP_ROW_PX = 56;
// Vertical gap BETWEEN two consecutive chip rows. Separate from
// `CURTAIN_BREATHER_PX` so the inter-chip spacing can read tighter
// than the breather between the last chip and the curtain's rounded
// top (the curtain's straight edge against a chip pill needs more
// air to avoid feeling «clamped», while two pills sitting in a
// vertical stack want to read as a pair).
export const CHIP_GAP_PX = 14;
// Initial estimate for the search form's outer height. The actual
// height is measured at runtime via ResizeObserver and adapts to the
// available viewport so the form never overflows the chats card.
export const SEARCH_FORM_BASE_PX = 360;
// Breathing strip between the bottom of any header content (revealed
// chip pill, form's last actionable element) and the top of the
// curtain. Painted by the header's `SurfaceVariant.Container` (light-
// blue) so the chip / Create button / search results never visually
// touch the curtain's rounded top — the user reads chips that sit
// flush with the curtain as «зажатые» rather than two separate
// affordances. Not applied at `closed` (nothing to breathe to).
export const CURTAIN_BREATHER_PX = 20;
// Curtain snap transition. Tuned tight for an in-app reveal —
// emphasized-decelerate territory.
export const CURTAIN_SNAP_MS = 280;
export const CURTAIN_SNAP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
// Curtain card top-corner radius. Matches the composer card and the
// horseshoe surfaces elsewhere in the app.
export const CURTAIN_RADIUS_PX = 24;
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so
// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of
// a stage the user must cross on release to advance.
export const RUBBER_BAND = 0.65;
export const DIRECTION_DEAD_ZONE_PX = 10;
export const COMMIT_THRESHOLD = 0.65;
// Pull-up distance (raw finger px) required to close an active form.
export const ACTIVE_CLOSE_THRESHOLD_PX = 100;

View file

@ -0,0 +1,2 @@
export { StreamHeader } from './StreamHeader';
export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry';

View file

@ -0,0 +1,200 @@
import { MutableRefObject, useEffect, useRef } from 'react';
import { isNativePlatform } from '../../utils/capacitor';
import {
ACTIVE_CLOSE_THRESHOLD_PX,
CHIP_ROW_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
RUBBER_BAND,
} from './geometry';
import { CurtainSnap, isFormSnap, isPeekSnap } from './useCurtainState';
type Args = {
// The scroll viewport that hosts the chat list inside the curtain.
// Touch events fire here; gestures engage when the list is at
// `scrollTop === 0` (peek path) or any scrollTop (form-close path).
scrollRef: MutableRefObject<HTMLDivElement | null>;
// Current snap stop. Read at touchstart to decide gesture meaning.
// Mirrored into a ref so the listener (bound once) reads fresh values.
snap: CurtainSnap;
// Setter for the live drag delta during touchmove. The hook reads
// `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes, no inline-vs-
// React transition coordination.
setLiveDrag: (px: number, dragging: boolean) => void;
// Commit a new snap stop on release.
commit: (next: CurtainSnap) => void;
};
// Touch-gesture driver for the curtain. Native-only: on web/PC the
// listeners aren't attached at all.
//
// Peek path: drag down from `closed`/`peek1`/`peek2` rubber-bands the
// live delta and on release commits to the nearest stage. Drag UP from
// peek retreats toward `closed` via the same threshold logic.
//
// Form-close path: drag UP from a form snap tracks the finger 1:1; on
// release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`.
export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args): void {
// Mirror snap into a ref so the listener — bound once via useEffect —
// always reads the freshest value without re-attaching.
const snapRef = useRef<CurtainSnap>(snap);
snapRef.current = snap;
useEffect(() => {
if (!isNativePlatform()) return undefined;
const list = scrollRef.current;
if (!list) return undefined;
let startY: number | null = null;
let direction: 'up' | 'down' | null = null;
let engaged = false;
let lastDelta = 0;
const onTouchStart = (e: TouchEvent) => {
if (e.touches.length !== 1) return;
startY = e.touches[0].clientY;
direction = null;
engaged = false;
lastDelta = 0;
// Peek-reveal requires scrollTop === 0 (no content above to
// scroll). Form-close engages regardless of scrollTop (the form
// is open, the list scroll is the close target).
if (!isFormSnap(snapRef.current) && list.scrollTop !== 0) {
startY = null;
}
};
const onTouchMove = (e: TouchEvent) => {
if (e.touches.length !== 1) {
// Second finger landed mid-gesture — abort.
startY = null;
direction = null;
if (engaged) setLiveDrag(0, false);
engaged = false;
lastDelta = 0;
return;
}
if (startY === null) {
// Active mode may re-arm startY here if onTouchStart bailed.
if (isFormSnap(snapRef.current)) {
startY = e.touches[0].clientY;
} else {
return;
}
}
const delta = e.touches[0].clientY - startY;
const currentSnap = snapRef.current;
// Resolve a direction once the finger crosses the dead-zone.
if (direction === null) {
if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return;
direction = delta > 0 ? 'down' : 'up';
// Direction guards: nothing higher than closed; nothing lower
// than peek2; form snaps only close (up).
if (currentSnap === 'closed' && direction === 'up') {
startY = null;
direction = null;
return;
}
if (currentSnap === 'peek2' && direction === 'down') {
startY = null;
direction = null;
return;
}
if (isFormSnap(currentSnap) && direction === 'down') {
startY = null;
direction = null;
return;
}
}
engaged = true;
e.preventDefault();
if (isFormSnap(currentSnap)) {
// Form close: finger moves UP (delta < 0). Track 1:1, capped
// at 0 so an accidental downward jitter doesn't push the
// curtain below its resting position.
lastDelta = Math.min(0, delta);
} else {
// Peek: rubber-banded BOTH directions. Down (delta > 0) reveals
// more chips; up (delta < 0) retreats toward `closed`. Bounds
// are enforced by the direction guards above plus the snap
// clamp on touchend, so we don't clamp here.
lastDelta = delta * RUBBER_BAND;
}
setLiveDrag(lastDelta, true);
};
const onTouchEnd = () => {
if (!engaged) {
startY = null;
direction = null;
return;
}
const currentSnap = snapRef.current;
let next: CurtainSnap = currentSnap;
if (isFormSnap(currentSnap)) {
if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) {
next = 'closed';
}
} else if (isPeekSnap(currentSnap) || currentSnap === 'closed') {
const progressStages = lastDelta / CHIP_ROW_PX;
let baseStage = 0;
if (currentSnap === 'peek1') baseStage = 1;
else if (currentSnap === 'peek2') baseStage = 2;
if (Math.abs(progressStages) >= COMMIT_THRESHOLD) {
const target = Math.max(0, Math.min(2, Math.round(baseStage + progressStages)));
let resolved: CurtainSnap = 'closed';
if (target === 1) resolved = 'peek1';
else if (target === 2) resolved = 'peek2';
next = resolved;
}
}
if (next !== currentSnap) {
// commit() also resets liveDragPx + isDragging to 0/false in
// one batched update — React renders the curtain at the new
// resting top with the snap transition re-enabled.
commit(next);
} else {
// No commit: drop the live drag back to 0 with transition
// active so the curtain springs back to its resting position.
setLiveDrag(0, false);
}
startY = null;
direction = null;
engaged = false;
lastDelta = 0;
};
const onTouchCancel = () => {
// System cancel never commits — always snap back to current snap.
if (engaged) setLiveDrag(0, false);
startY = null;
direction = null;
engaged = false;
lastDelta = 0;
};
list.addEventListener('touchstart', onTouchStart, { passive: true });
list.addEventListener('touchmove', onTouchMove, { passive: false });
list.addEventListener('touchend', onTouchEnd, { passive: true });
list.addEventListener('touchcancel', onTouchCancel, { passive: true });
return () => {
list.removeEventListener('touchstart', onTouchStart);
list.removeEventListener('touchmove', onTouchMove);
list.removeEventListener('touchend', onTouchEnd);
list.removeEventListener('touchcancel', onTouchCancel);
};
// setLiveDrag/commit are stable useCallbacks; scrollRef is stable.
// `snap` is mirrored via snapRef written above on every render.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [scrollRef, setLiveDrag, commit]);
}

View file

@ -0,0 +1,211 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import {
CHIP_GAP_PX,
CHIP_ROW_PX,
CURTAIN_BREATHER_PX,
CURTAIN_SNAP_MS,
SEARCH_FORM_BASE_PX,
TABS_ROW_PX,
} from './geometry';
// Discrete snap stops for the curtain. The curtain's resting `top`
// is derived from this value plus the live finger drag delta. There
// is no separate «active vs peek» mode flag — the snap encodes both.
export type CurtainSnap =
| 'closed' // curtain flush under tabs row; nothing peeking
| 'peek1' // search chip revealed
| 'peek2' // search + new-chat chips revealed
| 'form-search' // full search form revealed
| 'form-chat'; // full new-chat form revealed
export const isFormSnap = (
snap: CurtainSnap
): snap is 'form-search' | 'form-chat' =>
snap === 'form-search' || snap === 'form-chat';
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek1' | 'peek2' =>
snap === 'peek1' || snap === 'peek2';
// Form kind currently rendered in the header. Stays set during the
// curtain's close transition so the form has content to slide behind;
// cleared by `acknowledgeClosed` after the snap settles at `closed`.
export type ActiveForm = 'search' | 'chat' | null;
export type CurtainState = {
snap: CurtainSnap;
activeForm: ActiveForm;
// Live finger delta in px. Added to the snap-derived resting top to
// compute the curtain's visible top. Stays at 0 when no gesture is
// in flight. Positive = finger pulled down (peek reveal); negative =
// finger pulled up (close gesture).
liveDragPx: number;
// True while a touch gesture is in flight. Controls the curtain's
// `top` transition: disabled while dragging (curtain tracks finger
// 1:1), restored on release so the snap commit animates smoothly.
isDragging: boolean;
// Live measured height of the active form's outer; used to compute
// the curtain's resting `top` when `snap === 'form-*'`. `null` while
// no form is mounted.
formHeightPx: number | null;
// Ref pointing at the rendered form's outer — a ResizeObserver
// watches this to feed `formHeightPx`. Consumer attaches it to the
// form's wrapping element.
formMeasureRef: React.RefObject<HTMLDivElement>;
// Open a form. Sets `snap` and `activeForm` synchronously.
open: (form: 'search' | 'chat') => void;
// Close the curtain (raise it back to `closed`). Keeps `activeForm`
// set until the snap transition lands so the form stays mounted
// during the slide-up.
close: () => void;
// Commit a snap stop directly. Used by the touch gesture on release.
// Also resets `liveDragPx` and `isDragging` in one batched update.
commit: (next: CurtainSnap) => void;
// Setter for the live drag delta — called from the touch gesture on
// every touchmove. Updates are batched by React inside event handlers.
setLiveDrag: (px: number, dragging: boolean) => void;
// Notify the hook that the curtain reached `closed` so any lingering
// form can be unmounted (called from the curtain element's
// `onTransitionEnd`).
acknowledgeClosed: () => void;
};
// Resting `top` (px) of the curtain for a given snap stop and the
// currently measured form height (null falls back to the base).
// Snap-top math mirrors the DOM layout of the chip stack so the
// curtain's resting edge always lands on the boundary of the next
// row (no «next chip pill peeking through» bug). Chip rows are 56px
// each; between them is `CHIP_GAP_PX` (chip-to-chip, tighter); after
// the last revealed chip is `CURTAIN_BREATHER_PX` (chip-to-curtain,
// wider). Forms use just the breather.
export function snapTopPx(snap: CurtainSnap, formH: number | null): number {
switch (snap) {
case 'closed':
return TABS_ROW_PX;
case 'peek1':
return TABS_ROW_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX;
case 'peek2':
return (
TABS_ROW_PX +
CHIP_ROW_PX +
CHIP_GAP_PX +
CHIP_ROW_PX +
CURTAIN_BREATHER_PX
);
case 'form-search':
case 'form-chat':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX;
default:
return TABS_ROW_PX;
}
}
export function useCurtainState(): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
const [liveDragPx, setLiveDragPx] = useState(0);
const [isDragging, setIsDragging] = useState(false);
const formMeasureRef = useRef<HTMLDivElement>(null);
const open = useCallback((form: 'search' | 'chat') => {
setActiveForm(form);
setSnap(form === 'search' ? 'form-search' : 'form-chat');
setLiveDragPx(0);
setIsDragging(false);
}, []);
const close = useCallback(() => {
setSnap('closed');
setLiveDragPx(0);
setIsDragging(false);
}, []);
const commit = useCallback((next: CurtainSnap) => {
setSnap(next);
setLiveDragPx(0);
setIsDragging(false);
if (isFormSnap(next)) {
setActiveForm(next === 'form-search' ? 'search' : 'chat');
}
// Note: when committing to a non-form snap (peek*/closed) we do
// NOT clear `activeForm` here — it stays set so the closing
// transition has form content beneath. `acknowledgeClosed` clears
// it once the curtain settles at `closed`.
}, []);
const setLiveDrag = useCallback((px: number, dragging: boolean) => {
setLiveDragPx(px);
setIsDragging(dragging);
}, []);
const acknowledgeClosed = useCallback(() => {
if (snap === 'closed') setActiveForm(null);
}, [snap]);
// Measure the form's outer height while it's mounted. We do NOT
// overwrite the measured height to null on unmount — keeping the
// last-known height stable lets the close transition target the
// same `top` value as the open transition (no jump at SNAP_MS-end).
useLayoutEffect(() => {
if (!activeForm) return undefined;
const el = formMeasureRef.current;
if (!el) return undefined;
const measure = () => setFormHeightPx(el.offsetHeight);
measure();
const ro = new ResizeObserver(measure);
ro.observe(el);
return () => ro.disconnect();
}, [activeForm]);
// Safety-net for missed `transitionend` (route unmount mid-anim,
// browser quirks). Once snap settles at `closed`, force-drop the
// form after a generous window past the snap duration.
const timerRef = useRef<number | null>(null);
useEffect(() => {
if (snap !== 'closed') {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
return undefined;
}
timerRef.current = window.setTimeout(() => {
setActiveForm(null);
}, CURTAIN_SNAP_MS + 200);
return () => {
if (timerRef.current !== null) {
window.clearTimeout(timerRef.current);
timerRef.current = null;
}
};
}, [snap]);
return useMemo(
() => ({
snap,
activeForm,
liveDragPx,
isDragging,
formHeightPx,
formMeasureRef,
open,
close,
commit,
setLiveDrag,
acknowledgeClosed,
}),
[
snap,
activeForm,
liveDragPx,
isDragging,
formHeightPx,
open,
close,
commit,
setLiveDrag,
acknowledgeClosed,
]
);
}

View file

@ -37,8 +37,17 @@ const FALLBACK_SERVER = 'vojo.chat';
type CreateChatProps = {
defaultUserId?: string;
// Called after a successful create or after navigating to an
// already-existing DM. Inline header mount uses this to retract the
// curtain over the form; the legacy `/direct/_create` route leaves it
// unset.
onCreated?: (roomId: string) => void;
// `gap` between form blocks. Default `'500'` matches the legacy
// standalone page. The inline header passes `'400'` for a tighter
// visual.
gap?: '400' | '500';
};
export function CreateChat({ defaultUserId }: CreateChatProps) {
export function CreateChat({ defaultUserId, onCreated, gap = '500' }: CreateChatProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const alive = useAlive();
@ -112,6 +121,7 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
if (existing) {
usernameInput.value = '';
navigate(getDirectRoomPath(existing.roomId));
onCreated?.(existing.roomId);
return;
}
@ -119,12 +129,13 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
if (alive()) {
usernameInput.value = '';
navigate(getDirectRoomPath(roomId));
onCreated?.(roomId);
}
});
};
return (
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap="500">
<Box as="form" onSubmit={handleSubmit} grow="Yes" direction="Column" gap={gap}>
<Box direction="Column" gap="100">
<Box direction="Row" wrap="Wrap" gap="200">
<Box grow="Yes" style={{ minWidth: toRem(120) }} direction="Column" gap="100">
@ -194,13 +205,15 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
<Icon src={Icons.Warning} filled size="100" />
<Text size="T300" style={{ color: color.Critical.Main }}>
<b>
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
? t('Direct.rate_limited', {
minutes: millisecondsToMinutes(
(error.data.retry_after_ms as number | undefined) ?? 0
),
})
: error.message}
{(() => {
if (error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED) {
const ra = error.data?.retry_after_ms;
return t('Direct.rate_limited', {
minutes: millisecondsToMinutes(typeof ra === 'number' ? ra : 0),
});
}
return error.message;
})()}
</b>
</Text>
</Box>

View file

@ -15,30 +15,11 @@ import {
Text,
toRem,
} from 'folds';
import React, {
ChangeEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import React, { useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { isKeyHotkey } from 'is-hotkey';
import { useAtom, useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk';
import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList';
import { useAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { mDirectAtom } from '../../state/mDirectList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import {
getAllParents,
@ -46,91 +27,17 @@ import {
getRoomAvatarUrl,
guessPerfectParent,
} from '../../utils/room';
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { factoryRoomIdByActivity } from '../../utils/sort';
import { highlightText } from '../../plugins/react-custom-html-parser';
import { nameInitials } from '../../utils/common';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useListFocusIndex } from '../../hooks/useListFocusIndex';
import { getMxIdLocalPart, getMxIdServer, guessDmRoomUserId } from '../../utils/matrix';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix';
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
import { searchModalAtom } from '../../state/searchModal';
import { useKeyDown } from '../../hooks/useKeyDown';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { KeySymbol } from '../../utils/key-symbol';
import { isMacOS } from '../../utils/user-agent';
enum SearchRoomType {
Rooms = '#',
Spaces = '*',
Directs = '@',
}
const getSearchPrefixToRoomType = (prefix: string): SearchRoomType | undefined => {
if (prefix === '#') return SearchRoomType.Rooms;
if (prefix === '*') return SearchRoomType.Spaces;
if (prefix === '@') return SearchRoomType.Directs;
return undefined;
};
const useTopActiveRooms = (
searchRoomType: SearchRoomType | undefined,
rooms: string[],
directs: string[],
spaces: string[]
) => {
const mx = useMatrixClient();
return useMemo(() => {
if (searchRoomType === SearchRoomType.Spaces) {
return spaces;
}
if (searchRoomType === SearchRoomType.Directs) {
return [...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}
if (searchRoomType === SearchRoomType.Rooms) {
return [...rooms].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}
return [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}, [mx, rooms, directs, spaces, searchRoomType]);
};
const getDmUserId = (
roomId: string,
getRoom: (roomId: string) => Room | undefined,
myUserId: string
): string | undefined => {
const room = getRoom(roomId);
const targetUserId = room && guessDmRoomUserId(room, myUserId);
return targetUserId;
};
const useSearchTargetRooms = (
searchRoomType: SearchRoomType | undefined,
rooms: string[],
directs: string[],
spaces: string[]
) =>
useMemo(() => {
if (searchRoomType === undefined) {
return [...rooms, ...directs, ...spaces];
}
if (searchRoomType === SearchRoomType.Rooms) return rooms;
if (searchRoomType === SearchRoomType.Spaces) return spaces;
if (searchRoomType === SearchRoomType.Directs) return directs;
return [];
}, [rooms, spaces, directs, searchRoomType]);
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
matchOptions: {
contain: true,
},
normalizeOptions: {
ignoreWhitespace: false,
},
};
import { getDmUserId, useRoomSearch } from './useRoomSearch';
type SearchProps = {
requestClose: () => void;
@ -139,109 +46,34 @@ export function Search({ requestClose }: SearchProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const scrollRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const { navigateRoom, navigateSpace } = useRoomNavigate();
const roomToUnread = useAtomValue(roomToUnreadAtom);
const [searchRoomType, setSearchRoomType] = useState<SearchRoomType>();
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const mDirects = useAtomValue(mDirectAtom);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const spaces = useSpaces(mx, allRoomsAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces);
const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces);
const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId;
if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId());
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername];
}
return roomName;
const openRoomId = useCallback(
(roomId: string, isSpace: boolean) => {
if (isSpace) navigateSpace(roomId);
else navigateRoom(roomId);
requestClose();
},
[getRoom, mDirects, mx]
[navigateRoom, navigateSpace, requestClose]
);
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
const roomsToRender = result ? result.items : topActiveRooms;
const listFocus = useListFocusIndex(roomsToRender.length, 0);
const queryHighlighRegex = result?.query
? makeHighlightRegex(result.query.split(' '))
: undefined;
const openRoomId = (roomId: string, isSpace: boolean) => {
if (isSpace) navigateSpace(roomId);
else navigateRoom(roomId);
requestClose();
};
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
listFocus.reset();
const target = evt.currentTarget;
let value = target.value.trim();
const prefix = value.match(/^[#@*]/)?.[0];
const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix);
if (searchType) {
value = value.slice(1);
setSearchRoomType(searchType);
} else {
setSearchRoomType(undefined);
}
if (value === '') {
resetSearch();
return;
}
search(value);
};
const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = (evt) => {
const roomId = roomsToRender[listFocus.index];
if (isKeyHotkey('enter', evt) && roomId) {
openRoomId(roomId, spaces.includes(roomId));
return;
}
if (isKeyHotkey('arrowdown', evt)) {
evt.preventDefault();
listFocus.next();
return;
}
if (isKeyHotkey('arrowup', evt)) {
evt.preventDefault();
listFocus.previous();
}
};
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = (evt) => {
const target = evt.currentTarget;
const roomId = target.getAttribute('data-room-id');
const isSpace = target.getAttribute('data-space') === 'true';
if (!roomId) return;
openRoomId(roomId, isSpace);
};
useEffect(() => {
const scrollView = scrollRef.current;
const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`);
if (focusedItem && scrollView) {
focusedItem.scrollIntoView({
block: 'center',
});
}
}, [listFocus.index]);
const {
inputRef,
scrollRef,
roomsToRender,
result,
listFocus,
queryHighlightRegex,
handleInputChange,
handleInputKeyDown,
handleRoomClick,
getRoom,
mDirects,
orphanSpaces,
roomToParents,
roomToUnread,
myUserId,
} = useRoomSearch({ onOpenRoomId: openRoomId });
return (
<Overlay open>
@ -305,13 +137,14 @@ export function Search({ requestClose }: SearchProps) {
if (!room) return null;
const dm = mDirects.has(roomId);
const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId());
const dmUsername = dmUserId && getMxIdLocalPart(dmUserId);
const dmUserServer = dmUserId && getMxIdServer(dmUserId);
const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined;
const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined;
const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined;
const allParents = getAllParents(roomToParents, roomId);
const orphanParents =
allParents && orphanSpaces.filter((o) => allParents.has(o));
const orphanParents = allParents
? orphanSpaces.filter((o) => allParents.has(o))
: undefined;
const perfectOrphanParent =
orphanParents && guessPerfectParent(mx, roomId, orphanParents);
@ -383,15 +216,15 @@ export function Search({ requestClose }: SearchProps) {
>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{queryHighlighRegex
? highlightText(queryHighlighRegex, [room.name])
{queryHighlightRegex
? highlightText(queryHighlightRegex, [room.name])
: room.name}
</Text>
{dmUsername && (
<Text as="span" size="T200" priority="300" truncate>
@
{queryHighlighRegex
? highlightText(queryHighlighRegex, [dmUsername])
{queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername])
: dmUsername}
</Text>
)}

View file

@ -1 +1,2 @@
export * from './Search';
export * from './useRoomSearch';

View file

@ -0,0 +1,205 @@
import {
ChangeEventHandler,
KeyboardEventHandler,
MouseEventHandler,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
import { isKeyHotkey } from 'is-hotkey';
import { Room } from 'matrix-js-sdk';
import { useAtomValue } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList';
import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom';
import { useListFocusIndex } from '../../hooks/useListFocusIndex';
import {
SearchItemStrGetter,
useAsyncSearch,
UseAsyncSearchOptions,
} from '../../hooks/useAsyncSearch';
import { mDirectAtom } from '../../state/mDirectList';
import { allRoomsAtom } from '../../state/room-list/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { factoryRoomIdByActivity } from '../../utils/sort';
import { getMxIdLocalPart, guessDmRoomUserId } from '../../utils/matrix';
import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
export enum SearchRoomType {
Rooms = '#',
Spaces = '*',
Directs = '@',
}
const PREFIX_TO_TYPE: Record<string, SearchRoomType | undefined> = {
'#': SearchRoomType.Rooms,
'*': SearchRoomType.Spaces,
'@': SearchRoomType.Directs,
};
const SEARCH_OPTIONS: UseAsyncSearchOptions = {
matchOptions: { contain: true },
normalizeOptions: { ignoreWhitespace: false },
};
export const getDmUserId = (
roomId: string,
getRoom: (roomId: string) => Room | undefined,
myUserId: string
): string | undefined => {
const room = getRoom(roomId);
return room && guessDmRoomUserId(room, myUserId);
};
type UseRoomSearchOptions = {
// Called after the user commits a room selection (click or Enter).
// Modal Search uses this to close itself; the inline header form uses
// it to retract the curtain. The hook itself doesn't navigate — the
// caller decides via `onOpenRoomId(roomId, isSpace)`.
onOpenRoomId: (roomId: string, isSpace: boolean) => void;
};
// Single source of truth for the room/space/direct search panel —
// prefix detection (`#`/`*`/`@`), filtered scope, top-active fallback,
// keyboard navigation, focus auto-scroll, and result-highlight regex.
// Used by both the global `Search` modal and the inline header form.
export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
const mx = useMatrixClient();
const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null);
const [searchRoomType, setSearchRoomType] = useState<SearchRoomType>();
const allRoomsSet = useAllJoinedRoomsSet();
const getRoom = useGetRoom(allRoomsSet);
const roomToParents = useAtomValue(roomToParentsAtom);
const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents);
const mDirects = useAtomValue(mDirectAtom);
const rooms = useRooms(mx, allRoomsAtom, mDirects);
const spaces = useSpaces(mx, allRoomsAtom);
const directs = useDirects(mx, allRoomsAtom, mDirects);
const roomToUnread = useAtomValue(roomToUnreadAtom);
const topActiveRooms = useMemo(() => {
if (searchRoomType === SearchRoomType.Spaces) return spaces;
if (searchRoomType === SearchRoomType.Directs) {
return [...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}
if (searchRoomType === SearchRoomType.Rooms) {
return [...rooms].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}
return [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20);
}, [mx, rooms, directs, spaces, searchRoomType]);
const targetRooms = useMemo(() => {
if (searchRoomType === undefined) return [...rooms, ...directs, ...spaces];
if (searchRoomType === SearchRoomType.Rooms) return rooms;
if (searchRoomType === SearchRoomType.Spaces) return spaces;
if (searchRoomType === SearchRoomType.Directs) return directs;
return [];
}, [rooms, spaces, directs, searchRoomType]);
const myUserId = mx.getSafeUserId();
const getTargetStr: SearchItemStrGetter<string> = useCallback(
(roomId: string) => {
const roomName = getRoom(roomId)?.name ?? roomId;
if (mDirects.has(roomId)) {
const targetUserId = getDmUserId(roomId, getRoom, myUserId);
const targetUsername = targetUserId && getMxIdLocalPart(targetUserId);
if (targetUsername) return [roomName, targetUsername];
}
return roomName;
},
[getRoom, mDirects, myUserId]
);
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
const roomsToRender = result ? result.items : topActiveRooms;
const listFocus = useListFocusIndex(roomsToRender.length, 0);
const queryHighlightRegex = result?.query
? makeHighlightRegex(result.query.split(' '))
: undefined;
const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(evt) => {
listFocus.reset();
const target = evt.currentTarget;
let value = target.value.trim();
const prefix = value.match(/^[#@*]/)?.[0];
const nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined;
setSearchRoomType(nextType);
if (nextType) value = value.slice(1);
if (value === '') {
resetSearch();
return;
}
search(value);
},
[listFocus, search, resetSearch]
);
const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
(evt) => {
const roomId = roomsToRender[listFocus.index];
if (isKeyHotkey('enter', evt) && roomId) {
onOpenRoomId(roomId, spaces.includes(roomId));
return;
}
if (isKeyHotkey('arrowdown', evt)) {
evt.preventDefault();
listFocus.next();
return;
}
if (isKeyHotkey('arrowup', evt)) {
evt.preventDefault();
listFocus.previous();
}
},
[roomsToRender, listFocus, onOpenRoomId, spaces]
);
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const target = evt.currentTarget;
const roomId = target.getAttribute('data-room-id');
const isSpace = target.getAttribute('data-space') === 'true';
if (!roomId) return;
onOpenRoomId(roomId, isSpace);
},
[onOpenRoomId]
);
// Auto-scroll the highlighted item into view as the user arrows.
useEffect(() => {
const scrollView = scrollRef.current;
const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`);
if (focusedItem && scrollView) {
focusedItem.scrollIntoView({ block: 'nearest' });
}
}, [listFocus.index]);
return {
inputRef,
scrollRef,
roomsToRender,
searchRoomType,
result,
listFocus,
queryHighlightRegex,
handleInputChange,
handleInputKeyDown,
handleRoomClick,
// Per-row context the consumer needs to render rows:
getRoom,
mDirects,
spaces,
orphanSpaces,
roomToParents,
roomToUnread,
myUserId,
};
}

View file

@ -28,10 +28,10 @@ export const container = style({
});
// === App body === Holds the wrapped children (the DM list — header,
// scroll content, DirectNewChatRow, DirectSelfRow). Fills the
// container via `inset: 0`. Does NOT translate or shrink — the DM
// list stays exactly where it was in the closed state. Instead, the
// bottom of the pane is masked away by an animated `clip-path:
// scroll content, DirectSelfRow). Fills the container via `inset: 0`.
// Does NOT translate or shrink — the DM list stays exactly where it
// was in the closed state. Instead, the bottom of the pane is masked
// away by an animated `clip-path:
// inset(...)` with rounded BL/BR corners — the user sees the visible
// top portion of the DM list with a rounded carve at the new bottom
// edge, exactly like the profile horseshoe shows the chat with a
@ -42,7 +42,7 @@ export const container = style({
// and the DM list's `@tanstack/react-virtual` re-measures items mid-
// gesture. Why not `transform: translateY` either: translating moves
// the whole pane up off the top of the viewport, including the
// DirectStreamHeader the user wants to keep visible. Clip-path leaves
// StreamHeader the user wants to keep visible. Clip-path leaves
// layout unchanged — the DM list keeps its scroll position, its
// measured heights, and its top items in place; only the bottom edge
// of what's visible gets carved into the void below.

View file

@ -7,15 +7,15 @@
//
// User-visible behaviour:
//
// • The wrapped app body (DirectStreamHeader → DM list → new-chat
// row → DirectSelfRow) stays exactly where it was — no translate,
// • The wrapped app body (StreamHeader → DM list → DirectSelfRow)
// stays exactly where it was — no translate,
// no shrink. The bottom of the visible portion is "masked away"
// by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx
// Rpx)` with rounded BL/BR carves at the new visible edge. The
// carved area exposes the container's void colour underneath, and
// the silhouette below covers the rest of the masked zone. Why
// not `transform: translateY`: translating moves the TOP of the
// pane out of the viewport (DirectStreamHeader scrolls off-
// pane out of the viewport (StreamHeader scrolls off-
// screen). Why not `flex-shrink + margin-bottom`: the virtualized
// DM list (`@tanstack/react-virtual`) re-measures items every
// time the scroll container resizes — items above the shrinking

View file

@ -250,7 +250,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
}
/>
</Route>
{/* Bots reuses DirectStreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
{/* Bots reuses StreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */}
<Route
path={BOTS_PATH}
element={

View file

@ -1,15 +1,15 @@
import React from 'react';
import React, { useRef } from 'react';
import { Box, color, config, toRem } from 'folds';
import { useMatch } from 'react-router-dom';
import { PageNav, PageNavContent } from '../../../components/page';
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
import { StreamHeader } from '../../../components/stream-header';
import { useBotPresets } from '../../../features/bots/catalog';
import type { BotPreset } from '../../../features/bots/catalog';
import { BotCard } from '../../../features/bots/BotCard';
import { BOTS_BOT_PATH } from '../../paths';
// Static preset links only. Room discovery belongs to /bots/:botId so the list
// doesn't install Matrix listeners or scan rooms for every catalog entry.
// Static preset links only. Room discovery belongs to /bots/:botId so the
// list doesn't install Matrix listeners or scan rooms for every catalog entry.
function BotRow({ preset }: { preset: BotPreset }) {
// `end: false` so future child routes under :botId keep the parent card
// selected. RR-v6 already URL-decodes `params.botId`, no extra decode here.
@ -20,24 +20,29 @@ function BotRow({ preset }: { preset: BotPreset }) {
export function Bots() {
const bots = useBotPresets();
// `scrollRef` is passed to the header so the touch gesture (native
// only) can recognise list scrollTop=0 and engage the curtain peek.
// Icons + click flows work on every platform regardless.
const scrollRef = useRef<HTMLDivElement>(null);
return (
<PageNav resizable>
<DirectStreamHeader />
<PageNavContent>
<Box
direction="Column"
gap="100"
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
{bots.map((preset) => (
<BotRow key={preset.id} preset={preset} />
))}
</Box>
</PageNavContent>
<PageNav resizable surface="surfaceVariant">
<StreamHeader scrollRef={scrollRef}>
<PageNavContent scrollRef={scrollRef}>
<Box
direction="Column"
gap="100"
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
{bots.map((preset) => (
<BotRow key={preset.id} preset={preset} />
))}
</Box>
</PageNavContent>
</StreamHeader>
</PageNav>
);
}

View file

@ -1,48 +1,49 @@
import React, { useEffect, useRef } from 'react';
import { useSpace } from '../../../hooks/useSpace';
import { PageNav, PageNavContent } from '../../../components/page';
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
import { StreamHeader } from '../../../components/stream-header';
import { ChannelsList } from './ChannelsList';
import { ChannelCreateRow } from './ChannelCreateRow';
import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe';
import { WorkspaceFooter } from './WorkspaceFooter';
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// Stub nav rendered at the /channels/ index route when the user has no
// orphan spaces yet. Provides the segment switcher so they can navigate
// back to DM / Bots without going through the global rail. The list /
// footer panes only make sense once a Space is in context.
// Index route at /channels/ (no space selected). Renders the shared
// StreamHeader so the segment switcher is consistent across surfaces,
// but no list/footer — those need a Space context.
export function ChannelsRootNav() {
const scrollRef = useRef<HTMLDivElement>(null);
return (
<PageNav resizable>
<DirectStreamHeader />
<PageNavContent>
<div />
</PageNavContent>
<PageNav resizable surface="surfaceVariant">
<StreamHeader scrollRef={scrollRef}>
<PageNavContent scrollRef={scrollRef}>
{/* Empty list slot is intentional: StreamHeader needs a real
scroll target on this route for its touch gesture even
though there's no list yet. */}
<div />
</PageNavContent>
</StreamHeader>
</PageNav>
);
}
// Channels left pane (Mattermost-style). Renders the segment switcher at
// the top, the joined-rooms hierarchy in the middle (sub-spaces grouped),
// and the workspace identity plate at the bottom. M6 will make the footer
// a dropdown for users with 2+ orphan spaces; in M1 it is read-only.
// Channels left pane (Mattermost-style). Segment switcher at the top,
// joined-rooms hierarchy in the middle, workspace identity plate at
// the bottom. M6 will turn the footer into a dropdown for users with
// 2+ orphan spaces; in M1 it is read-only.
//
// Note: we deliberately do NOT call `useNavToActivePathMapper` here. That
// atom is owned by the legacy /<space>/ tree (Space.tsx) so the rail's
// "click avatar → resume your last in-space path" behaviour stays
// well-defined. Channels has its own segment-level navigation, so a
// rail-click on a channels-active space lands on the legacy lobby — that's
// fine for M1; users can re-enter channels via the DirectStreamHeader.
// Note: deliberately do NOT call `useNavToActivePathMapper` here. That
// atom is owned by the legacy /<space>/ tree (Space.tsx) so the
// global rail's «click avatar → resume your last in-space path» stays
// well-defined. Channels has its own segment-level navigation.
export function Channels() {
const space = useSpace();
const scrollRef = useRef<HTMLDivElement>(null);
// Persist URL-driven active space so cold-starts at /channels/ resume on
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
// value but never writes it because the index route has no :spaceIdOrAlias
// param — write happens HERE, where RouteSpaceProvider has already
// resolved the space from the URL.
// value but never writes it because the index route has no
// :spaceIdOrAlias param — the write happens here.
useEffect(() => {
try {
localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId);
@ -52,21 +53,21 @@ export function Channels() {
}, [space.roomId]);
return (
<PageNav resizable>
{/* The horseshoe wraps the entire channels column so the workspace
switcher sheet slides up from the bottom of THIS PageNav slot
(not the viewport). The wrapped children stay put; their
bottom edge is carved by an animated clip-path with a 12px
void between the carve and the sliding silhouette. See
ChannelsWorkspaceHorseshoe.tsx for the canonical idioms it
inherits from MobileSettingsHorseshoe (commit a7d6fc2). */}
<PageNav resizable surface="surfaceVariant">
<ChannelsWorkspaceHorseshoe space={space}>
<DirectStreamHeader />
<PageNavContent scrollRef={scrollRef}>
<ChannelsList scrollRef={scrollRef} />
</PageNavContent>
<ChannelCreateRow space={space} />
<WorkspaceFooter space={space} />
<StreamHeader
scrollRef={scrollRef}
bottomPinned={
<>
<ChannelCreateRow space={space} />
<WorkspaceFooter space={space} />
</>
}
>
<PageNavContent scrollRef={scrollRef}>
<ChannelsList scrollRef={scrollRef} />
</PageNavContent>
</StreamHeader>
</ChannelsWorkspaceHorseshoe>
</PageNav>
);

View file

@ -25,7 +25,7 @@ export const container = style({
overflow: 'hidden',
});
// Wrapped children (DirectStreamHeader → ChannelsList → ChannelCreateRow →
// Wrapped children (StreamHeader → 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

View file

@ -21,8 +21,7 @@ import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { DirectStreamHeader } from './DirectStreamHeader';
import { DirectNewChatRow } from './DirectNewChatRow';
import { StreamHeader } from '../../../components/stream-header';
import { DirectSelfRow } from './DirectSelfRow';
import { MobileSettingsHorseshoe } from '../../../features/settings';
@ -79,6 +78,7 @@ function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) {
aria-expanded={expanded}
style={{
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
background: 'transparent',
cursor: 'pointer',
@ -118,10 +118,13 @@ export function Direct() {
// reactions — anything roomToUnread misses. Uses the 'Room.timeline'
// literal (= RoomEvent.Timeline) to avoid named imports from matrix-js-sdk
// that the project's moduleResolution flags as TS2614 (see
// docs/known-tech-debt-lint/).
// docs/known-tech-debt-lint/). `directs` is read via a ref so the listener
// doesn't re-bind on every set-identity change (heavy sessions can flip
// the set 5-10× during sync).
const [, setActivityTick] = useState(0);
const directsRef = useRef(directs);
directsRef.current = directs;
useEffect(() => {
const directsSet = new Set(directs);
const handleTimeline = (
_ev: unknown,
room: { roomId: string } | undefined,
@ -130,7 +133,7 @@ export function Direct() {
data: { liveEvent?: boolean } | undefined
) => {
if (!room || !data?.liveEvent) return;
if (!directsSet.has(room.roomId)) return;
if (!directsRef.current.includes(room.roomId)) return;
setActivityTick((n) => n + 1);
};
const emitter = mx as unknown as {
@ -141,7 +144,7 @@ export function Direct() {
return () => {
emitter.removeListener('Room.timeline', handleTimeline);
};
}, [mx, directs]);
}, [mx]);
const selectedRoomId = useSelectedRoom();
@ -199,98 +202,85 @@ export function Direct() {
});
return (
<PageNav resizable>
{/* On mobile, `MobileSettingsHorseshoe` wraps the whole DM
list (including DirectSelfRow). The bottom-up Settings sheet
opens via tap on DirectSelfRow OR drag-up from the row
(`data-settings-drag-origin` marks the drag-target). The
app body STAYS IN PLACE only the bottom is carved away by
an animated `clip-path: inset(...)` with rounded BL/BR; the
sheet emerges below with rounded TL/TR, separated by a 12px
void. On non-mobile the wrapper is a pass-through, so the
desktop layout is unchanged. */}
<PageNav resizable surface="surfaceVariant">
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
Settings sheet can carve into the bottom of this pane. On non-mobile
it's a pass-through. */}
<MobileSettingsHorseshoe>
<DirectStreamHeader />
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<div
style={{
position: 'relative',
height: virtualizer.getTotalSize(),
}}
>
{virtualizer.getVirtualItems().map((vItem) => {
const item = items[vItem.index];
if (!item) return null;
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />}>
{noRoomToDisplay ? (
<DirectEmpty />
) : (
<PageNavContent scrollRef={scrollRef}>
<Box direction="Column" gap="300">
<NavCategory>
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map((vItem) => {
const item = items[vItem.index];
if (!item) return null;
if (item.kind === 'invite' || item.kind === 'spam-invite') {
const { entry } = item;
const selected = selectedRoomId === entry.roomId;
if (item.kind === 'invite' || item.kind === 'spam-invite') {
const { entry } = item;
const selected = selectedRoomId === entry.roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`invite-${entry.roomId}`}
ref={virtualizer.measureElement}
>
<DirectInviteRow
room={entry.room}
selected={selected}
isSpam={item.kind === 'spam-invite'}
/>
</VirtualTile>
);
}
if (item.kind === 'spam-toggle') {
return (
<VirtualTile
virtualItem={vItem}
key="spam-toggle"
ref={virtualizer.measureElement}
>
<SpamToggleRow
spamCount={item.spamCount}
expanded={item.expanded}
onToggle={() => setSpamExpanded((v) => !v)}
/>
</VirtualTile>
);
}
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`invite-${entry.roomId}`}
key={`direct-${roomId}`}
ref={virtualizer.measureElement}
>
<DirectInviteRow
room={entry.room}
<DmStreamRow
room={room}
selected={selected}
isSpam={item.kind === 'spam-invite'}
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
}
if (item.kind === 'spam-toggle') {
return (
<VirtualTile
virtualItem={vItem}
key="spam-toggle"
ref={virtualizer.measureElement}
>
<SpamToggleRow
spamCount={item.spamCount}
expanded={item.expanded}
onToggle={() => setSpamExpanded((v) => !v)}
/>
</VirtualTile>
);
}
// kind === 'direct'
const { roomId } = item;
const room = mx.getRoom(roomId);
if (!room) return null;
const selected = selectedRoomId === roomId;
return (
<VirtualTile
virtualItem={vItem}
key={`direct-${roomId}`}
ref={virtualizer.measureElement}
>
<DmStreamRow
room={room}
selected={selected}
linkPath={getDirectRoomPath(getCanonicalAliasOrRoomId(mx, roomId))}
notificationMode={getRoomNotificationMode(
notificationPreferences,
room.roomId
)}
/>
</VirtualTile>
);
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
<DirectNewChatRow />
<DirectSelfRow />
})}
</div>
</NavCategory>
</Box>
</PageNavContent>
)}
</StreamHeader>
</MobileSettingsHorseshoe>
</PageNav>
);

View file

@ -1,57 +0,0 @@
import React from 'react';
import { useTranslation } from 'react-i18next';
import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds';
import { useNavigate } from 'react-router-dom';
import { NavButton, NavItem, NavItemContent } from '../../../components/nav';
import { getDirectCreatePath } from '../../pathUtils';
import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected';
const ROW_MIN_HEIGHT = toRem(56);
export function DirectNewChatRow() {
const { t } = useTranslation();
const navigate = useNavigate();
const selected = useDirectCreateSelected();
return (
<Box
shrink="No"
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<NavItem
variant="Background"
radii="400"
aria-selected={selected}
style={{ minHeight: ROW_MIN_HEIGHT }}
>
<NavButton onClick={() => navigate(getDirectCreatePath())}>
<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('Direct.create_chat')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</Box>
);
}

View file

@ -1,96 +0,0 @@
import React, { forwardRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useMatch, useNavigate } from 'react-router-dom';
import { Box, color, toRem } from 'folds';
import { PageNavHeader } from '../../../components/page';
import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../paths';
import { isNativePlatform } from '../../../utils/capacitor';
import { useBotPresets } from '../../../features/bots/catalog';
type SegmentProps = {
active: boolean;
disabled?: boolean;
label: string;
onClick?: () => void;
};
const Segment = forwardRef<HTMLButtonElement, SegmentProps>(
({ active, disabled, label, onClick }, ref) => (
<button
ref={ref}
type="button"
onClick={disabled ? undefined : onClick}
aria-pressed={active}
aria-disabled={disabled || undefined}
style={{
appearance: 'none',
border: 'none',
background: 'transparent',
color: color.Background.OnContainer,
opacity: disabled ? 0.45 : 1,
cursor: disabled ? 'default' : 'pointer',
padding: `${toRem(6)} ${toRem(10)}`,
borderRadius: toRem(6),
font: 'inherit',
fontWeight: active ? 600 : 500,
fontSize: toRem(13),
lineHeight: 1.2,
flex: 1,
position: 'relative',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
{label}
{active && (
<span
style={{
position: 'absolute',
left: 0,
right: 0,
bottom: `-${toRem(1)}`,
height: toRem(2),
backgroundColor: color.Primary.Main,
}}
/>
)}
</button>
)
);
export function DirectStreamHeader() {
const { t } = useTranslation();
const navigate = useNavigate();
const bots = useBotPresets();
const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false });
const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false });
const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false });
const showBotsSegment = bots.length > 0 || !!botsMatch;
const navOpts = { replace: isNativePlatform() };
return (
<PageNavHeader style={{ padding: 0 }}>
<Box alignItems="Stretch" grow="Yes" style={{ alignSelf: 'stretch' }}>
<Segment
active={!!directMatch}
label={t('Direct.segment_dm')}
onClick={() => navigate(DIRECT_PATH, navOpts)}
/>
<Segment
active={!!channelsMatch}
label={t('Direct.segment_channels')}
onClick={() => navigate(CHANNELS_PATH, navOpts)}
/>
{showBotsSegment && (
<Segment
active={!!botsMatch}
label={t('Direct.segment_bots')}
onClick={() => navigate(BOTS_PATH, navOpts)}
/>
)}
</Box>
</PageNavHeader>
);
}