feat(search): find homeserver-directory users in search and start DMs from there, retiring the Direct new-chat Plus
This commit is contained in:
parent
c12c228eb8
commit
0aaecbbe2e
15 changed files with 790 additions and 234 deletions
|
|
@ -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/`.
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
{/* 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 ? 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}
|
||||
onClick={primaryAction.onClick}
|
||||
aria-label={primaryAction.label}
|
||||
aria-expanded={false}
|
||||
aria-haspopup="dialog"
|
||||
disabled={iconsDisabled}
|
||||
>
|
||||
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
|
||||
<Icon size="100" src={primaryAction.iconSrc} />
|
||||
</IconButton>
|
||||
)}
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
fill="None"
|
||||
|
|
|
|||
|
|
@ -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`,
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
{/* 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 ? 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}
|
||||
onClick={primaryAction.onClick}
|
||||
aria-label={primaryAction.label}
|
||||
aria-expanded={false}
|
||||
aria-haspopup="dialog"
|
||||
>
|
||||
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
|
||||
<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>
|
||||
{/* 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 ? primaryAction.iconSrc : Icons.Plus}
|
||||
label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
|
||||
onClick={primaryAction ? primaryAction.onClick : openChat}
|
||||
iconSrc={primaryAction.iconSrc}
|
||||
label={primaryAction.label}
|
||||
onClick={primaryAction.onClick}
|
||||
hidden={curtain.snap !== 'peek'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</header>
|
||||
|
|
|
|||
|
|
@ -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} />;
|
||||
}
|
||||
|
|
@ -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) }}>
|
||||
{/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14)
|
||||
so the chip → input morph reads as a content crossfade. */}
|
||||
<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"
|
||||
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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,10 +145,9 @@ export function useCurtainState(pinKey: string): CurtainState {
|
|||
[pinKey, setPinnedMap]
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
(form: 'search' | 'chat') => {
|
||||
setActiveForm(form);
|
||||
setSnap(form === 'search' ? 'form-search' : 'form-chat');
|
||||
const open = useCallback(() => {
|
||||
setActiveForm('search');
|
||||
setSnap('form-search');
|
||||
setLiveDragPx(0);
|
||||
setIsDragging(false);
|
||||
// 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
|
||||
// form.
|
||||
setPinned(false);
|
||||
},
|
||||
[setPinned]
|
||||
);
|
||||
}, [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,
|
||||
|
|
|
|||
45
src/app/features/create-chat/useCreateDirect.ts
Normal file
45
src/app/features/create-chat/useCreateDirect.ts
Normal 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]
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
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];
|
||||
const nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined;
|
||||
setSearchRoomType(nextType);
|
||||
nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined;
|
||||
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(
|
||||
(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,
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue