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:
parent
81d23be61f
commit
0eb2e056c0
25 changed files with 1741 additions and 523 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -394,7 +394,8 @@
|
|||
"e2e_encryption": "Сквозное шифрование",
|
||||
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
|
||||
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
||||
"create": "Создать"
|
||||
"create": "Создать",
|
||||
"close": "Закрыть"
|
||||
},
|
||||
"Channels": {
|
||||
"no_spaces_title": "Пока нет сообществ",
|
||||
|
|
|
|||
32
src/app/components/stream-header/Chip.tsx
Normal file
32
src/app/components/stream-header/Chip.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
src/app/components/stream-header/Segment.tsx
Normal file
28
src/app/components/stream-header/Segment.tsx
Normal 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';
|
||||
233
src/app/components/stream-header/StreamHeader.css.ts
Normal file
233
src/app/components/stream-header/StreamHeader.css.ts
Normal 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)}`,
|
||||
});
|
||||
291
src/app/components/stream-header/StreamHeader.tsx
Normal file
291
src/app/components/stream-header/StreamHeader.tsx
Normal 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 };
|
||||
18
src/app/components/stream-header/forms/InlineNewChatForm.tsx
Normal file
18
src/app/components/stream-header/forms/InlineNewChatForm.tsx
Normal 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} />;
|
||||
}
|
||||
244
src/app/components/stream-header/forms/InlineRoomSearch.tsx
Normal file
244
src/app/components/stream-header/forms/InlineRoomSearch.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
62
src/app/components/stream-header/geometry.ts
Normal file
62
src/app/components/stream-header/geometry.ts
Normal 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;
|
||||
2
src/app/components/stream-header/index.ts
Normal file
2
src/app/components/stream-header/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export { StreamHeader } from './StreamHeader';
|
||||
export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry';
|
||||
200
src/app/components/stream-header/useCurtainGesture.ts
Normal file
200
src/app/components/stream-header/useCurtainGesture.ts
Normal 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]);
|
||||
}
|
||||
211
src/app/components/stream-header/useCurtainState.ts
Normal file
211
src/app/components/stream-header/useCurtainState.ts
Normal 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,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1 +1,2 @@
|
|||
export * from './Search';
|
||||
export * from './useRoomSearch';
|
||||
|
|
|
|||
205
src/app/features/search/useRoomSearch.ts
Normal file
205
src/app/features/search/useRoomSearch.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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={
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue