feat(search): find homeserver-directory users in search and start DMs from there, retiring the Direct new-chat Plus

This commit is contained in:
heaven 2026-06-04 23:49:33 +03:00
parent c12c228eb8
commit 0aaecbbe2e
15 changed files with 790 additions and 234 deletions

View file

@ -162,9 +162,9 @@ Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs g
| `common-settings/` | **Shared** settings modules reused by both room and space settings: `general/` (RoomProfile, Address, Encryption, HistoryVisibility, JoinRules, Publish, Upgrade), `members/`, `permissions/` (Powers, PowersEditor, PermissionGroups), `emojis-stickers/` (RoomPacks), `developer-tools/` (SendRoomEvent, StateEventEditor). | | `common-settings/` | **Shared** settings modules reused by both room and space settings: `general/` (RoomProfile, Address, Encryption, HistoryVisibility, JoinRules, Publish, Upgrade), `members/`, `permissions/` (Powers, PowersEditor, PermissionGroups), `emojis-stickers/` (RoomPacks), `developer-tools/` (SendRoomEvent, StateEventEditor). |
| `room-settings/` / `space-settings/` | Each defines only its own General + Permissions and **imports** Members/EmojisStickers/DeveloperTools from `common-settings`. Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. | | `room-settings/` / `space-settings/` | Each defines only its own General + Permissions and **imports** Members/EmojisStickers/DeveloperTools from `common-settings`. Mounted globally via `RoomSettingsRenderer` / `SpaceSettingsRenderer`. |
| `lobby/` | Space lobby (`Lobby` + Hierarchy/Item + pragmatic-drag-and-drop reordering). Still routed under `SPACE_PATH/_LOBBY_PATH` and reached from the **legacy** Space tab — the new Channels surface does **not** use it. | | `lobby/` | Space lobby (`Lobby` + Hierarchy/Item + pragmatic-drag-and-drop reordering). Still routed under `SPACE_PATH/_LOBBY_PATH` and reached from the **legacy** Space tab — the new Channels surface does **not** use it. |
| `search/` | Global room/space switcher modal (Cmd+K style) — `Search.tsx` (mounted via `SearchModalRenderer`) + `useRoomSearch.ts` (extracted `useAsyncSearch` over directs/rooms/spaces/orphan-spaces). | | `search/` | Unified switcher (Cmd+K modal `Search.tsx` via `SearchModalRenderer` + the StreamHeader's `InlineRoomSearch`, both on `useRoomSearch.ts`). Searches local rooms/DMs/spaces (`useAsyncSearch`) **and** a **"People" section** from the homeserver user directory (`mx.searchUserDirectory`, debounced 300ms / ≥2 chars; exact `@user:server` falls back to `mx.getProfileInfo`, then a soft-added raw id). People are deduped against existing DMs (`getDMRoomFor`); clicking one opens-or-creates a DM via `create-chat/useCreateDirect.ts`. This is how you reach someone you haven't chatted with — the old "+ new chat" form is gone from the listing headers (see `stream-header/`). Findability of who appears is the server's lever: Synapse `user_directory.search_all_users` (federation only surfaces remote users the server already knows). |
| `message-search/` | In-room message search (`MessageSearch` + filters/input/`SearchResultGroup`). | | `message-search/` | In-room message search (`MessageSearch` + filters/input/`SearchResultGroup`). |
| `create-chat/` | DM creation — username + server fields (`FALLBACK_SERVER='vojo.chat'`), dedup via `getDMRoomFor`. | | `create-chat/` | DM creation. `CreateChat.tsx` = the explicit username + server form (`FALLBACK_SERVER='vojo.chat'`, dedup via `getDMRoomFor`), now reached only by the `/u/<user>` deep link (`UserLinkRedirect``/direct/create`) and the Direct empty-state CTA — NOT from the listing-header Plus (retired; people are found in `search/`). `useCreateDirect.ts` = the shared open-or-create-DM hook used by both `CreateChat` and the search People section. |
| `create-room/` / `create-space/` | Room/space creation (`CreateRoom`/`CreateSpace` + their modal wrappers, mounted via `*ModalRenderer`). | | `create-room/` / `create-space/` | Room/space creation (`CreateRoom`/`CreateSpace` + their modal wrappers, mounted via `*ModalRenderer`). |
| `add-existing/` | Add existing rooms to a space. | | `add-existing/` | Add existing rooms to a space. |
| `join-before-navigate/` | Pre-join room card (`JoinBeforeNavigate.tsx`, ~82 LOC). | | `join-before-navigate/` | Pre-join room card (`JoinBeforeNavigate.tsx`, ~82 LOC). |
@ -202,7 +202,7 @@ Slate-based. `Editor.tsx` (Slate root — preserve), `Editor.preview.tsx`, `Elem
### Navigation / list (mostly NEW dirs) ### Navigation / list (mostly NEW dirs)
- `nav/` (**NEW**, heavily used) — generic list-row primitives (`NavCategory`, `NavCategoryHeader`, `NavItem`/`NavLink`, `NavItemContent`, `NavItemOptions`, `NavEmptyLayout`). The lower-level layer beneath `features/room-nav/`. - `nav/` (**NEW**, heavily used) — generic list-row primitives (`NavCategory`, `NavCategoryHeader`, `NavItem`/`NavLink`, `NavItemContent`, `NavItemOptions`, `NavEmptyLayout`). The lower-level layer beneath `features/room-nav/`.
- `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Chip`/`Segment` + `useCurtain*` gestures + `forms/InlineNewChatForm`/`InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. - `stream-header/` (**NEW**) — the **tab curtain header** for the Direct/Channels/Bots listing tabs (`StreamHeader` + `Chip`/`Segment` + `useCurtain*` gestures + `forms/InlineRoomSearch`). **Not** the room header — don't confuse with `RoomViewHeaderDm`. The Plus/new-chat action is **gone on Direct** (people are found in search); the Plus only renders where a tab supplies a `primaryAction` (Channels' create-channel/community). The curtain has a single form (`form-search`); its peek geometry is **chip-count-aware**`peekTravelPx(chipRows)` (1 on Direct, 2 on Channels) is threaded into `snapTopPx` + both gesture hooks so rest position and commit scale stay in lockstep.
- `mobile-tabs-pager/` (**NEW**) — the mobile swipe pager (see Routing). - `mobile-tabs-pager/` (**NEW**) — the mobile swipe pager (see Routing).
- `sidebar/``Sidebar` (66px wrapper), `SidebarItem`, `SidebarStack`, `SidebarStackSeparator`, `SidebarContent` (currently mounted only via dead `SidebarNav`). - `sidebar/``Sidebar` (66px wrapper), `SidebarItem`, `SidebarStack`, `SidebarStackSeparator`, `SidebarContent` (currently mounted only via dead `SidebarNav`).
- `virtualizer/` (`VirtualTile`), `scroll-top-container/`. - `virtualizer/` (`VirtualTile`), `scroll-top-container/`.

View file

@ -77,16 +77,14 @@ export function MobileTabsPagerHeader({
const curtainControls = useAtomValue(mobilePagerCurtainAtom); const curtainControls = useAtomValue(mobilePagerCurtainAtom);
const isFormActive = curtainControls?.isFormActive ?? false; const isFormActive = curtainControls?.isFormActive ?? false;
const openChat = useCallback(() => curtainControls?.openChat(), [curtainControls]);
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]); const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]); const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
const iconsDisabled = curtainControls === null; const iconsDisabled = curtainControls === null;
// Tab-specific override for the Plus button (Channels publishes // Tab-specific create action (Channels publishes «create channel» /
// «create channel» / «create community»). Falls back to the default // «create community»). When null (Direct) NO Plus renders — new-chat
// «new chat» path that opens InlineNewChatForm via the curtain. // is gone and people are found through search. `primaryAction.onClick`
// `primaryAction.onClick` is already stable (memoised by the // is already stable (memoised by the publishing pane), so we wire it
// publishing pane), so we wire it directly into onClick without // directly into onClick without re-wrapping in another useCallback.
// re-wrapping in another useCallback.
const primaryAction = curtainControls?.primaryAction ?? null; const primaryAction = curtainControls?.primaryAction ?? null;
// The static header does NOT translate to follow the curtain. It // The static header does NOT translate to follow the curtain. It
@ -173,25 +171,26 @@ export function MobileTabsPagerHeader({
</IconButton> </IconButton>
) : ( ) : (
<div className={streamHeaderCss.iconsCluster}> <div className={streamHeaderCss.iconsCluster}>
{/* Default «new chat» Plus is gone people are found through
search. Only the tab's `primaryAction` (Channels'
create-channel / create-community) renders a Plus; it
opens a portal Modal dialog, so there's nothing in-subtree
to point `aria-controls` at. */}
{primaryAction && (
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat} onClick={primaryAction.onClick}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')} aria-label={primaryAction.label}
// See StreamHeader's matching IconButton: drop only
// `aria-controls` when the override opens a portal
// Modal (no in-subtree form to point at). The
// override IS a dialog opener, so `aria-haspopup` +
// `aria-expanded={false}` stay accurate either way.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false} aria-expanded={false}
aria-haspopup="dialog" aria-haspopup="dialog"
disabled={iconsDisabled} disabled={iconsDisabled}
> >
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} /> <Icon size="100" src={primaryAction.iconSrc} />
</IconButton> </IconButton>
)}
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"
fill="None" fill="None"

View file

@ -362,5 +362,14 @@ export const formInner = style({
top: 0, top: 0,
left: 0, left: 0,
right: 0, right: 0,
padding: `${toRem(8)} ${toRem(24)} ${toRem(12)}`, // Narrower side padding than the chip rows (24px) so the search form
// uses more of the column width — the inset bar read as «narrow».
//
// Bottom padding is 0 (not 12 like the top): the form's last child is
// the scrollable result list, and it must run flush into the curtain's
// top edge. Any bottom padding here — like the curtain breather the
// form snap deliberately drops (see `snapTopPx`) — would reintroduce a
// light-blue strip that clips the search results before they reach the
// curtain. The 8px top padding stays: it's the gap below the tabs row.
padding: `${toRem(8)} ${toRem(12)} 0`,
}); });

View file

@ -30,7 +30,6 @@ import { Chip } from './Chip';
import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState';
import { useCurtainHandleGesture } from './useCurtainHandleGesture'; import { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture'; import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineNewChatForm } from './forms/InlineNewChatForm';
import { InlineRoomSearch } from './forms/InlineRoomSearch'; import { InlineRoomSearch } from './forms/InlineRoomSearch';
const INLINE_FORM_ID = 'stream-header-inline-form'; const INLINE_FORM_ID = 'stream-header-inline-form';
@ -63,11 +62,11 @@ type StreamHeaderProps = {
// listing share `"channels"` so pin survives the toggle between // listing share `"channels"` so pin survives the toggle between
// empty state and a chosen workspace. // empty state and a chosen workspace.
pinKey: string; pinKey: string;
// Optional override for the Plus button. When omitted the header // Optional contextual create action (a Plus button + peek chip). When
// renders the default «new chat» action that opens InlineNewChatForm // omitted (Direct) the header shows no Plus at all — starting a chat
// via the curtain. Channels overrides this with «create channel» / // is folded into search, which finds people via the homeserver user
// «create community» so the same Plus slot launches a contextual // directory. Channels sets this to «create channel» / «create
// action instead of the DM-creation form. // community» so its Plus launches that flow.
primaryAction?: StreamHeaderPrimaryAction; primaryAction?: StreamHeaderPrimaryAction;
}; };
@ -121,7 +120,12 @@ export function StreamHeader({
else navigate(BOTS_PATH, navOpts); else navigate(BOTS_PATH, navOpts);
}, [selectTabInstant, navigate, navOpts]); }, [selectTabInstant, navigate, navOpts]);
const curtain = useCurtainState(pinKey); // Peek reveals a single Search chip by default (Direct — new-chat is
// gone, people are found through search), or two when a `primaryAction`
// adds the tab's contextual create chip (Channels). The count drives
// the curtain's peek travel geometry.
const chipRows = primaryAction ? 2 : 1;
const curtain = useCurtainState(pinKey, chipRows);
// Suppress every curtain gesture whenever the user is interacting // Suppress every curtain gesture whenever the user is interacting
// with something else that would otherwise race the pin path: // with something else that would otherwise race the pin path:
@ -186,6 +190,7 @@ export function StreamHeader({
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned, pinned: curtain.pinned,
setPinned: curtain.setPinned, setPinned: curtain.setPinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag, setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit, commit: curtain.commit,
disabled: gestureDisabled, disabled: gestureDisabled,
@ -198,6 +203,7 @@ export function StreamHeader({
scrollRef, scrollRef,
snap: curtain.snap, snap: curtain.snap,
pinned: curtain.pinned, pinned: curtain.pinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag, setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit, commit: curtain.commit,
disabled: gestureDisabled, disabled: gestureDisabled,
@ -205,8 +211,7 @@ export function StreamHeader({
}); });
const isActive = isFormSnap(curtain.snap); const isActive = isFormSnap(curtain.snap);
const openSearch = useCallback(() => curtain.open('search'), [curtain]); const openSearch = useCallback(() => curtain.open(), [curtain]);
const openChat = useCallback(() => curtain.open('chat'), [curtain]);
const { close } = curtain; const { close } = curtain;
// Memoised controls object so the cleanup's identity check (atom // Memoised controls object so the cleanup's identity check (atom
@ -216,12 +221,11 @@ export function StreamHeader({
const pagerControls = useMemo<MobilePagerCurtainControls>( const pagerControls = useMemo<MobilePagerCurtainControls>(
() => ({ () => ({
openSearch, openSearch,
openChat,
closeForm: close, closeForm: close,
isFormActive: isActive, isFormActive: isActive,
primaryAction: primaryAction ?? null, primaryAction: primaryAction ?? null,
}), }),
[openSearch, openChat, close, isActive, primaryAction] [openSearch, close, isActive, primaryAction]
); );
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom); const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
@ -260,7 +264,9 @@ export function StreamHeader({
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX; const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
const curtainTop = curtain.pinned const curtainTop = curtain.pinned
? 0 + curtain.liveDragPx ? 0 + curtain.liveDragPx
: snapTopPx(curtain.snap, curtain.formHeightPx) + platformOffset + curtain.liveDragPx; : snapTopPx(curtain.snap, curtain.formHeightPx, curtain.peekTravelPx) +
platformOffset +
curtain.liveDragPx;
// After the curtain settles at `closed`, unmount any lingering form. // After the curtain settles at `closed`, unmount any lingering form.
// Guarded so unrelated transitionend events (e.g. children's own // Guarded so unrelated transitionend events (e.g. children's own
@ -392,25 +398,26 @@ export function StreamHeader({
</IconButton> </IconButton>
) : ( ) : (
<div className={css.iconsCluster}> <div className={css.iconsCluster}>
{/* The default «new chat» Plus is gone people are found
through search now. Only the tab-specific `primaryAction`
(Channels' create-channel / create-community) still
renders a Plus here; it opens a portal Modal dialog
outside this subtree, so there's no in-tree form region
to point `aria-controls` at. */}
{primaryAction && (
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat} onClick={primaryAction.onClick}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')} aria-label={primaryAction.label}
// `aria-controls` points at the curtain-mounted form
// region — drop it when `primaryAction` opens a portal
// dialog (`Modal` lives outside this subtree, so there
// is nothing to control here). `aria-haspopup="dialog"`
// + `aria-expanded={false}` stay accurate for both
// branches: the override opens a true Modal dialog.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false} aria-expanded={false}
aria-haspopup="dialog" aria-haspopup="dialog"
> >
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} /> <Icon size="100" src={primaryAction.iconSrc} />
</IconButton> </IconButton>
)}
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"
fill="None" fill="None"
@ -445,19 +452,14 @@ export function StreamHeader({
<div <div
id={INLINE_FORM_ID} id={INLINE_FORM_ID}
role="region" role="region"
aria-label={ aria-label={t('Search.search')}
curtain.activeForm === 'search'
? t('Search.search')
: t('Direct.create_chat_subtitle')
}
className={css.formArea} className={css.formArea}
style={{ style={{
height: toRem(curtain.formHeightPx ?? 0), height: toRem(curtain.formHeightPx ?? 0),
}} }}
> >
<div ref={curtain.formMeasureRef} className={css.formInner}> <div ref={curtain.formMeasureRef} className={css.formInner}>
{curtain.activeForm === 'search' && <InlineRoomSearch onClose={close} />} <InlineRoomSearch onClose={close} />
{curtain.activeForm === 'chat' && <InlineNewChatForm onClose={close} />}
</div> </div>
</div> </div>
) : ( ) : (
@ -470,14 +472,19 @@ export function StreamHeader({
hidden={curtain.snap !== 'peek'} hidden={curtain.snap !== 'peek'}
/> />
</div> </div>
{/* Second chip is the tab's contextual create action
(Channels). Direct has none its new-chat Plus is gone,
so the peek reveals a single Search chip. */}
{primaryAction && (
<div className={css.chipRow}> <div className={css.chipRow}>
<Chip <Chip
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus} iconSrc={primaryAction.iconSrc}
label={primaryAction ? primaryAction.label : t('Direct.create_chat')} label={primaryAction.label}
onClick={primaryAction ? primaryAction.onClick : openChat} onClick={primaryAction.onClick}
hidden={curtain.snap !== 'peek'} hidden={curtain.snap !== 'peek'}
/> />
</div> </div>
)}
</> </>
)} )}
</header> </header>

View file

@ -1,18 +0,0 @@
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. This curtain is the
// canonical native new-chat surface; the `/direct/_create` route renders
// the same grouped block under a back-chevron PageHeader. Here we only
// feed it the `onClose` callback and the tighter `gap='400'` rhythm so the
// form fits comfortably under the header.
export function InlineNewChatForm({ onClose }: Props) {
return <CreateChat gap="400" onCreated={onClose} />;
}

View file

@ -1,15 +1,20 @@
import React, { useCallback, useEffect } from 'react'; import React, { useCallback, useEffect } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Avatar, Box, Icon, Icons, Scroll, Text, color, toRem } from 'folds'; import { Avatar, Box, Icon, IconButton, Icons, Scroll, Spinner, Text, color, toRem } from 'folds';
import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UserAvatar } from '../../../components/user-avatar';
import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge'; import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge';
import { getAllParents, getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent } from '../../../utils/room'; import {
getAllParents,
getDirectRoomAvatarUrl,
getRoomAvatarUrl,
guessPerfectParent,
} from '../../../utils/room';
import { nameInitials } from '../../../utils/common'; import { nameInitials } from '../../../utils/common';
import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
import { highlightText } from '../../../plugins/react-custom-html-parser';
import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch'; import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch';
import { SEARCH_FORM_BASE_PX } from '../geometry'; import { SEARCH_FORM_BASE_PX } from '../geometry';
@ -20,7 +25,9 @@ type Props = {
// Inline search panel mounted in the StreamHeader. Shares all search // Inline search panel mounted in the StreamHeader. Shares all search
// logic with the global Search modal via `useRoomSearch`; only the // logic with the global Search modal via `useRoomSearch`; only the
// presentation chrome differs (no Modal/Overlay/FocusTrap, a custom // presentation chrome differs (no Modal/Overlay/FocusTrap, a custom
// row layout that matches the inline aesthetic). // row layout that matches the inline aesthetic). Surfaces both your
// existing rooms/DMs and "People" from the homeserver user directory —
// tapping a person opens or creates a DM.
export function InlineRoomSearch({ onClose }: Props) { export function InlineRoomSearch({ onClose }: Props) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -42,10 +49,16 @@ export function InlineRoomSearch({ onClose }: Props) {
roomsToRender, roomsToRender,
result, result,
listFocus, listFocus,
queryHighlightRegex, query,
clearSearch,
handleInputChange, handleInputChange,
handleInputKeyDown, handleInputKeyDown,
handleRoomClick, handleRoomClick,
peopleToRender,
directoryLoading,
handleUserClick,
creatingUserId,
roomCount,
getRoom, getRoom,
mDirects, mDirects,
orphanSpaces, orphanSpaces,
@ -62,13 +75,32 @@ export function InlineRoomSearch({ onClose }: Props) {
inputRef.current?.focus(); inputRef.current?.focus();
}, [inputRef]); }, [inputRef]);
const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0;
return ( 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 <Box
direction="Column"
gap="200"
// Fill the form's full outer height so the result list runs flush
// into the curtain's top edge (the form snap drops the breather —
// see `snapTopPx`). `- 8` accounts for `formInner`'s 8px top
// padding (StreamHeader.css.ts), so measured `formInner.offsetHeight`
// lands at exactly `SEARCH_FORM_BASE_PX`, keeping the curtain at its
// resting top and the pre-measure fallback in lockstep.
style={{ width: '100%', height: toRem(SEARCH_FORM_BASE_PX - 8) }}
>
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14)
so the chip input morph reads as a content crossfade.
`width: 100%` + `minWidth: 0` keep the bar a constant full
width whether empty or typed without it the flex row
collapses to its content (the search icon) until text widens
it, which read as the bar "growing as you type". */}
<Box
shrink="No"
alignItems="Center" alignItems="Center"
style={{ style={{
width: '100%',
minWidth: 0,
backgroundColor: color.Background.Container, backgroundColor: color.Background.Container,
borderRadius: toRem(20), borderRadius: toRem(20),
padding: `${toRem(8)} ${toRem(14)}`, padding: `${toRem(8)} ${toRem(14)}`,
@ -96,12 +128,29 @@ export function InlineRoomSearch({ onClose }: Props) {
minWidth: 0, minWidth: 0,
}} }}
/> />
{directoryLoading ? (
<Spinner size="200" variant="Secondary" />
) : (
query.length > 0 && (
<IconButton
type="button"
size="300"
radii="Pill"
variant="SurfaceVariant"
fill="None"
onClick={clearSearch}
aria-label={t('Search.clear')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)
)}
</Box> </Box>
{/* ── Result list ────────────────────────────────────── */} {/* ── Result list ────────────────────────────────────── */}
<Box grow="Yes" style={{ minHeight: 0 }}> <Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover"> <Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
{roomsToRender.length === 0 && ( {isEmpty && !directoryLoading && (
<Box <Box
grow="Yes" grow="Yes"
alignItems="Center" alignItems="Center"
@ -158,8 +207,8 @@ export function InlineRoomSearch({ onClose }: Props) {
appearance: 'none', appearance: 'none',
WebkitAppearance: 'none', WebkitAppearance: 'none',
border: 'none', border: 'none',
backgroundColor: focused ? color.Primary.Main : 'transparent', backgroundColor: focused ? color.Background.ContainerHover : 'transparent',
color: focused ? color.Primary.OnMain : color.Background.OnContainer, color: color.Background.OnContainer,
borderRadius: toRem(12), borderRadius: toRem(12),
padding: `${toRem(8)} ${toRem(10)}`, padding: `${toRem(8)} ${toRem(10)}`,
width: '100%', width: '100%',
@ -197,16 +246,11 @@ export function InlineRoomSearch({ onClose }: Props) {
</Avatar> </Avatar>
<Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}> <Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text size="T400" truncate> <Text size="T400" truncate>
{queryHighlightRegex {room.name}
? highlightText(queryHighlightRegex, [room.name])
: room.name}
</Text> </Text>
{dmUsername && ( {dmUsername && (
<Text as="span" size="T200" priority="300" truncate> <Text as="span" size="T200" priority="300" truncate>
@ @{dmUsername}
{queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername])
: dmUsername}
</Text> </Text>
)} )}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && ( {!dm && perfectParent && perfectParent !== perfectOrphanParent && (
@ -237,6 +281,100 @@ export function InlineRoomSearch({ onClose }: Props) {
})} })}
</Box> </Box>
)} )}
{/* ── People (homeserver user directory) ─────────────── */}
{peopleToRender.length > 0 && (
<Box direction="Column" gap="100" style={{ padding: `${toRem(4)} 0` }}>
<Text size="L400" priority="300" style={{ padding: `${toRem(4)} ${toRem(10)}` }}>
{t('Search.people')}
</Text>
{peopleToRender.map((user, j) => {
const index = roomCount + j;
const focused = listFocus.index === index;
const username = getMxIdLocalPart(user.userId) ?? user.userId;
const server = getMxIdServer(user.userId);
const name = user.displayName || username;
const avatarSrc = user.avatarUrl
? mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ?? undefined
: undefined;
const creating = creatingUserId === user.userId;
return (
<button
key={user.userId}
type="button"
data-focus-index={index}
data-user-id={user.userId}
onClick={handleUserClick}
aria-pressed={focused}
disabled={creating}
style={{
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
backgroundColor: focused ? color.Background.ContainerHover : 'transparent',
color: color.Background.OnContainer,
borderRadius: toRem(12),
padding: `${toRem(8)} ${toRem(10)}`,
width: '100%',
display: 'flex',
alignItems: 'center',
gap: toRem(10),
cursor: creating ? 'default' : 'pointer',
textAlign: 'left',
font: 'inherit',
}}
>
<Avatar size="200" radii="400">
<UserAvatar
userId={user.userId}
src={avatarSrc}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
<Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text size="T400" truncate>
{name}
</Text>
<Text as="span" size="T200" priority="300" truncate>
@{username}
</Text>
</Box>
<Box gap="100" alignItems="Center" shrink="No">
{creating ? (
<Spinner size="100" variant={focused ? 'Primary' : 'Secondary'} />
) : (
server && (
<Text size="T200" priority="300" truncate>
<b>{server}</b>
</Text>
)
)}
</Box>
</button>
);
})}
</Box>
)}
{/* Reach someone on another server
The directory can't surface users on other homeservers you
don't already share a room with, so when a query finds no
people we teach the by-address escape hatch: typing a full
`@user:server` produces a tappable result above (via the
user-directory / profile lookup) that opens a DM. */}
{result && !directoryLoading && peopleToRender.length === 0 && (
<Box style={{ padding: `${toRem(8)} ${toRem(10)} ${toRem(4)}` }}>
<Text size="T200" priority="300">
{t('Search.other_server_hint')}
</Text>
</Box>
)}
</Scroll> </Scroll>
</Box> </Box>
</Box> </Box>

View file

@ -16,7 +16,9 @@
// closed = TABS_ROW_PX // closed = TABS_ROW_PX
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX // peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_PX
// + CURTAIN_BREATHER_PX // + CURTAIN_BREATHER_PX
// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX // form:* = TABS_ROW_PX + formHeight (NO breather — the search
// results clip flush at the curtain's top edge; the
// form's own height already reaches the curtain)
// //
// Pinned visual contract: at `pinned` the curtain's top edge lands at // Pinned visual contract: at `pinned` the curtain's top edge lands at
// y = safe-top in viewport coords (because the stage starts after the // y = safe-top in viewport coords (because the stage starts after the
@ -70,13 +72,19 @@ export const CHIP_GAP_PX = 14;
// available viewport so the form never overflows the chats card. // available viewport so the form never overflows the chats card.
export const SEARCH_FORM_BASE_PX = 360; export const SEARCH_FORM_BASE_PX = 360;
// Breathing strip between the bottom of any header content (revealed // Breathing strip between the bottom of the revealed peek chip(s) and
// chip pill, form's last actionable element) and the top of the // the top of the curtain. Painted by the header's `SurfaceVariant.
// curtain. Painted by the header's `SurfaceVariant.Container` (light- // Container` (light-blue) so the chip / Create button never visually
// blue) so the chip / Create button / search results never visually // touches the curtain's rounded top — the user reads a chip that sits
// touch the curtain's rounded top — the user reads chips that sit // flush with the curtain as «зажатый» rather than two separate
// flush with the curtain as «зажатые» rather than two separate // affordances. Applies to the PEEK snap only (via `peekTravelPx` and
// affordances. Not applied at `closed` (nothing to breathe to). // the `chipRow` last-child margin).
//
// Deliberately NOT applied to the form snap: the search results are a
// scroll list and must clip flush at the curtain's top edge — a light-
// blue strip there reads as «content cut off early», not breathing room
// (see `snapTopPx`'s form-search case). Also not applied at `closed`
// (nothing to breathe to).
export const CURTAIN_BREATHER_PX = 20; export const CURTAIN_BREATHER_PX = 20;
// Curtain snap transition. Tuned tight for an in-app reveal — // Curtain snap transition. Tuned tight for an in-app reveal —
@ -91,9 +99,22 @@ export const CURTAIN_RADIUS_PX = 24;
// Total vertical travel of the curtain between `closed` and `peek` — // Total vertical travel of the curtain between `closed` and `peek` —
// the resting-top delta between the two snaps. Used as the basis for // the resting-top delta between the two snaps. Used as the basis for
// the peek-commit threshold: the user must drag (rubber-banded) at // the peek-commit threshold: the user must drag (rubber-banded) at
// least COMMIT_THRESHOLD × PEEK_TRAVEL_PX before release for the snap // least COMMIT_THRESHOLD × peekTravel before release for the snap to
// to flip. Anything shorter reads as accidental and springs back. // flip. Anything shorter reads as accidental and springs back.
export const PEEK_TRAVEL_PX = CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX; //
// The chip count is DYNAMIC per tab: Direct reveals a single chip
// (Search — new-chat is gone, people are found through search), while
// Channels reveals two (Search + the contextual create action). So the
// peek travel is a function of `chipRows`, computed per StreamHeader
// from its actual chip count and threaded into the gesture hooks +
// `snapTopPx` so the rest position and the commit scale stay in
// lockstep (a mismatch would make the curtain overshoot then retract).
export const peekTravelPx = (chipRows: number): number =>
chipRows * CHIP_ROW_PX + Math.max(0, chipRows - 1) * CHIP_GAP_PX + CURTAIN_BREATHER_PX;
// Back-compat default (two-chip peek). The live value is computed per
// StreamHeader via `peekTravelPx(chipRows)`.
export const PEEK_TRAVEL_PX = peekTravelPx(2);
// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so // Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so
// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of // the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of

