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": "End-to-End Encryption",
|
||||||
"e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.",
|
"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!",
|
"rate_limited": "Server rate-limited your request for {{minutes}} minutes!",
|
||||||
"create": "Create"
|
"create": "Create",
|
||||||
|
"close": "Close"
|
||||||
},
|
},
|
||||||
"Channels": {
|
"Channels": {
|
||||||
"no_spaces_title": "No communities yet",
|
"no_spaces_title": "No communities yet",
|
||||||
|
|
|
||||||
|
|
@ -394,7 +394,8 @@
|
||||||
"e2e_encryption": "Сквозное шифрование",
|
"e2e_encryption": "Сквозное шифрование",
|
||||||
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
|
"e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.",
|
||||||
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
"rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!",
|
||||||
"create": "Создать"
|
"create": "Создать",
|
||||||
|
"close": "Закрыть"
|
||||||
},
|
},
|
||||||
"Channels": {
|
"Channels": {
|
||||||
"no_spaces_title": "Пока нет сообществ",
|
"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 = {
|
type CreateChatProps = {
|
||||||
defaultUserId?: string;
|
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 { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const alive = useAlive();
|
const alive = useAlive();
|
||||||
|
|
@ -112,6 +121,7 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
||||||
if (existing) {
|
if (existing) {
|
||||||
usernameInput.value = '';
|
usernameInput.value = '';
|
||||||
navigate(getDirectRoomPath(existing.roomId));
|
navigate(getDirectRoomPath(existing.roomId));
|
||||||
|
onCreated?.(existing.roomId);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -119,12 +129,13 @@ export function CreateChat({ defaultUserId }: CreateChatProps) {
|
||||||
if (alive()) {
|
if (alive()) {
|
||||||
usernameInput.value = '';
|
usernameInput.value = '';
|
||||||
navigate(getDirectRoomPath(roomId));
|
navigate(getDirectRoomPath(roomId));
|
||||||
|
onCreated?.(roomId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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="Column" gap="100">
|
||||||
<Box direction="Row" wrap="Wrap" gap="200">
|
<Box direction="Row" wrap="Wrap" gap="200">
|
||||||
<Box grow="Yes" style={{ minWidth: toRem(120) }} direction="Column" gap="100">
|
<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" />
|
<Icon src={Icons.Warning} filled size="100" />
|
||||||
<Text size="T300" style={{ color: color.Critical.Main }}>
|
<Text size="T300" style={{ color: color.Critical.Main }}>
|
||||||
<b>
|
<b>
|
||||||
{error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED
|
{(() => {
|
||||||
? t('Direct.rate_limited', {
|
if (error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED) {
|
||||||
minutes: millisecondsToMinutes(
|
const ra = error.data?.retry_after_ms;
|
||||||
(error.data.retry_after_ms as number | undefined) ?? 0
|
return t('Direct.rate_limited', {
|
||||||
),
|
minutes: millisecondsToMinutes(typeof ra === 'number' ? ra : 0),
|
||||||
})
|
});
|
||||||
: error.message}
|
}
|
||||||
|
return error.message;
|
||||||
|
})()}
|
||||||
</b>
|
</b>
|
||||||
</Text>
|
</Text>
|
||||||
</Box>
|
</Box>
|
||||||
|
|
|
||||||
|
|
@ -15,30 +15,11 @@ import {
|
||||||
Text,
|
Text,
|
||||||
toRem,
|
toRem,
|
||||||
} from 'folds';
|
} from 'folds';
|
||||||
import React, {
|
import React, { useCallback } from 'react';
|
||||||
ChangeEventHandler,
|
|
||||||
KeyboardEventHandler,
|
|
||||||
MouseEventHandler,
|
|
||||||
useCallback,
|
|
||||||
useEffect,
|
|
||||||
useMemo,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from 'react';
|
|
||||||
import { Trans, useTranslation } from 'react-i18next';
|
import { Trans, useTranslation } from 'react-i18next';
|
||||||
import { isKeyHotkey } from 'is-hotkey';
|
import { isKeyHotkey } from 'is-hotkey';
|
||||||
import { useAtom, useAtomValue } from 'jotai';
|
import { useAtom } from 'jotai';
|
||||||
import { Room } from 'matrix-js-sdk';
|
|
||||||
import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList';
|
|
||||||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
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 { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||||
import {
|
import {
|
||||||
getAllParents,
|
getAllParents,
|
||||||
|
|
@ -46,91 +27,17 @@ import {
|
||||||
getRoomAvatarUrl,
|
getRoomAvatarUrl,
|
||||||
guessPerfectParent,
|
guessPerfectParent,
|
||||||
} from '../../utils/room';
|
} from '../../utils/room';
|
||||||
import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser';
|
import { highlightText } from '../../plugins/react-custom-html-parser';
|
||||||
import { factoryRoomIdByActivity } from '../../utils/sort';
|
|
||||||
import { nameInitials } from '../../utils/common';
|
import { nameInitials } from '../../utils/common';
|
||||||
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
|
||||||
import { useListFocusIndex } from '../../hooks/useListFocusIndex';
|
import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix';
|
||||||
import { getMxIdLocalPart, getMxIdServer, guessDmRoomUserId } from '../../utils/matrix';
|
|
||||||
import { roomToParentsAtom } from '../../state/room/roomToParents';
|
|
||||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
|
||||||
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge';
|
||||||
import { searchModalAtom } from '../../state/searchModal';
|
import { searchModalAtom } from '../../state/searchModal';
|
||||||
import { useKeyDown } from '../../hooks/useKeyDown';
|
import { useKeyDown } from '../../hooks/useKeyDown';
|
||||||
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
|
||||||
import { KeySymbol } from '../../utils/key-symbol';
|
import { KeySymbol } from '../../utils/key-symbol';
|
||||||
import { isMacOS } from '../../utils/user-agent';
|
import { isMacOS } from '../../utils/user-agent';
|
||||||
|
import { getDmUserId, useRoomSearch } from './useRoomSearch';
|
||||||
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,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
type SearchProps = {
|
type SearchProps = {
|
||||||
requestClose: () => void;
|
requestClose: () => void;
|
||||||
|
|
@ -139,109 +46,34 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const inputRef = useRef<HTMLInputElement>(null);
|
|
||||||
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
const { navigateRoom, navigateSpace } = useRoomNavigate();
|
||||||
const roomToUnread = useAtomValue(roomToUnreadAtom);
|
|
||||||
|
|
||||||
const [searchRoomType, setSearchRoomType] = useState<SearchRoomType>();
|
const openRoomId = useCallback(
|
||||||
|
(roomId: string, isSpace: boolean) => {
|
||||||
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;
|
|
||||||
},
|
|
||||||
[getRoom, mDirects, mx]
|
|
||||||
);
|
|
||||||
|
|
||||||
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);
|
if (isSpace) navigateSpace(roomId);
|
||||||
else navigateRoom(roomId);
|
else navigateRoom(roomId);
|
||||||
requestClose();
|
requestClose();
|
||||||
};
|
},
|
||||||
|
[navigateRoom, navigateSpace, requestClose]
|
||||||
|
);
|
||||||
|
|
||||||
const handleInputChange: ChangeEventHandler<HTMLInputElement> = (evt) => {
|
const {
|
||||||
listFocus.reset();
|
inputRef,
|
||||||
|
scrollRef,
|
||||||
const target = evt.currentTarget;
|
roomsToRender,
|
||||||
let value = target.value.trim();
|
result,
|
||||||
const prefix = value.match(/^[#@*]/)?.[0];
|
listFocus,
|
||||||
const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix);
|
queryHighlightRegex,
|
||||||
if (searchType) {
|
handleInputChange,
|
||||||
value = value.slice(1);
|
handleInputKeyDown,
|
||||||
setSearchRoomType(searchType);
|
handleRoomClick,
|
||||||
} else {
|
getRoom,
|
||||||
setSearchRoomType(undefined);
|
mDirects,
|
||||||
}
|
orphanSpaces,
|
||||||
|
roomToParents,
|
||||||
if (value === '') {
|
roomToUnread,
|
||||||
resetSearch();
|
myUserId,
|
||||||
return;
|
} = useRoomSearch({ onOpenRoomId: openRoomId });
|
||||||
}
|
|
||||||
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]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Overlay open>
|
<Overlay open>
|
||||||
|
|
@ -305,13 +137,14 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
||||||
const dm = mDirects.has(roomId);
|
const dm = mDirects.has(roomId);
|
||||||
const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId());
|
const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined;
|
||||||
const dmUsername = dmUserId && getMxIdLocalPart(dmUserId);
|
const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined;
|
||||||
const dmUserServer = dmUserId && getMxIdServer(dmUserId);
|
const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined;
|
||||||
|
|
||||||
const allParents = getAllParents(roomToParents, roomId);
|
const allParents = getAllParents(roomToParents, roomId);
|
||||||
const orphanParents =
|
const orphanParents = allParents
|
||||||
allParents && orphanSpaces.filter((o) => allParents.has(o));
|
? orphanSpaces.filter((o) => allParents.has(o))
|
||||||
|
: undefined;
|
||||||
const perfectOrphanParent =
|
const perfectOrphanParent =
|
||||||
orphanParents && guessPerfectParent(mx, roomId, orphanParents);
|
orphanParents && guessPerfectParent(mx, roomId, orphanParents);
|
||||||
|
|
||||||
|
|
@ -383,15 +216,15 @@ export function Search({ requestClose }: SearchProps) {
|
||||||
>
|
>
|
||||||
<Box grow="Yes" alignItems="Center" gap="100">
|
<Box grow="Yes" alignItems="Center" gap="100">
|
||||||
<Text size="T400" truncate>
|
<Text size="T400" truncate>
|
||||||
{queryHighlighRegex
|
{queryHighlightRegex
|
||||||
? highlightText(queryHighlighRegex, [room.name])
|
? highlightText(queryHighlightRegex, [room.name])
|
||||||
: room.name}
|
: room.name}
|
||||||
</Text>
|
</Text>
|
||||||
{dmUsername && (
|
{dmUsername && (
|
||||||
<Text as="span" size="T200" priority="300" truncate>
|
<Text as="span" size="T200" priority="300" truncate>
|
||||||
@
|
@
|
||||||
{queryHighlighRegex
|
{queryHighlightRegex
|
||||||
? highlightText(queryHighlighRegex, [dmUsername])
|
? highlightText(queryHighlightRegex, [dmUsername])
|
||||||
: dmUsername}
|
: dmUsername}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -1 +1,2 @@
|
||||||
export * from './Search';
|
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,
|
// === App body === Holds the wrapped children (the DM list — header,
|
||||||
// scroll content, DirectNewChatRow, DirectSelfRow). Fills the
|
// scroll content, DirectSelfRow). Fills the container via `inset: 0`.
|
||||||
// container via `inset: 0`. Does NOT translate or shrink — the DM
|
// Does NOT translate or shrink — the DM list stays exactly where it
|
||||||
// list stays exactly where it was in the closed state. Instead, the
|
// was in the closed state. Instead, the bottom of the pane is masked
|
||||||
// bottom of the pane is masked away by an animated `clip-path:
|
// away by an animated `clip-path:
|
||||||
// inset(...)` with rounded BL/BR corners — the user sees the visible
|
// 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
|
// 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
|
// 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-
|
// and the DM list's `@tanstack/react-virtual` re-measures items mid-
|
||||||
// gesture. Why not `transform: translateY` either: translating moves
|
// gesture. Why not `transform: translateY` either: translating moves
|
||||||
// the whole pane up off the top of the viewport, including the
|
// 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
|
// layout unchanged — the DM list keeps its scroll position, its
|
||||||
// measured heights, and its top items in place; only the bottom edge
|
// measured heights, and its top items in place; only the bottom edge
|
||||||
// of what's visible gets carved into the void below.
|
// of what's visible gets carved into the void below.
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@
|
||||||
//
|
//
|
||||||
// User-visible behaviour:
|
// User-visible behaviour:
|
||||||
//
|
//
|
||||||
// • The wrapped app body (DirectStreamHeader → DM list → new-chat
|
// • The wrapped app body (StreamHeader → DM list → DirectSelfRow)
|
||||||
// row → DirectSelfRow) stays exactly where it was — no translate,
|
// stays exactly where it was — no translate,
|
||||||
// no shrink. The bottom of the visible portion is "masked away"
|
// 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
|
// 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
|
// Rpx)` with rounded BL/BR carves at the new visible edge. The
|
||||||
// carved area exposes the container's void colour underneath, and
|
// carved area exposes the container's void colour underneath, and
|
||||||
// the silhouette below covers the rest of the masked zone. Why
|
// the silhouette below covers the rest of the masked zone. Why
|
||||||
// not `transform: translateY`: translating moves the TOP of the
|
// 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
|
// screen). Why not `flex-shrink + margin-bottom`: the virtualized
|
||||||
// DM list (`@tanstack/react-virtual`) re-measures items every
|
// DM list (`@tanstack/react-virtual`) re-measures items every
|
||||||
// time the scroll container resizes — items above the shrinking
|
// time the scroll container resizes — items above the shrinking
|
||||||
|
|
|
||||||
|
|
@ -250,7 +250,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
</Route>
|
</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
|
<Route
|
||||||
path={BOTS_PATH}
|
path={BOTS_PATH}
|
||||||
element={
|
element={
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,15 @@
|
||||||
import React from 'react';
|
import React, { useRef } from 'react';
|
||||||
import { Box, color, config, toRem } from 'folds';
|
import { Box, color, config, toRem } from 'folds';
|
||||||
import { useMatch } from 'react-router-dom';
|
import { useMatch } from 'react-router-dom';
|
||||||
import { PageNav, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
|
import { StreamHeader } from '../../../components/stream-header';
|
||||||
import { useBotPresets } from '../../../features/bots/catalog';
|
import { useBotPresets } from '../../../features/bots/catalog';
|
||||||
import type { BotPreset } from '../../../features/bots/catalog';
|
import type { BotPreset } from '../../../features/bots/catalog';
|
||||||
import { BotCard } from '../../../features/bots/BotCard';
|
import { BotCard } from '../../../features/bots/BotCard';
|
||||||
import { BOTS_BOT_PATH } from '../../paths';
|
import { BOTS_BOT_PATH } from '../../paths';
|
||||||
|
|
||||||
// Static preset links only. Room discovery belongs to /bots/:botId so the list
|
// Static preset links only. Room discovery belongs to /bots/:botId so the
|
||||||
// doesn't install Matrix listeners or scan rooms for every catalog entry.
|
// list doesn't install Matrix listeners or scan rooms for every catalog entry.
|
||||||
function BotRow({ preset }: { preset: BotPreset }) {
|
function BotRow({ preset }: { preset: BotPreset }) {
|
||||||
// `end: false` so future child routes under :botId keep the parent card
|
// `end: false` so future child routes under :botId keep the parent card
|
||||||
// selected. RR-v6 already URL-decodes `params.botId`, no extra decode here.
|
// selected. RR-v6 already URL-decodes `params.botId`, no extra decode here.
|
||||||
|
|
@ -20,11 +20,15 @@ function BotRow({ preset }: { preset: BotPreset }) {
|
||||||
|
|
||||||
export function Bots() {
|
export function Bots() {
|
||||||
const bots = useBotPresets();
|
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 (
|
return (
|
||||||
<PageNav resizable>
|
<PageNav resizable surface="surfaceVariant">
|
||||||
<DirectStreamHeader />
|
<StreamHeader scrollRef={scrollRef}>
|
||||||
<PageNavContent>
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
<Box
|
<Box
|
||||||
direction="Column"
|
direction="Column"
|
||||||
gap="100"
|
gap="100"
|
||||||
|
|
@ -38,6 +42,7 @@ export function Bots() {
|
||||||
))}
|
))}
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
|
</StreamHeader>
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,48 +1,49 @@
|
||||||
import React, { useEffect, useRef } from 'react';
|
import React, { useEffect, useRef } from 'react';
|
||||||
import { useSpace } from '../../../hooks/useSpace';
|
import { useSpace } from '../../../hooks/useSpace';
|
||||||
import { PageNav, PageNavContent } from '../../../components/page';
|
import { PageNav, PageNavContent } from '../../../components/page';
|
||||||
import { DirectStreamHeader } from '../direct/DirectStreamHeader';
|
import { StreamHeader } from '../../../components/stream-header';
|
||||||
import { ChannelsList } from './ChannelsList';
|
import { ChannelsList } from './ChannelsList';
|
||||||
import { ChannelCreateRow } from './ChannelCreateRow';
|
import { ChannelCreateRow } from './ChannelCreateRow';
|
||||||
import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe';
|
import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe';
|
||||||
import { WorkspaceFooter } from './WorkspaceFooter';
|
import { WorkspaceFooter } from './WorkspaceFooter';
|
||||||
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
import { ACTIVE_SPACE_KEY } from './useActiveSpace';
|
||||||
|
|
||||||
// Stub nav rendered at the /channels/ index route when the user has no
|
// Index route at /channels/ (no space selected). Renders the shared
|
||||||
// orphan spaces yet. Provides the segment switcher so they can navigate
|
// StreamHeader so the segment switcher is consistent across surfaces,
|
||||||
// back to DM / Bots without going through the global rail. The list /
|
// but no list/footer — those need a Space context.
|
||||||
// footer panes only make sense once a Space is in context.
|
|
||||||
export function ChannelsRootNav() {
|
export function ChannelsRootNav() {
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
return (
|
return (
|
||||||
<PageNav resizable>
|
<PageNav resizable surface="surfaceVariant">
|
||||||
<DirectStreamHeader />
|
<StreamHeader scrollRef={scrollRef}>
|
||||||
<PageNavContent>
|
<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 />
|
<div />
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
|
</StreamHeader>
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Channels left pane (Mattermost-style). Renders the segment switcher at
|
// Channels left pane (Mattermost-style). Segment switcher at the top,
|
||||||
// the top, the joined-rooms hierarchy in the middle (sub-spaces grouped),
|
// joined-rooms hierarchy in the middle, workspace identity plate at
|
||||||
// and the workspace identity plate at the bottom. M6 will make the footer
|
// the bottom. M6 will turn the footer into a dropdown for users with
|
||||||
// a dropdown for users with 2+ orphan spaces; in M1 it is read-only.
|
// 2+ orphan spaces; in M1 it is read-only.
|
||||||
//
|
//
|
||||||
// Note: we deliberately do NOT call `useNavToActivePathMapper` here. That
|
// Note: deliberately do NOT call `useNavToActivePathMapper` here. That
|
||||||
// atom is owned by the legacy /<space>/ tree (Space.tsx) so the rail's
|
// atom is owned by the legacy /<space>/ tree (Space.tsx) so the
|
||||||
// "click avatar → resume your last in-space path" behaviour stays
|
// global rail's «click avatar → resume your last in-space path» stays
|
||||||
// well-defined. Channels has its own segment-level navigation, so a
|
// well-defined. Channels has its own segment-level navigation.
|
||||||
// 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.
|
|
||||||
export function Channels() {
|
export function Channels() {
|
||||||
const space = useSpace();
|
const space = useSpace();
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
// Persist URL-driven active space so cold-starts at /channels/ resume on
|
// Persist URL-driven active space so cold-starts at /channels/ resume on
|
||||||
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
|
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
|
||||||
// value but never writes it because the index route has no :spaceIdOrAlias
|
// value but never writes it because the index route has no
|
||||||
// param — write happens HERE, where RouteSpaceProvider has already
|
// :spaceIdOrAlias param — the write happens here.
|
||||||
// resolved the space from the URL.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId);
|
localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId);
|
||||||
|
|
@ -52,21 +53,21 @@ export function Channels() {
|
||||||
}, [space.roomId]);
|
}, [space.roomId]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav resizable>
|
<PageNav resizable surface="surfaceVariant">
|
||||||
{/* 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). */}
|
|
||||||
<ChannelsWorkspaceHorseshoe space={space}>
|
<ChannelsWorkspaceHorseshoe space={space}>
|
||||||
<DirectStreamHeader />
|
<StreamHeader
|
||||||
|
scrollRef={scrollRef}
|
||||||
|
bottomPinned={
|
||||||
|
<>
|
||||||
|
<ChannelCreateRow space={space} />
|
||||||
|
<WorkspaceFooter space={space} />
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
<PageNavContent scrollRef={scrollRef}>
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
<ChannelsList scrollRef={scrollRef} />
|
<ChannelsList scrollRef={scrollRef} />
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
<ChannelCreateRow space={space} />
|
</StreamHeader>
|
||||||
<WorkspaceFooter space={space} />
|
|
||||||
</ChannelsWorkspaceHorseshoe>
|
</ChannelsWorkspaceHorseshoe>
|
||||||
</PageNav>
|
</PageNav>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -25,7 +25,7 @@ export const container = style({
|
||||||
overflow: 'hidden',
|
overflow: 'hidden',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Wrapped children (DirectStreamHeader → ChannelsList → ChannelCreateRow →
|
// Wrapped children (StreamHeader → ChannelsList → ChannelCreateRow →
|
||||||
// WorkspaceFooter). Stays put — the bottom is carved away by an animated
|
// WorkspaceFooter). Stays put — the bottom is carved away by an animated
|
||||||
// `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is
|
// `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is
|
||||||
// load-bearing: the container is painted with the void colour when the
|
// load-bearing: the container is painted with the void colour when the
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,7 @@ import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||||
import { DirectStreamHeader } from './DirectStreamHeader';
|
import { StreamHeader } from '../../../components/stream-header';
|
||||||
import { DirectNewChatRow } from './DirectNewChatRow';
|
|
||||||
import { DirectSelfRow } from './DirectSelfRow';
|
import { DirectSelfRow } from './DirectSelfRow';
|
||||||
import { MobileSettingsHorseshoe } from '../../../features/settings';
|
import { MobileSettingsHorseshoe } from '../../../features/settings';
|
||||||
|
|
||||||
|
|
@ -79,6 +78,7 @@ function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) {
|
||||||
aria-expanded={expanded}
|
aria-expanded={expanded}
|
||||||
style={{
|
style={{
|
||||||
appearance: 'none',
|
appearance: 'none',
|
||||||
|
WebkitAppearance: 'none',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
background: 'transparent',
|
background: 'transparent',
|
||||||
cursor: 'pointer',
|
cursor: 'pointer',
|
||||||
|
|
@ -118,10 +118,13 @@ export function Direct() {
|
||||||
// reactions — anything roomToUnread misses. Uses the 'Room.timeline'
|
// reactions — anything roomToUnread misses. Uses the 'Room.timeline'
|
||||||
// literal (= RoomEvent.Timeline) to avoid named imports from matrix-js-sdk
|
// literal (= RoomEvent.Timeline) to avoid named imports from matrix-js-sdk
|
||||||
// that the project's moduleResolution flags as TS2614 (see
|
// 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 [, setActivityTick] = useState(0);
|
||||||
|
const directsRef = useRef(directs);
|
||||||
|
directsRef.current = directs;
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const directsSet = new Set(directs);
|
|
||||||
const handleTimeline = (
|
const handleTimeline = (
|
||||||
_ev: unknown,
|
_ev: unknown,
|
||||||
room: { roomId: string } | undefined,
|
room: { roomId: string } | undefined,
|
||||||
|
|
@ -130,7 +133,7 @@ export function Direct() {
|
||||||
data: { liveEvent?: boolean } | undefined
|
data: { liveEvent?: boolean } | undefined
|
||||||
) => {
|
) => {
|
||||||
if (!room || !data?.liveEvent) return;
|
if (!room || !data?.liveEvent) return;
|
||||||
if (!directsSet.has(room.roomId)) return;
|
if (!directsRef.current.includes(room.roomId)) return;
|
||||||
setActivityTick((n) => n + 1);
|
setActivityTick((n) => n + 1);
|
||||||
};
|
};
|
||||||
const emitter = mx as unknown as {
|
const emitter = mx as unknown as {
|
||||||
|
|
@ -141,7 +144,7 @@ export function Direct() {
|
||||||
return () => {
|
return () => {
|
||||||
emitter.removeListener('Room.timeline', handleTimeline);
|
emitter.removeListener('Room.timeline', handleTimeline);
|
||||||
};
|
};
|
||||||
}, [mx, directs]);
|
}, [mx]);
|
||||||
|
|
||||||
const selectedRoomId = useSelectedRoom();
|
const selectedRoomId = useSelectedRoom();
|
||||||
|
|
||||||
|
|
@ -199,30 +202,19 @@ export function Direct() {
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PageNav resizable>
|
<PageNav resizable surface="surfaceVariant">
|
||||||
{/* On mobile, `MobileSettingsHorseshoe` wraps the whole DM
|
{/* MobileSettingsHorseshoe wraps the full DM column on mobile so the
|
||||||
list (including DirectSelfRow). The bottom-up Settings sheet
|
Settings sheet can carve into the bottom of this pane. On non-mobile
|
||||||
opens via tap on DirectSelfRow OR drag-up from the row
|
it's a pass-through. */}
|
||||||
(`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. */}
|
|
||||||
<MobileSettingsHorseshoe>
|
<MobileSettingsHorseshoe>
|
||||||
<DirectStreamHeader />
|
<StreamHeader scrollRef={scrollRef} bottomPinned={<DirectSelfRow />}>
|
||||||
{noRoomToDisplay ? (
|
{noRoomToDisplay ? (
|
||||||
<DirectEmpty />
|
<DirectEmpty />
|
||||||
) : (
|
) : (
|
||||||
<PageNavContent scrollRef={scrollRef}>
|
<PageNavContent scrollRef={scrollRef}>
|
||||||
<Box direction="Column" gap="300">
|
<Box direction="Column" gap="300">
|
||||||
<NavCategory>
|
<NavCategory>
|
||||||
<div
|
<div style={{ position: 'relative', height: virtualizer.getTotalSize() }}>
|
||||||
style={{
|
|
||||||
position: 'relative',
|
|
||||||
height: virtualizer.getTotalSize(),
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{virtualizer.getVirtualItems().map((vItem) => {
|
{virtualizer.getVirtualItems().map((vItem) => {
|
||||||
const item = items[vItem.index];
|
const item = items[vItem.index];
|
||||||
if (!item) return null;
|
if (!item) return null;
|
||||||
|
|
@ -261,7 +253,6 @@ export function Direct() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// kind === 'direct'
|
|
||||||
const { roomId } = item;
|
const { roomId } = item;
|
||||||
const room = mx.getRoom(roomId);
|
const room = mx.getRoom(roomId);
|
||||||
if (!room) return null;
|
if (!room) return null;
|
||||||
|
|
@ -289,8 +280,7 @@ export function Direct() {
|
||||||
</Box>
|
</Box>
|
||||||
</PageNavContent>
|
</PageNavContent>
|
||||||
)}
|
)}
|
||||||
<DirectNewChatRow />
|
</StreamHeader>
|
||||||
<DirectSelfRow />
|
|
||||||
</MobileSettingsHorseshoe>
|
</MobileSettingsHorseshoe>
|
||||||
</PageNav>
|
</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