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). |
| `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. |
| `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`). |
| `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`). |
| `add-existing/` | Add existing rooms to a space. |
| `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)
- `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).
- `sidebar/``Sidebar` (66px wrapper), `SidebarItem`, `SidebarStack`, `SidebarStackSeparator`, `SidebarContent` (currently mounted only via dead `SidebarNav`).
- `virtualizer/` (`VirtualTile`), `scroll-top-container/`.

View file

@ -77,16 +77,14 @@ export function MobileTabsPagerHeader({
const curtainControls = useAtomValue(mobilePagerCurtainAtom);
const isFormActive = curtainControls?.isFormActive ?? false;
const openChat = useCallback(() => curtainControls?.openChat(), [curtainControls]);
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
const iconsDisabled = curtainControls === null;
// Tab-specific override for the Plus button (Channels publishes
// «create channel» / «create community»). Falls back to the default
// «new chat» path that opens InlineNewChatForm via the curtain.
// `primaryAction.onClick` is already stable (memoised by the
// publishing pane), so we wire it directly into onClick without
// re-wrapping in another useCallback.
// Tab-specific create action (Channels publishes «create channel» /
// «create community»). When null (Direct) NO Plus renders — new-chat
// is gone and people are found through search. `primaryAction.onClick`
// is already stable (memoised by the publishing pane), so we wire it
// directly into onClick without re-wrapping in another useCallback.
const primaryAction = curtainControls?.primaryAction ?? null;
// The static header does NOT translate to follow the curtain. It
@ -173,25 +171,26 @@ export function MobileTabsPagerHeader({
</IconButton>
) : (
<div className={streamHeaderCss.iconsCluster}>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
// 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-haspopup="dialog"
disabled={iconsDisabled}
>
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton>
{/* 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
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={primaryAction.onClick}
aria-label={primaryAction.label}
aria-expanded={false}
aria-haspopup="dialog"
disabled={iconsDisabled}
>
<Icon size="100" src={primaryAction.iconSrc} />
</IconButton>
)}
<IconButton
variant="SurfaceVariant"
fill="None"

View file

@ -362,5 +362,14 @@ export const formInner = style({
top: 0,
left: 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 { useCurtainHandleGesture } from './useCurtainHandleGesture';
import { useCurtainBodyGesture } from './useCurtainBodyGesture';
import { InlineNewChatForm } from './forms/InlineNewChatForm';
import { InlineRoomSearch } from './forms/InlineRoomSearch';
const INLINE_FORM_ID = 'stream-header-inline-form';
@ -63,11 +62,11 @@ type StreamHeaderProps = {
// listing share `"channels"` so pin survives the toggle between
// empty state and a chosen workspace.
pinKey: string;
// Optional override for the Plus button. When omitted the header
// renders the default «new chat» action that opens InlineNewChatForm
// via the curtain. Channels overrides this with «create channel» /
// «create community» so the same Plus slot launches a contextual
// action instead of the DM-creation form.
// Optional contextual create action (a Plus button + peek chip). When
// omitted (Direct) the header shows no Plus at all — starting a chat
// is folded into search, which finds people via the homeserver user
// directory. Channels sets this to «create channel» / «create
// community» so its Plus launches that flow.
primaryAction?: StreamHeaderPrimaryAction;
};
@ -121,7 +120,12 @@ export function StreamHeader({
else navigate(BOTS_PATH, 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
// with something else that would otherwise race the pin path:
@ -186,6 +190,7 @@ export function StreamHeader({
snap: curtain.snap,
pinned: curtain.pinned,
setPinned: curtain.setPinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
@ -198,6 +203,7 @@ export function StreamHeader({
scrollRef,
snap: curtain.snap,
pinned: curtain.pinned,
peekTravelPx: curtain.peekTravelPx,
setLiveDrag: curtain.setLiveDrag,
commit: curtain.commit,
disabled: gestureDisabled,
@ -205,8 +211,7 @@ export function StreamHeader({
});
const isActive = isFormSnap(curtain.snap);
const openSearch = useCallback(() => curtain.open('search'), [curtain]);
const openChat = useCallback(() => curtain.open('chat'), [curtain]);
const openSearch = useCallback(() => curtain.open(), [curtain]);
const { close } = curtain;
// Memoised controls object so the cleanup's identity check (atom
@ -216,12 +221,11 @@ export function StreamHeader({
const pagerControls = useMemo<MobilePagerCurtainControls>(
() => ({
openSearch,
openChat,
closeForm: close,
isFormActive: isActive,
primaryAction: primaryAction ?? null,
}),
[openSearch, openChat, close, isActive, primaryAction]
[openSearch, close, isActive, primaryAction]
);
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
@ -260,7 +264,9 @@ export function StreamHeader({
const platformOffset = isNativePlatform() ? 0 : WEB_TABS_ROW_PX - TABS_ROW_PX;
const curtainTop = curtain.pinned
? 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.
// Guarded so unrelated transitionend events (e.g. children's own
@ -392,25 +398,26 @@ export function StreamHeader({
</IconButton>
) : (
<div className={css.iconsCluster}>
<IconButton
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
// `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-haspopup="dialog"
>
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton>
{/* 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
variant="SurfaceVariant"
fill="None"
size="400"
radii="Pill"
onClick={primaryAction.onClick}
aria-label={primaryAction.label}
aria-expanded={false}
aria-haspopup="dialog"
>
<Icon size="100" src={primaryAction.iconSrc} />
</IconButton>
)}
<IconButton
variant="SurfaceVariant"
fill="None"
@ -445,19 +452,14 @@ export function StreamHeader({
<div
id={INLINE_FORM_ID}
role="region"
aria-label={
curtain.activeForm === 'search'
? t('Search.search')
: t('Direct.create_chat_subtitle')
}
aria-label={t('Search.search')}
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} />}
<InlineRoomSearch onClose={close} />
</div>
</div>
) : (
@ -470,14 +472,19 @@ export function StreamHeader({
hidden={curtain.snap !== 'peek'}
/>
</div>
<div className={css.chipRow}>
<Chip
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
onClick={primaryAction ? primaryAction.onClick : openChat}
hidden={curtain.snap !== 'peek'}
/>
</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}>
<Chip
iconSrc={primaryAction.iconSrc}
label={primaryAction.label}
onClick={primaryAction.onClick}
hidden={curtain.snap !== 'peek'}
/>
</div>
)}
</>
)}
</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 { 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 { useMediaAuthentication } from '../../../hooks/useMediaAuthentication';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { RoomAvatar, RoomIcon } from '../../../components/room-avatar';
import { UserAvatar } from '../../../components/user-avatar';
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 { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix';
import { highlightText } from '../../../plugins/react-custom-html-parser';
import { getMxIdLocalPart, getMxIdServer, mxcUrlToHttp } from '../../../utils/matrix';
import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch';
import { SEARCH_FORM_BASE_PX } from '../geometry';
@ -20,7 +25,9 @@ type Props = {
// 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).
// 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) {
const { t } = useTranslation();
const mx = useMatrixClient();
@ -42,10 +49,16 @@ export function InlineRoomSearch({ onClose }: Props) {
roomsToRender,
result,
listFocus,
queryHighlightRegex,
query,
clearSearch,
handleInputChange,
handleInputKeyDown,
handleRoomClick,
peopleToRender,
directoryLoading,
handleUserClick,
creatingUserId,
roomCount,
getRoom,
mDirects,
orphanSpaces,
@ -62,13 +75,32 @@ export function InlineRoomSearch({ onClose }: Props) {
inputRef.current?.focus();
}, [inputRef]);
const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0;
return (
<Box direction="Column" gap="200" style={{ height: toRem(SEARCH_FORM_BASE_PX - 40) }}>
<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. */}
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"
style={{
width: '100%',
minWidth: 0,
backgroundColor: color.Background.Container,
borderRadius: toRem(20),
padding: `${toRem(8)} ${toRem(14)}`,
@ -96,12 +128,29 @@ export function InlineRoomSearch({ onClose }: Props) {
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>
{/* ── Result list ────────────────────────────────────── */}
<Box grow="Yes" style={{ minHeight: 0 }}>
<Scroll ref={scrollRef} size="300" hideTrack visibility="Hover">
{roomsToRender.length === 0 && (
{isEmpty && !directoryLoading && (
<Box
grow="Yes"
alignItems="Center"
@ -158,8 +207,8 @@ export function InlineRoomSearch({ onClose }: Props) {
appearance: 'none',
WebkitAppearance: 'none',
border: 'none',
backgroundColor: focused ? color.Primary.Main : 'transparent',
color: focused ? color.Primary.OnMain : color.Background.OnContainer,
backgroundColor: focused ? color.Background.ContainerHover : 'transparent',
color: color.Background.OnContainer,
borderRadius: toRem(12),
padding: `${toRem(8)} ${toRem(10)}`,
width: '100%',
@ -197,16 +246,11 @@ export function InlineRoomSearch({ onClose }: Props) {
</Avatar>
<Box grow="Yes" alignItems="Center" gap="100" style={{ minWidth: 0 }}>
<Text size="T400" truncate>
{queryHighlightRegex
? highlightText(queryHighlightRegex, [room.name])
: room.name}
{room.name}
</Text>
{dmUsername && (
<Text as="span" size="T200" priority="300" truncate>
@
{queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername])
: dmUsername}
@{dmUsername}
</Text>
)}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
@ -237,6 +281,100 @@ export function InlineRoomSearch({ onClose }: Props) {
})}
</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>
</Box>
</Box>

View file

@ -16,7 +16,9 @@
// closed = TABS_ROW_PX
// peek = TABS_ROW_PX + 2·CHIP_ROW_PX + CHIP_GAP_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
// 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.
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).
// Breathing strip between the bottom of the revealed peek chip(s) and
// the top of the curtain. Painted by the header's `SurfaceVariant.
// Container` (light-blue) so the chip / Create button never visually
// touches the curtain's rounded top — the user reads a chip that sits
// flush with the curtain as «зажатый» rather than two separate
// affordances. Applies to the PEEK snap only (via `peekTravelPx` and
// 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;
// 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` —
// the resting-top delta between the two snaps. Used as the basis for
// the peek-commit threshold: the user must drag (rubber-banded) at
// least COMMIT_THRESHOLD × PEEK_TRAVEL_PX before release for the snap
// to 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;
// least COMMIT_THRESHOLD × peekTravel before release for the snap to
// flip. Anything shorter reads as accidental and springs back.
//
// 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
// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of

View file

@ -4,7 +4,6 @@ import {
ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
RUBBER_BAND,
} from './geometry';
import { CurtainSnap, isFormSnap } from './useCurtainState';
@ -54,6 +53,10 @@ type Args = {
// by the touchstart bail) — pin / unpin commits are the handle's
// exclusive contract, see «Direction asymmetry» on the hook.
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,
// no direct DOM writes.
setLiveDrag: (px: number, dragging: boolean) => void;
@ -138,6 +141,7 @@ export function useCurtainBodyGesture({
scrollRef,
snap,
pinned,
peekTravelPx,
setLiveDrag,
commit,
disabled,
@ -147,6 +151,8 @@ export function useCurtainBodyGesture({
snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(peekTravelPx);
peekTravelPxRef.current = peekTravelPx;
const commitRef = useRef(commit);
commitRef.current = commit;
const setHandleStateRef = useRef(setHandleState);
@ -286,7 +292,7 @@ export function useCurtainBodyGesture({
// want exposed on the body. Rubber-banded 0.65×
// displacement matches the «physically heavier» body feel.
lastDelta = Math.max(0, delta * RUBBER_BAND);
atCommit = lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
atCommit = lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break;
case 'close-peek':
// 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
// territory (the user's hard rule — pin is handle-only).
lastDelta = delta * RUBBER_BAND;
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break;
case 'form-close':
// 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»
// above and the touchmove switch). Below threshold the
// curtain springs back to closed.
if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
}
break;
case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('closed');
} else {
setLiveDrag(0, false);

View file

@ -4,7 +4,6 @@ import {
ACTIVE_CLOSE_THRESHOLD_PX,
COMMIT_THRESHOLD,
DIRECTION_DEAD_ZONE_PX,
PEEK_TRAVEL_PX,
PIN_COMMIT_THRESHOLD,
PIN_TRAVEL_PX,
} from './geometry';
@ -28,6 +27,10 @@ type Args = {
// Setter for the pinned overlay; called on release once the user's
// drag past the commit threshold qualifies the gesture.
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
// `liveDragPx` from the parent state too, so React drives the
// curtain's `top` re-render — no direct DOM writes.
@ -187,6 +190,7 @@ export function useCurtainHandleGesture({
snap,
pinned,
setPinned,
peekTravelPx,
setLiveDrag,
commit,
disabled,
@ -196,6 +200,8 @@ export function useCurtainHandleGesture({
snapRef.current = snap;
const pinnedRef = useRef<boolean>(pinned);
pinnedRef.current = pinned;
const peekTravelPxRef = useRef<number>(peekTravelPx);
peekTravelPxRef.current = peekTravelPx;
const setPinnedRef = useRef(setPinned);
setPinnedRef.current = setPinned;
const commitRef = useRef(commit);
@ -304,7 +310,7 @@ export function useCurtainHandleGesture({
atCommit =
lastDelta <= 0
? -lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD
: lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
: lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break;
case 'pinned-free':
// 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
// anywhere» expectation.
lastDelta = Math.min(0, delta);
atCommit = -lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD;
atCommit = -lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD;
break;
case 'form-close':
// 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.
if (-lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
setPinnedRef.current(true);
} else if (lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
} else if (lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('peek');
} else {
setLiveDrag(0, false);
@ -394,7 +400,7 @@ export function useCurtainHandleGesture({
// alone would set snap='peek' yet leave the curtain
// visually at top=0 (the pin overlay wins). Both updates
// 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);
commitRef.current('peek');
} else if (lastDelta / PIN_TRAVEL_PX >= PIN_COMMIT_THRESHOLD) {
@ -404,7 +410,7 @@ export function useCurtainHandleGesture({
}
break;
case 'close-peek':
if (-lastDelta / PEEK_TRAVEL_PX >= COMMIT_THRESHOLD) {
if (-lastDelta / peekTravelPxRef.current >= COMMIT_THRESHOLD) {
commitRef.current('closed');
} else {
setLiveDrag(0, false);

View file

@ -1,33 +1,26 @@
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { useAtom } from 'jotai';
import { curtainPinnedByTabAtom } from '../../state/mobilePagerHeader';
import {
CHIP_GAP_PX,
CHIP_ROW_PX,
CURTAIN_BREATHER_PX,
CURTAIN_SNAP_MS,
SEARCH_FORM_BASE_PX,
TABS_ROW_PX,
} from './geometry';
import { CURTAIN_SNAP_MS, peekTravelPx, 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
| 'peek' // both action chips (search + new chat) revealed
| 'form-search' // full search form revealed
| 'form-chat'; // full new-chat form revealed
| 'peek' // action chip(s) revealed (Search, plus the tab's create action on Channels)
| 'form-search'; // full search form revealed
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' | 'form-chat' =>
snap === 'form-search' || snap === 'form-chat';
export const isFormSnap = (snap: CurtainSnap): snap is 'form-search' => snap === 'form-search';
export const isPeekSnap = (snap: CurtainSnap): snap is 'peek' => snap === 'peek';
// 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;
// Whether a form is 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`. 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 = {
snap: CurtainSnap;
@ -52,15 +45,19 @@ export type CurtainState = {
// 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.
// the curtain's resting `top` when `snap === 'form-search'`. `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;
// Closed→peek travel for this curtain's chip count. Threaded into the
// 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`
// set until the snap transition lands so the form stays mounted
// during the slide-up.
@ -80,29 +77,41 @@ export type CurtainState = {
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 {
// Resting `top` (px) of the curtain for a given snap stop, the
// currently measured form height (null falls back to the base), and
// the chip-count-derived peek travel (`peekTravel`). The peek rest
// lands exactly `peekTravel` below the tabs row so the curtain's edge
// sits on the boundary below the revealed chip(s) — no «next chip pill
// peeking through».
//
// The form rests at exactly `TABS_ROW_PX + formHeight` — NO breather
// (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) {
case 'closed':
return TABS_ROW_PX;
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-chat':
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX;
return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX);
default:
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 [activeForm, setActiveForm] = useState<ActiveForm>(null);
const [formHeightPx, setFormHeightPx] = useState<number | null>(null);
@ -115,6 +124,8 @@ export function useCurtainState(pinKey: string): CurtainState {
const [pinnedMap, setPinnedMap] = useAtom(curtainPinnedByTabAtom);
const pinned = !!pinnedMap[pinKey];
const peekTravel = peekTravelPx(chipRows);
const formMeasureRef = useRef<HTMLDivElement>(null);
const setPinned = useCallback(
@ -134,23 +145,20 @@ export function useCurtainState(pinKey: string): CurtainState {
[pinKey, setPinnedMap]
);
const open = useCallback(
(form: 'search' | 'chat') => {
setActiveForm(form);
setSnap(form === 'search' ? 'form-search' : 'form-chat');
setLiveDragPx(0);
setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the
// visible openers (static pager header icons, in-pane chips on
// non-pager surfaces) are all covered by the curtain when pinned,
// so the user can't trigger this directly — but a future
// programmatic open() would otherwise mount the form behind the
// still-pinned curtain at curtainTop=0 and present an invisible
// form.
setPinned(false);
},
[setPinned]
);
const open = useCallback(() => {
setActiveForm('search');
setSnap('form-search');
setLiveDragPx(0);
setIsDragging(false);
// Safety net: clear pin so the form is visible. In practice the
// visible openers (static pager header icons, in-pane chips on
// non-pager surfaces) are all covered by the curtain when pinned,
// so the user can't trigger this directly — but a future
// programmatic open() would otherwise mount the form behind the
// still-pinned curtain at curtainTop=0 and present an invisible
// form.
setPinned(false);
}, [setPinned]);
const close = useCallback(() => {
setSnap('closed');
@ -225,6 +233,7 @@ export function useCurtainState(pinKey: string): CurtainState {
isDragging,
formHeightPx,
formMeasureRef,
peekTravelPx: peekTravel,
open,
close,
commit,
@ -239,6 +248,7 @@ export function useCurtainState(pinKey: string): CurtainState {
liveDragPx,
isDragging,
formHeightPx,
peekTravel,
open,
close,
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,
Overlay,
OverlayCenter,
IconButton,
Scroll,
Spinner,
Text,
toRem,
} from 'folds';
@ -20,16 +22,16 @@ import { isKeyHotkey } from 'is-hotkey';
import { useAtom } from 'jotai';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UserAvatar } from '../../components/user-avatar';
import {
getAllParents,
getDirectRoomAvatarUrl,
getRoomAvatarUrl,
guessPerfectParent,
} from '../../utils/room';
import { highlightText } from '../../plugins/react-custom-html-parser';
import { nameInitials } from '../../utils/common';
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 { searchModalAtom } from '../../state/searchModal';
import { useKeyDown } from '../../hooks/useKeyDown';
@ -63,10 +65,16 @@ export function Search({ requestClose }: SearchProps) {
roomsToRender,
result,
listFocus,
queryHighlightRegex,
query,
clearSearch,
handleInputChange,
handleInputKeyDown,
handleRoomClick,
peopleToRender,
directoryLoading,
handleUserClick,
creatingUserId,
roomCount,
getRoom,
mDirects,
orphanSpaces,
@ -75,6 +83,8 @@ export function Search({ requestClose }: SearchProps) {
myUserId,
} = useRoomSearch({ onOpenRoomId: openRoomId });
const isEmpty = roomsToRender.length === 0 && peopleToRender.length === 0;
return (
<Overlay open>
<OverlayCenter>
@ -94,6 +104,12 @@ export function Search({ requestClose }: SearchProps) {
<Modal
size="400"
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),
borderRadius: config.radii.R400,
backgroundColor: '#181a20',
@ -101,12 +117,18 @@ export function Search({ requestClose }: SearchProps) {
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
shrink="No"
alignItems="Center"
gap="200"
style={{
width: '100%',
minWidth: 0,
padding: `${toRem(12)} ${config.space.S400}`,
borderBottom: HAIRLINE,
}}
@ -131,9 +153,26 @@ export function Search({ requestClose }: SearchProps) {
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 grow="Yes">
{roomsToRender.length === 0 && (
{isEmpty && !directoryLoading && (
<Box
style={{ paddingTop: config.space.S700 }}
grow="Yes"
@ -150,9 +189,19 @@ export function Search({ requestClose }: SearchProps) {
? t('Search.no_match_for_query', { query: result.query })
: t('Search.no_rooms_to_display')}
</Text>
{result && (
<Text
size="T200"
align="Center"
priority="300"
style={{ maxWidth: toRem(300) }}
>
{t('Search.other_server_hint')}
</Text>
)}
</Box>
)}
{roomsToRender.length > 0 && (
{!isEmpty && (
<Scroll ref={scrollRef} size="300" hideTrack>
<div style={{ padding: config.space.S200, paddingRight: config.space.S100 }}>
{roomsToRender.map((roomId, index) => {
@ -190,10 +239,10 @@ export function Search({ requestClose }: SearchProps) {
aria-pressed={focused}
radii="300"
style={{
// Fleet highlight: a violet tint + a 2px left bar
// instead of a solid Primary fill (Dawn selection).
backgroundColor: focused ? 'rgba(149, 128, 255, 0.08)' : undefined,
boxShadow: focused ? `inset 2px 0 0 ${color.Primary.Main}` : undefined,
// Neutral focus highlight — a soft surface tint,
// no accent colour (the violet selection read as
// garish next to the yellow match highlight).
backgroundColor: focused ? 'rgba(255, 255, 255, 0.06)' : undefined,
}}
after={
<Box gap="100">
@ -256,9 +305,7 @@ export function Search({ requestClose }: SearchProps) {
>
<Box grow="Yes" alignItems="Center" gap="100">
<Text size="T400" truncate>
{queryHighlightRegex
? highlightText(queryHighlightRegex, [room.name])
: room.name}
{room.name}
</Text>
{dmUsername && (
<Text
@ -268,10 +315,7 @@ export function Search({ requestClose }: SearchProps) {
truncate
style={{ fontFamily: 'var(--font-mono)' }}
>
@
{queryHighlightRegex
? highlightText(queryHighlightRegex, [dmUsername])
: dmUsername}
@{dmUsername}
</Text>
)}
{!dm && perfectParent && perfectParent !== perfectOrphanParent && (
@ -283,6 +327,108 @@ export function Search({ requestClose }: SearchProps) {
</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>
</Scroll>
)}

View file

@ -25,8 +25,11 @@ 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 { getDMRoomFor, getMxIdLocalPart, guessDmRoomUserId, isUserId } from '../../utils/matrix';
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 {
Rooms = '#',
@ -45,6 +48,25 @@ const SEARCH_OPTIONS: UseAsyncSearchOptions = {
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 = (
roomId: string,
getRoom: (roomId: string) => Room | undefined,
@ -55,19 +77,25 @@ export const getDmUserId = (
};
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)`.
// Called after the user commits a room selection (click or Enter), or
// after a People hit resolves to a DM room (existing or freshly
// created). 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.
// Single source of truth for the unified search panel — local
// rooms/DMs/spaces (prefix detection `#`/`*`/`@`, filtered scope,
// top-active fallback) AND people from the homeserver user directory
// (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) {
const mx = useMatrixClient();
const alive = useAlive();
const createDirect = useCreateDirect();
const inputRef = useRef<HTMLInputElement>(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 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
? makeHighlightRegex(result.query.split(' '))
: 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(
(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;
setQuery(evt.currentTarget.value);
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];
nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined;
if (nextType) value = value.slice(1);
}
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);
}
search(value);
},
[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(
(evt) => {
const roomId = roomsToRender[listFocus.index];
if (isKeyHotkey('enter', evt) && roomId) {
onOpenRoomId(roomId, spaces.includes(roomId));
if (isKeyHotkey('enter', evt)) {
const { index } = listFocus;
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;
}
if (isKeyHotkey('arrowdown', evt)) {
@ -159,7 +325,7 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
listFocus.previous();
}
},
[roomsToRender, listFocus, onOpenRoomId, spaces]
[roomsToRender, roomCount, peopleToRender, listFocus, onOpenRoomId, spaces, startDmWithUser]
);
const handleRoomClick: MouseEventHandler<HTMLButtonElement> = useCallback(
@ -173,6 +339,15 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
[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.
useEffect(() => {
const scrollView = scrollRef.current;
@ -190,9 +365,19 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) {
result,
listFocus,
queryHighlightRegex,
query,
clearSearch,
handleInputChange,
handleInputKeyDown,
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:
getRoom,
mDirects,

View file

@ -31,9 +31,11 @@ export function DirectCreate() {
}, [mx, navigate, directs, userId]);
// 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
// (InlineNewChatForm) is the canonical native new-chat surface, and the
// keyboard no longer fights an oversized hero on short native viewports.
// grouped CreateChat block in a padded Scroll. This explicit create-by-id
// surface backs the `/u/<user>` deep link (UserLinkRedirect) and the Direct
// 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 (
<Page>
<PageHeader balance outlined={false}>

View file

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