View file

@ -4,7 +4,6 @@ import {
ACTIVE_CLOSE_THRESHOLD_PX, ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD, COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX, DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
RUBBER_BAND, RUBBER_BAND,
} from './geometry'; } from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState'; import { CurtainSnap, isFormSnap } from './useCurtainState';
@ -54,6 +53,10 @@ type Args = {
// by the touchstart bail) — pin / unpin commits are the handle's // by the touchstart bail) — pin / unpin commits are the handle's
// exclusive contract, see «Direction asymmetry» on the hook. // exclusive contract, see «Direction asymmetry» on the hook.
pinned: boolean; pinned: boolean;
// Closed→peek travel for this curtain's chip count (1 chip on Direct,
// 2 on Channels). The peek commit threshold scales off this so the
// gesture matches the actual rest position. Ref-mirrored like `snap`.
peekTravelPx: number;
// Live drag delta sink — feeds the curtain's `top` via React state, // Live drag delta sink — feeds the curtain's `top` via React state,
// no direct DOM writes. // no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void; setLiveDrag: (px: number, dragging: boolean) => void;
@ -138,6 +141,7 @@ export function useCurtainBodyGesture({
scrollRef, scrollRef,
snap, snap,
pinned, pinned,
peekTravelPx,
setLiveDrag, setLiveDrag,
commit, commit,
disabled, disabled,
@ -147,6 +151,8 @@ export function useCurtainBodyGesture({
snapRef.current = snap; snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned); const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned; pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(peekTravelPx);
peekTravelPxRef.current = peekTravelPx;
const commitRef = useRef(commit); const commitRef = useRef(commit);
commitRef.current = commit; commitRef.current = commit;
const setHandleStateRef = useRef(setHandleState); const setHandleStateRef = useRef(setHandleState);
@ -286,7 +292,7 @@ export function useCurtainBodyGesture({
// want exposed on the body. Rubber-banded 0.65× // want exposed on the body. Rubber-banded 0.65×
// displacement matches the «physically heavier» body feel. // displacement matches the «physically heavier» body feel.
lastDelta = Math.max(0, delta * RUBBER_BAND); lastDelta = Math.max(0, delta * RUBBER_BAND);
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; atCommit = lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break; break;
case 'close-peek': case 'close-peek':
// Rubber-banded up. No clamp either side — matches the // Rubber-banded up. No clamp either side — matches the
@ -295,7 +301,7 @@ export function useCurtainBodyGesture({
// damping. Commit target is `closed`; no path into pin // damping. Commit target is `closed`; no path into pin
// territory (the user's hard rule — pin is handle-only). // territory (the user's hard rule — pin is handle-only).
lastDelta = delta * RUBBER_BAND; lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break; break;
case 'form-close': case 'form-close':
// Rubber-banded up; capped at 0 so an accidental downward // Rubber-banded up; capped at 0 so an accidental downward
@ -340,14 +346,14 @@ export function useCurtainBodyGesture({
// commit lives on the handle (see «Direction asymmetry» // commit lives on the handle (see «Direction asymmetry»
// above and the touchmove switch). Below threshold the // above and the touchmove switch). Below threshold the
// curtain springs back to closed. // curtain springs back to closed.
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('peek'); commitRef.current('peek');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
} }
break; break;
case 'close-peek': case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('closed'); commitRef.current('closed');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);

View file

@ -4,7 +4,6 @@ import {
ACTIVE_CLOSE_THRESHOLD_PX, ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD, COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX, DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
PIN_COMMIT_THRESHOLD, PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_PX, PIN_TRAVEL_PX,
} from './geometry'; } from './geometry';
@ -28,6 +27,10 @@ type Args = {
// Setter for the pinned overlay; called on release once the user's // Setter for the pinned overlay; called on release once the user's
// drag past the commit threshold qualifies the gesture. // drag past the commit threshold qualifies the gesture.
setPinned: (next: boolean) => void; setPinned: (next: boolean) => void;
// Closed→peek travel for this curtain's chip count (1 chip on Direct,
// 2 on Channels). The peek commit threshold scales off this so the
// gesture matches the actual rest position. Ref-mirrored like `snap`.
peekTravelPx: number;
// Setter for the live drag delta during touchmove. The hook reads // Setter for the live drag delta during touchmove. The hook reads
// `liveDragPx` from the parent state too, so React drives the // `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes. // curtain's `top` re-render — no direct DOM writes.
@ -187,6 +190,7 @@ export function useCurtainHandleGesture({
snap, snap,
pinned, pinned,
setPinned, setPinned,
peekTravelPx,
setLiveDrag, setLiveDrag,
commit, commit,
disabled, disabled,
@ -196,6 +200,8 @@ export function useCurtainHandleGesture({
snapRef.current = snap; snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned); const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned; pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(peekTravelPx);
peekTravelPxRef.current = peekTravelPx;
const setPinnedRef = useRef(setPinned); const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned; setPinnedRef.current = setPinned;
const commitRef = useRef(commit); const commitRef = useRef(commit);
@ -304,7 +310,7 @@ export function useCurtainHandleGesture({
atCommit = atCommit =
lastDelta <= 0 lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD ? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; : lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break; break;
case 'pinned-free': case 'pinned-free':
// 1:1 down from pinned. Clamped at 0 below (a downward // 1:1 down from pinned. Clamped at 0 below (a downward
@ -326,7 +332,7 @@ export function useCurtainHandleGesture({
// safe-top direction, matching the «drag up off-screen from // safe-top direction, matching the «drag up off-screen from
// anywhere» expectation. // anywhere» expectation.
lastDelta = Math.min(0, delta); lastDelta = Math.min(0, delta);
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD; atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break; break;
case 'form-close': case 'form-close':
// Form close: finger moves UP (delta < 0). Track 1:1, capped // Form close: finger moves UP (delta < 0). Track 1:1, capped
@ -374,7 +380,7 @@ export function useCurtainHandleGesture({
// >0). Below either threshold, spring back to closed. // >0). Below either threshold, spring back to closed.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) { if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true); setPinnedRef.current(true);
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { } else if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('peek'); commitRef.current('peek');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);
@ -394,7 +400,7 @@ export function useCurtainHandleGesture({
// alone would set snap='peek' yet leave the curtain // alone would set snap='peek' yet leave the curtain
// visually at top=0 (the pin overlay wins). Both updates // visually at top=0 (the pin overlay wins). Both updates
// batch into one render inside this touchend handler. // batch into one render inside this touchend handler.
if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * PEEK_TRAVEL_PX) { if (lastDelta >= PIN_TRAVEL_PX + COMMIT_THRESHOLD * peekTravelPxRef.current) {
setPinnedRef.current(false); setPinnedRef.current(false);
commitRef.current('peek'); commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) { } else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
@ -404,7 +410,7 @@ export function useCurtainHandleGesture({
} }
break; break;
case 'close-peek': case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) { if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('closed'); commitRef.current('closed');
} else { } else {
setLiveDrag(0, false); setLiveDrag(0, false);

View file

@ -1,33 +1,26 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader'; import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import { import { CURTAIN_SNAP_MS, peekTravelPx, SEARCH_FORM_BASE_PX, TABS_ROW_PX } from './geometry';
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` // Discrete snap stops for the curtain. The curtain's resting `top`
// is derived from this value plus the live finger drag delta. There // is derived from this value plus the live finger drag delta. There
// is no separate «active vs peek» mode flag — the snap encodes both. // is no separate «active vs peek» mode flag — the snap encodes both.
export type CurtainSnap = export type CurtainSnap =
| 'closed' // curtain flush under tabs row; nothing peeking | 'closed' // curtain flush under tabs row; nothing peeking
| 'peek' // both action chips (search + new chat) revealed | 'peek' // action chip(s) revealed (Search, plus the tab's create action on Channels)
| 'form-search' // full search form 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' => export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap === 'form-search';
snap === 'form-search' || snap === 'form-chat';
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek'; export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
// Form kind currently rendered in the header. Stays set during the // Whether a form is currently rendered in the header. Stays set during
// curtain's close transition so the form has content to slide behind; // the curtain's close transition so the form has content to slide
// cleared by `acknowledgeClosed` after the snap settles at `closed`. // behind; cleared by `acknowledgeClosed` after the snap settles at
export type ActiveForm = 'search' | 'chat' | null; // `closed`. There is only one form now (search) — new-chat was folded
// into search's People section — so this is a simple on/off flag.
export type ActiveForm = 'search' | null;
export type CurtainState = { export type CurtainState = {
snap: CurtainSnap; snap: CurtainSnap;
@ -52,15 +45,19 @@ export type CurtainState = {
// 1:1), restored on release so the snap commit animates smoothly. // 1:1), restored on release so the snap commit animates smoothly.
isDragging: boolean; isDragging: boolean;
// Live measured height of the active form's outer; used to compute // Live measured height of the active form's outer; used to compute
// the curtain's resting `top` when `snap === 'form-*'`. `null` while // the curtain's resting `top` when `snap === 'form-search'`. `null`
// no form is mounted. // while no form is mounted.
formHeightPx: number | null; formHeightPx: number | null;
// Ref pointing at the rendered form's outer — a ResizeObserver // Ref pointing at the rendered form's outer — a ResizeObserver
// watches this to feed `formHeightPx`. Consumer attaches it to the // watches this to feed `formHeightPx`. Consumer attaches it to the
// form's wrapping element. // form's wrapping element.
formMeasureRef: React.RefObject<HTMLDivElement>; formMeasureRef: React.RefObject<HTMLDivElement>;
// Open a form. Sets `snap` and `activeForm` synchronously. // Closed→peek travel for this curtain's chip count. Threaded into the
open: (form: 'search' | 'chat') => void; // gesture hooks (commit scale) and `snapTopPx` (rest position) so the
// two stay in lockstep.
peekTravelPx: number;
// Open the search form. Sets `snap` and `activeForm` synchronously.
open: () => void;
// Close the curtain (raise it back to `closed`). Keeps `activeForm` // Close the curtain (raise it back to `closed`). Keeps `activeForm`
// set until the snap transition lands so the form stays mounted // set until the snap transition lands so the form stays mounted
// during the slide-up. // during the slide-up.
@ -80,29 +77,41 @@ export type CurtainState = {
acknowledgeClosed: () => void; acknowledgeClosed: () => void;
}; };
// Resting `top` (px) of the curtain for a given snap stop and the // Resting `top` (px) of the curtain for a given snap stop, the
// currently measured form height (null falls back to the base). // currently measured form height (null falls back to the base), and
// Snap-top math mirrors the DOM layout of the chip stack so the // the chip-count-derived peek travel (`peekTravel`). The peek rest
// curtain's resting edge always lands on the boundary of the next // lands exactly `peekTravel` below the tabs row so the curtain's edge
// row (no «next chip pill peeking through» bug). Chip rows are 56px // sits on the boundary below the revealed chip(s) — no «next chip pill
// each; between them is `CHIP_GAP_PX` (chip-to-chip, tighter); after // peeking through».
// the last revealed chip is `CURTAIN_BREATHER_PX` (chip-to-curtain, //
// wider). Forms use just the breather. // The form rests at exactly `TABS_ROW_PX + formHeight` — NO breather
export function snapTopPx(snap: CurtainSnap, formH: number | null): number { // (unlike peek). The search results are a scroll list, and any light-
// blue strip between the last visible result and the curtain's top
// reads as «content cut off early» rather than as breathing room. So
// the form's measured height already reaches the curtain top: the list
// clips flush at the curtain's edge. `SEARCH_FORM_BASE_PX` is the
// pre-measure fallback and is sized (= the form's full outer height)
// so it equals the measured `formH`, avoiding any open-time top jump.
// The chip/peek breather lives separately in `peekTravel` and is
// untouched.
export function snapTopPx(snap: CurtainSnap, formH: number | null, peekTravel: number): number {
switch (snap) { switch (snap) {
case 'closed': case 'closed':
return TABS_ROW_PX; return TABS_ROW_PX;
case 'peek': case 'peek':
return TABS_ROW_PX + CHIP_ROW_PX + CHIP_GAP_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX; return TABS_ROW_PX + peekTravel;
case 'form-search': case 'form-search':
case 'form-chat': return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX;
default: default:
return TABS_ROW_PX; return TABS_ROW_PX;
} }
} }
export function useCurtainState(pinKey: string): CurtainState { // `chipRows` = how many peek chips this curtain reveals (1 on Direct =
// Search only; 2 on Channels = Search + the contextual create action).
// Drives the peek travel so the rest position and gesture commit scale
// match the actual chip stack.
export function useCurtainState(pinKey: string, chipRows: number): CurtainState {
const [snap, setSnap] = useState<CurtainSnap>('closed'); const [snap, setSnap] = useState<CurtainSnap>('closed');
const [activeForm, setActiveForm] = useState<ActiveForm>(null); const [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null); const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
@ -115,6 +124,8 @@ export function useCurtainState(pinKey: string): CurtainState {
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom); const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const pinned = !!pinnedMap[pinKey]; const pinned = !!pinnedMap[pinKey];
const peekTravel = peekTravelPx(chipRows);
const formMeasureRef = useRef<HTMLDivElement>(null); const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback( const setPinned = useCallback(
@ -134,10 +145,9 @@ export function useCurtainState(pinKey: string): CurtainState {
[pinKey, setPinnedMap] [pinKey, setPinnedMap]
); );
const open = useCallback( const open = useCallback(() => {
(form: 'search' | 'chat') => { setActiveForm('search');
setActiveForm(form); setSnap('form-search');
setSnap(form === 'search' ? 'form-search' : 'form-chat');
setLiveDragPx(0); setLiveDragPx(0);
setIsDragging(false); setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the // Safety net: clear pin so the form is visible. In practice the
@ -148,9 +158,7 @@ export function useCurtainState(pinKey: string): CurtainState {
// still-pinned curtain at curtainTop=0 and present an invisible // still-pinned curtain at curtainTop=0 and present an invisible
// form. // form.
setPinned(false); setPinned(false);
}, }, [setPinned]);
[setPinned]
);
const close = useCallback(() => { const close = useCallback(() => {
setSnap('closed'); setSnap('closed');
@ -225,6 +233,7 @@ export function useCurtainState(pinKey: string): CurtainState {
isDragging, isDragging,
formHeightPx, formHeightPx,
formMeasureRef, formMeasureRef,
peekTravelPx: peekTravel,
open, open,
close, close,
commit, commit,
@ -239,6 +248,7 @@ export function useCurtainState(pinKey: string): CurtainState {
liveDragPx, liveDragPx,
isDragging, isDragging,
formHeightPx, formHeightPx,
peekTravel,
open, open,
close, close,
commit, commit,

View file

@ -0,0 +1,45 @@
import { useCallback } from 'react';
import { ICreateRoomStateEvent, Preset, Visibility } from 'matrix-js-sdk';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { addRoomIdToMDirect, getDMRoomFor } from '../../utils/matrix';
import { createRoomEncryptionState } from '../../components/create-room';
export type CreateDirectOptions = {
// End-to-end encrypt the freshly created room. Ignored when an
// existing DM is reused. Defaults to `false` to match the historical
// `CreateChat` toggle default.
encrypt?: boolean;
};
// Single source of truth for "open or create a 1:1 DM with `userId`".
// Returns the existing DM's roomId when one is already tracked in
// `m.direct` (dedup via `getDMRoomFor`); otherwise creates a fresh
// `is_direct` room, records it in `m.direct`, and returns the new
// roomId. Shared by the legacy `CreateChat` form and the unified
// search People section so both reach a DM through the exact same path.
export function useCreateDirect(): (userId: string, opts?: CreateDirectOptions) => Promise<string> {
const mx = useMatrixClient();
return useCallback(
async (userId, opts) => {
const existing = getDMRoomFor(mx, userId);
if (existing) return existing.roomId;
const initialState: ICreateRoomStateEvent[] = [];
if (opts?.encrypt) initialState.push(createRoomEncryptionState());
const result = await mx.createRoom({
is_direct: true,
invite: [userId],
visibility: Visibility.Private,
preset: Preset.TrustedPrivateChat,
initial_state: initialState,
});
addRoomIdToMDirect(mx, result.room_id, userId);
return result.room_id;
},
[mx]
);
}

View file

@ -10,7 +10,9 @@ import {
Modal, Modal,
Overlay, Overlay,
OverlayCenter, OverlayCenter,
IconButton,
Scroll, Scroll,
Spinner,
Text, Text,
toRem, toRem,
} from 'folds'; } from 'folds';
@ -20,16 +22,16 @@ import { isKeyHotkey } from 'is-hotkey';
import { useAtom } from 'jotai'; import { useAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UserAvatar } from '../../components/user-avatar';
import { import {
getAllParents, getAllParents,
getDirectRoomAvatarUrl, getDirectRoomAvatarUrl,
getRoomAvatarUrl, getRoomAvatarUrl,
guessPerfectParent, guessPerfectParent,
} from '../../utils/room'; } from '../../utils/room';
import { highlightText } from '../../plugins/react-custom-html-parser';
import { nameInitials } from '../../utils/common'; import { nameInitials } from '../../utils/common';
import { useRoomNavigate } from '../../hooks/useRoomNavigate'; import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix'; import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../utils/matrix';
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';
@ -63,10 +65,16 @@ export function Search({ requestClose }: SearchProps) {
roomsToRender, roomsToRender,
result, result,
listFocus, listFocus,
queryHighlightRegex, query,
clearSearch,
handleInputChange, handleInputChange,
handleInputKeyDown, handleInputKeyDown,
handleRoomClick, handleRoomClick,
peopleToRender,
directoryLoading,
handleUserClick,
creatingUserId,
roomCount,
getRoom, getRoom,
mDirects, mDirects,
orphanSpaces, orphanSpaces,
@ -75,6 +83,8 @@ export function Search({ requestClose }: SearchProps) {
myUserId, myUserId,
} = useRoomSearch({ onOpenRoomId: openRoomId }); } = useRoomSearch({ onOpenRoomId: openRoomId });
const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0;
return ( return (
<Overlay open> <Overlay open>
<OverlayCenter> <OverlayCenter>
@ -94,6 +104,12 @@ export function Search({ requestClose }: SearchProps) {
<Modal <Modal
size="400" size="400"
style={{ style={{
// `size="400"` only sets max-width/height, so without an
// explicit width the modal shrinks to its content — making
// it narrow when empty and widening as result rows appear.
// Pin width to fill up to the size cap so it stays a stable
// width from the first keystroke.
width: '100%',
maxHeight: toRem(400), maxHeight: toRem(400),
borderRadius: config.radii.R400, borderRadius: config.radii.R400,
backgroundColor: '#181a20', backgroundColor: '#181a20',
@ -101,12 +117,18 @@ export function Search({ requestClose }: SearchProps) {
boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)', boxShadow: '0 16px 48px rgba(0, 0, 0, 0.5)',
}} }}
> >
{/* ── Input row: hairline-divided, integrated (not a boxed Input) ── */} {/* Input row: hairline-divided, integrated (not a boxed Input)
`width: 100%` pins the row to the modal width so the
flex `input` always has space to fill otherwise the row
collapses to its content (search icon) when empty and
only widens as you type. */}
<Box <Box
shrink="No" shrink="No"
alignItems="Center" alignItems="Center"
gap="200" gap="200"
style={{ style={{
width: '100%',
minWidth: 0,
padding: `${toRem(12)} ${config.space.S400}`, padding: `${toRem(12)} ${config.space.S400}`,
borderBottom: HAIRLINE, borderBottom: HAIRLINE,
}} }}
@ -131,9 +153,26 @@ export function Search({ requestClose }: SearchProps) {
minWidth: 0, minWidth: 0,
}} }}
/> />
{directoryLoading ? (
<Spinner size="200" variant="Secondary" />
) : (
query.length > 0 && (
<IconButton
type="button"
size="300"
radii="Pill"
variant="SurfaceVariant"
fill="None"
onClick={clearSearch}
aria-label={t('Search.clear')}
>
<Icon size="100" src={Icons.Cross} />
</IconButton>
)
)}
</Box> </Box>
<Box grow="Yes"> <Box grow="Yes">
{roomsToRender.length === 0 && ( {isEmpty && !directoryLoading && (
<Box <Box
style={{ paddingTop: config.space.S700 }} style={{ paddingTop: config.space.S700 }}
grow="Yes" grow="Yes"
@ -150,9 +189,19 @@ export function Search({ requestClose }: SearchProps) {
? t('Search.no_match_for_query', { query: result.query }) ? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')} : t('Search.no_rooms_to_display')}
</Text> </Text>
{result && (
<Text
size="T200"
align="Center"
priority="300"
style={{ maxWidth: toRem(300) }}
>
{t('Search.other_server_hint')}
</Text>
)}
</Box> </Box>
)} )}
{roomsToRender.length > 0 && ( {!isEmpty && (
<Scroll ref={scrollRef} size="300" hideTrack> <Scroll ref={scrollRef} size="300" hideTrack>
<div style={{ padding: config.space.S200, paddingRight: config.space.S100 }}> <div style={{ padding: config.space.S200, paddingRight: config.space.S100 }}>
{roomsToRender.map((roomId, index) => { {roomsToRender.map((roomId, index) => {
@ -190,10 +239,10 @@ export function Search({ requestClose }: SearchProps) {
aria-pressed={focused} aria-pressed={focused}
radii="300" radii="300"
style={{ style={{
// Fleet highlight: a violet tint + a 2px left bar // Neutral focus highlight — a soft surface tint,
// instead of a solid Primary fill (Dawn selection). // no accent colour (the violet selection read as
backgroundColor: focused ? 'rgba(149, 128, 255, 0.08)' : undefined, // garish next to the yellow match highlight).
boxShadow: focused ? `inset 2px 0 0 ${color.Primary.Main}` : undefined, backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined,
}} }}
after={ after={
<Box gap="100"> <Box gap="100">
@ -256,9 +305,7 @@ 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>
{queryHighlightRegex {room.name}
? highlightText(queryHighlightRegex, [room.name])
: room.name}
</Text> </Text>
{dmUsername && ( {dmUsername && (
<Text <Text
@ -268,10 +315,7 @@ export function Search({ requestClose }: SearchProps) {
truncate truncate
style={{ fontFamily: 'var(--font-mono)' }} style={{ fontFamily: 'var(--font-mono)' }}
> >
@ @{dmUsername}
{queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername])
: dmUsername}
</Text> </Text>
)} )}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && ( {!dm && perfectParent && perfectParent !== perfectOrphanParent && (
@ -283,6 +327,108 @@ export function Search({ requestClose }: SearchProps) {
</MenuItem> </MenuItem>
); );
})} })}
{/* ── People (homeserver user directory) ─────────── */}
{peopleToRender.length > 0 && (
<>
<Text
size="L400"
priority="300"
style={{ padding: `${toRem(8)} ${toRem(8)} ${toRem(4)}` }}
>
{t('Search.people')}
</Text>
{peopleToRender.map((user, j) => {
const index = roomCount + j;
const focused = listFocus.index === index;
const username = getMxIdLocalPart(user.userId) ?? user.userId;
const server = getMxIdServer(user.userId);
const name = user.displayName || username;
const avatarSrc = user.avatarUrl
? mxcUrlToHttp(mx, user.avatarUrl, useAuthentication, 32, 32, 'crop') ??
undefined
: undefined;
const creating = creatingUserId === user.userId;
return (
<MenuItem
key={user.userId}
as="button"
data-focus-index={index}
data-user-id={user.userId}
onClick={handleUserClick}
variant="Surface"
aria-pressed={focused}
radii="300"
disabled={creating}
style={{
backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined,
}}
after={
creating ? (
<Spinner size="100" variant="Secondary" />
) : (
server && (
<Text
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
{server}
</Text>
)
)
}
before={
<Avatar size="200" radii="400">
<UserAvatar
userId={user.userId}
src={avatarSrc}
alt={name}
renderFallback={() => (
<Text as="span" size="H6">
{nameInitials(name)}
</Text>
)}
/>
</Avatar>
}
>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{name}
</Text>
<Text
as="span"
size="T200"
priority="300"
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
@{username}
</Text>
</Box>
</MenuItem>
);
})}
</>
)}
{/* Reach someone on another server: the directory
can't surface remote users you don't share a room
with, so when a query finds no people we teach the
by-address escape hatch (typing a full
@user:server yields a tappable result above). */}
{result && !directoryLoading && peopleToRender.length === 0 && (
<Text
size="T200"
priority="300"
style={{ display: 'block', padding: `${toRem(8)} ${toRem(8)} ${toRem(4)}` }}
>
{t('Search.other_server_hint')}
</Text>
)}
</div> </div>
</Scroll> </Scroll>
)} )}

View file

@ -25,8 +25,11 @@ import { allRoomsAtom } from '../../state/room-list/roomList';
import { roomToParentsAtom } from '../../state/room/roomToParents'; import { roomToParentsAtom } from '../../state/room/roomToParents';
import { roomToUnreadAtom } from '../../state/room/roomToUnread'; import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { factoryRoomIdByActivity } from '../../utils/sort'; import { factoryRoomIdByActivity } from '../../utils/sort';
import { getMxIdLocalPart, guessDmRoomUserId } from '../../utils/matrix'; import { getDMRoomFor, getMxIdLocalPart, guessDmRoomUserId, isUserId } from '../../utils/matrix';
import { makeHighlightRegex } from '../../plugins/react-custom-html-parser'; import { makeHighlightRegex } from '../../plugins/react-custom-html-parser';
import { useDebounce } from '../../hooks/useDebounce';
import { useAlive } from '../../hooks/useAlive';
import { useCreateDirect } from '../create-chat/useCreateDirect';
export enum SearchRoomType { export enum SearchRoomType {
Rooms = '#', Rooms = '#',
@ -45,6 +48,25 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
normalizeOptions: { ignoreWhitespace: false }, normalizeOptions: { ignoreWhitespace: false },
}; };
// User-directory search tuning. The directory query goes over the
// network (POST /_matrix/client/v3/user_directory/search), so debounce
// it and gate it behind a minimum length to avoid hammering the server
// (and tripping its M_LIMIT_EXCEEDED rate limit) on every keystroke. An
// exact MXID bypasses the minimum-length gate — the user clearly means
// that person, so we always resolve it (and fall back to a profile
// lookup so they're reachable even when not indexed in the directory).
const DIRECTORY_LIMIT = 20;
const DIRECTORY_MIN_CHARS = 2;
const DIRECTORY_DEBOUNCE_MS = 300;
// A person surfaced from the homeserver user directory (or an exact
// MXID the user typed). Clicking one opens-or-creates a DM.
export type RoomSearchUser = {
userId: string;
displayName?: string;
avatarUrl?: string;
};
export const getDmUserId = ( export const getDmUserId = (
roomId: string, roomId: string,
getRoom: (roomId: string) => Room | undefined, getRoom: (roomId: string) => Room | undefined,
@ -55,19 +77,25 @@ export const getDmUserId = (
}; };
type UseRoomSearchOptions = { type UseRoomSearchOptions = {
// Called after the user commits a room selection (click or Enter). // Called after the user commits a room selection (click or Enter), or
// Modal Search uses this to close itself; the inline header form uses // after a People hit resolves to a DM room (existing or freshly
// it to retract the curtain. The hook itself doesn't navigate — the // created). Modal Search uses this to close itself; the inline header
// caller decides via `onOpenRoomId(roomId, isSpace)`. // 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; onOpenRoomId: (roomId: string, isSpace: boolean) => void;
}; };
// Single source of truth for the room/space/direct search panel — // Single source of truth for the unified search panel — local
// prefix detection (`#`/`*`/`@`), filtered scope, top-active fallback, // rooms/DMs/spaces (prefix detection `#`/`*`/`@`, filtered scope,
// keyboard navigation, focus auto-scroll, and result-highlight regex. // top-active fallback) AND people from the homeserver user directory
// Used by both the global `Search` modal and the inline header form. // (the "People" section, which is how you reach someone you haven't
// chatted with yet). Keyboard navigation spans both sections, rooms
// first then people. Used by both the global `Search` modal and the
// inline header form.
export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
const mx = useMatrixClient(); const mx = useMatrixClient();
const alive = useAlive();
const createDirect = useCreateDirect();
const inputRef = useRef<HTMLInputElement>(null); const inputRef = useRef<HTMLInputElement>(null);
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
@ -118,35 +146,173 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS); const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS);
const roomsToRender = result ? result.items : topActiveRooms; const roomsToRender = result ? result.items : topActiveRooms;
const listFocus = useListFocusIndex(roomsToRender.length, 0);
// ── User-directory ("People") search ──────────────────────────────
// `dirGenRef` is a monotonic generation counter bumped on every input
// change. Each (debounced) directory request carries the generation
// it was issued under and drops its result if a newer keystroke has
// since bumped the counter — this serialises out-of-order network
// responses without an AbortController.
const [directoryResults, setDirectoryResults] = useState<RoomSearchUser[]>([]);
const [directoryLoading, setDirectoryLoading] = useState(false);
const dirGenRef = useRef(0);
// Current input text — drives the clear (✕) button's visibility.
const [query, setQuery] = useState('');
const runDirectory = useCallback(
async (term: string, exactMxid: boolean, gen: number) => {
if (gen !== dirGenRef.current) return;
try {
const { results } = await mx.searchUserDirectory({ term, limit: DIRECTORY_LIMIT });
let users: RoomSearchUser[] = results.map((r) => ({
userId: r.user_id,
displayName: r.display_name,
avatarUrl: r.avatar_url,
}));
// Exact MXID the directory didn't surface (not indexed / opted
// out / a federated user the server only knows by id): resolve
// the profile so they're still reachable, and soft-add the bare
// id if even that fails so the user can always start the chat.
if (exactMxid && isUserId(term) && !users.some((u) => u.userId === term)) {
try {
const profile = await mx.getProfileInfo(term);
users = [
{ userId: term, displayName: profile.displayname, avatarUrl: profile.avatar_url },
...users,
];
} catch {
users = [{ userId: term }, ...users];
}
}
if (gen !== dirGenRef.current || !alive()) return;
setDirectoryResults(users);
setDirectoryLoading(false);
} catch {
if (gen !== dirGenRef.current || !alive()) return;
setDirectoryResults([]);
setDirectoryLoading(false);
}
},
[mx, alive]
);
const debouncedRunDirectory = useDebounce(runDirectory, { wait: DIRECTORY_DEBOUNCE_MS });
// People already in a DM with us are reached through their existing
// room row, so drop them (and self) from the directory section to
// avoid showing the same person twice. `mDirects` in the deps re-runs
// the filter the moment a new DM lands (so a just-created chat leaves
// the People list immediately).
const peopleToRender = useMemo(
() => directoryResults.filter((u) => u.userId !== myUserId && !getDMRoomFor(mx, u.userId)),
// `mDirects` isn't read directly but is the reactive mirror of the
// `m.direct` account data that `getDMRoomFor` consults — keeping it
// in the deps re-runs the filter the instant a new DM lands so the
// just-chatted person drops out of the People section.
// eslint-disable-next-line react-hooks/exhaustive-deps
[directoryResults, mx, myUserId, mDirects]
);
// Keyboard focus spans both sections: indices [0, roomCount) are
// rooms, [roomCount, roomCount + peopleCount) are people.
const roomCount = roomsToRender.length;
const listFocus = useListFocusIndex(roomCount + peopleToRender.length, 0);
const queryHighlightRegex = result?.query const queryHighlightRegex = result?.query
? makeHighlightRegex(result.query.split(' ')) ? makeHighlightRegex(result.query.split(' '))
: undefined; : undefined;
// Per-user DM-in-flight marker so the clicked row can show a spinner
// while `createRoom` round-trips (opening an existing DM is instant).
const [creatingUserId, setCreatingUserId] = useState<string | null>(null);
const startDmWithUser = useCallback(
(userId: string) => {
setCreatingUserId(userId);
createDirect(userId)
.then((roomId) => {
if (!alive()) return;
setCreatingUserId(null);
onOpenRoomId(roomId, false);
})
.catch(() => {
if (alive()) setCreatingUserId(null);
});
},
[createDirect, alive, onOpenRoomId]
);
const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback( const handleInputChange: ChangeEventHandler<HTMLInputElement> = useCallback(
(evt) => { (evt) => {
listFocus.reset(); listFocus.reset();
const target = evt.currentTarget; setQuery(evt.currentTarget.value);
let value = target.value.trim(); const raw = evt.currentTarget.value.trim();
// A full `@local:server` is treated as an exact MXID (directory
// term + profile fallback), NOT as the `@` directs-filter prefix —
// so pasting someone's id finds exactly them.
const exactMxid = isUserId(raw);
let value = raw;
let nextType: SearchRoomType | undefined;
if (!exactMxid) {
const prefix = value.match(/^[#@*]/)?.[0]; const prefix = value.match(/^[#@*]/)?.[0];
const nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined; nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined;
setSearchRoomType(nextType);
if (nextType) value = value.slice(1); if (nextType) value = value.slice(1);
if (value === '') {
resetSearch();
return;
} }
search(value); setSearchRoomType(nextType);
// Local room/DM/space search.
if (value === '') resetSearch();
else search(value);
// Directory people search — only when nothing scopes us away from
// people (`#` rooms / `*` spaces suppress it). Bump the generation
// so any in-flight request from a prior keystroke is discarded.
dirGenRef.current += 1;
const directoryEnabled = nextType === undefined || nextType === SearchRoomType.Directs;
const dirTerm = exactMxid ? raw : value;
if (
directoryEnabled &&
dirTerm !== '' &&
(exactMxid || dirTerm.length >= DIRECTORY_MIN_CHARS)
) {
setDirectoryLoading(true);
debouncedRunDirectory(dirTerm, exactMxid, dirGenRef.current);
} else {
setDirectoryResults([]);
setDirectoryLoading(false);
}
}, },
[listFocus, search, resetSearch] [listFocus, search, resetSearch, debouncedRunDirectory]
); );
// Wipe the whole query in one tap (the ✕ button): reset local search,
// drop directory results, discard any in-flight directory request
// (gen bump), clear the input element and refocus it.
const clearSearch = useCallback(() => {
setQuery('');
setSearchRoomType(undefined);
resetSearch();
dirGenRef.current += 1;
setDirectoryResults([]);
setDirectoryLoading(false);
listFocus.reset();
const input = inputRef.current;
if (input) {
input.value = '';
input.focus();
}
}, [resetSearch, listFocus]);
const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback( const handleInputKeyDown: KeyboardEventHandler<HTMLInputElement> = useCallback(
(evt) => { (evt) => {
const roomId = roomsToRender[listFocus.index]; if (isKeyHotkey('enter', evt)) {
if (isKeyHotkey('enter', evt) && roomId) { const { index } = listFocus;
onOpenRoomId(roomId, spaces.includes(roomId)); if (index < roomCount) {
const roomId = roomsToRender[index];
if (roomId) onOpenRoomId(roomId, spaces.includes(roomId));
} else {
const user = peopleToRender[index - roomCount];
if (user) startDmWithUser(user.userId);
}
return; return;
} }
if (isKeyHotkey('arrowdown', evt)) { if (isKeyHotkey('arrowdown', evt)) {
@ -159,7 +325,7 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
listFocus.previous(); listFocus.previous();
} }
}, },
[roomsToRender, listFocus, onOpenRoomId, spaces] [roomsToRender, roomCount, peopleToRender, listFocus, onOpenRoomId, spaces, startDmWithUser]
); );
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = useCallback( const handleRoomClick: MouseEventHandler<HTMLButtonElement> = useCallback(
@ -173,6 +339,15 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
[onOpenRoomId] [onOpenRoomId]
); );
const handleUserClick: MouseEventHandler<HTMLButtonElement> = useCallback(
(evt) => {
const userId = evt.currentTarget.getAttribute('data-user-id');
if (!userId) return;
startDmWithUser(userId);
},
[startDmWithUser]
);
// Auto-scroll the highlighted item into view as the user arrows. // Auto-scroll the highlighted item into view as the user arrows.
useEffect(() => { useEffect(() => {
const scrollView = scrollRef.current; const scrollView = scrollRef.current;
@ -190,9 +365,19 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
result, result,
listFocus, listFocus,
queryHighlightRegex, queryHighlightRegex,
query,
clearSearch,
handleInputChange, handleInputChange,
handleInputKeyDown, handleInputKeyDown,
handleRoomClick, handleRoomClick,
// People (user directory) section:
peopleToRender,
directoryLoading,
handleUserClick,
creatingUserId,
// Index in the flat focus list where the People section begins, so
// consumers can map a user's array position to its focus index.
roomCount,
// Per-row context the consumer needs to render rows: // Per-row context the consumer needs to render rows:
getRoom, getRoom,
mDirects, mDirects,

View file

@ -31,9 +31,11 @@ export function DirectCreate() {
}, [mx, navigate, directs, userId]); }, [mx, navigate, directs, userId]);
// One Dawn layout for every size: a left-aligned back-chevron header over the // One Dawn layout for every size: a left-aligned back-chevron header over the
// grouped CreateChat block in a padded Scroll. No centered hero — the curtain // grouped CreateChat block in a padded Scroll. This explicit create-by-id
// (InlineNewChatForm) is the canonical native new-chat surface, and the // surface backs the `/u/<user>` deep link (UserLinkRedirect) and the Direct
// keyboard no longer fights an oversized hero on short native viewports. // empty-state CTA; the everyday way to start a chat is the search People
// section. No centered hero — the keyboard no longer fights an oversized hero
// on short native viewports.
return ( return (
<Page> <Page>
<PageHeader balance outlined={false}> <PageHeader balance outlined={false}>

View file

@ -27,12 +27,12 @@ export type StreamHeaderPrimaryAction = {
// is active at any moment, so writes don't race. // is active at any moment, so writes don't race.
export type MobilePagerCurtainControls = { export type MobilePagerCurtainControls = {
openSearch: () => void; openSearch: () => void;
openChat: () => void;
closeForm: () => void; closeForm: () => void;
isFormActive: boolean; isFormActive: boolean;
// Optional tab-specific override for the Plus button. When null the // Optional tab-specific create action (the Plus button). When null
// static header renders the default «new chat» Plus that triggers // (Direct) the static header renders NO Plus — new-chat is gone and
// `openChat`. // people are found through search. Channels sets it to «create
// channel» / «create community».
primaryAction: StreamHeaderPrimaryAction | null; primaryAction: StreamHeaderPrimaryAction | null;
}; };