From 0aaecbbe2edc29231c8b92baa3f89c199ebdfff1 Mon Sep 17 00:00:00 2001 From: heaven Date: Thu, 4 Jun 2026 23:49:33 +0300 Subject: [PATCH] feat(search): find homeserver-directory users in search and start DMs from there, retiring the Direct new-chat Plus --- docs/ai/architecture.md | 6 +- .../MobileTabsPagerHeader.tsx | 51 ++-- .../stream-header/StreamHeader.css.ts | 11 +- .../components/stream-header/StreamHeader.tsx | 99 ++++---- .../stream-header/forms/InlineNewChatForm.tsx | 18 -- .../stream-header/forms/InlineRoomSearch.tsx | 174 +++++++++++-- src/app/components/stream-header/geometry.ts | 43 +++- .../stream-header/useCurtainBodyGesture.ts | 16 +- .../stream-header/useCurtainHandleGesture.ts | 18 +- .../stream-header/useCurtainState.ts | 112 +++++---- .../features/create-chat/useCreateDirect.ts | 45 ++++ src/app/features/search/Search.tsx | 180 ++++++++++++-- src/app/features/search/useRoomSearch.ts | 235 ++++++++++++++++-- src/app/pages/client/direct/DirectCreate.tsx | 8 +- src/app/state/mobilePagerHeader.ts | 8 +- 15 files changed, 790 insertions(+), 234 deletions(-) delete mode 100644 src/app/components/stream-header/forms/InlineNewChatForm.tsx create mode 100644 src/app/features/create-chat/useCreateDirect.ts diff --git a/docs/ai/architecture.md b/docs/ai/architecture.md index a4cd9e2d..b1f6fe96 100644 --- a/docs/ai/architecture.md +++ b/docs/ai/architecture.md @@ -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/` 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/`. diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx index e41fb1ce..487b3be2 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -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({ ) : (
- - - + {/* 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 && ( + + + + )} 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( () => ({ 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({ ) : (
- - - + {/* 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 && ( + + + + )}
- {curtain.activeForm === 'search' && } - {curtain.activeForm === 'chat' && } +
) : ( @@ -470,14 +472,19 @@ export function StreamHeader({ hidden={curtain.snap !== 'peek'} />
-
-
+ {/* 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 && ( +
+
+ )} )} diff --git a/src/app/components/stream-header/forms/InlineNewChatForm.tsx b/src/app/components/stream-header/forms/InlineNewChatForm.tsx deleted file mode 100644 index 16f1abd0..00000000 --- a/src/app/components/stream-header/forms/InlineNewChatForm.tsx +++ /dev/null @@ -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 ; -} diff --git a/src/app/components/stream-header/forms/InlineRoomSearch.tsx b/src/app/components/stream-header/forms/InlineRoomSearch.tsx index 40780fda..e2e9c8cd 100644 --- a/src/app/components/stream-header/forms/InlineRoomSearch.tsx +++ b/src/app/components/stream-header/forms/InlineRoomSearch.tsx @@ -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 ( - + {/* ── 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". */} + {directoryLoading ? ( + + ) : ( + query.length > 0 && ( + + + + ) + )} {/* ── Result list ────────────────────────────────────── */} - {roomsToRender.length === 0 && ( + {isEmpty && !directoryLoading && ( - {queryHighlightRegex - ? highlightText(queryHighlightRegex, [room.name]) - : room.name} + {room.name} {dmUsername && ( - @ - {queryHighlightRegex - ? highlightText(queryHighlightRegex, [dmUsername]) - : dmUsername} + @{dmUsername} )} {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( @@ -237,6 +281,100 @@ export function InlineRoomSearch({ onClose }: Props) { })} )} + + {/* ── People (homeserver user directory) ─────────────── */} + {peopleToRender.length > 0 && ( + + + {t('Search.people')} + + {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 ( + + ); + })} + + )} + + {/* ── 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 && ( + + + {t('Search.other_server_hint')} + + + )} diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts index 1d3f2f74..68264f64 100644 --- a/src/app/components/stream-header/geometry.ts +++ b/src/app/components/stream-header/geometry.ts @@ -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 diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts index cc00fef8..ea91aa93 100644 --- a/src/app/components/stream-header/useCurtainBodyGesture.ts +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -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(pinned); pinnedRef.current = pinned; + const peekTravelPxRef = useRef(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); diff --git a/src/app/components/stream-header/useCurtainHandleGesture.ts b/src/app/components/stream-header/useCurtainHandleGesture.ts index aa99be04..ece87a34 100644 --- a/src/app/components/stream-header/useCurtainHandleGesture.ts +++ b/src/app/components/stream-header/useCurtainHandleGesture.ts @@ -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(pinned); pinnedRef.current = pinned; + const peekTravelPxRef = useRef(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); diff --git a/src/app/components/stream-header/useCurtainState.ts b/src/app/components/stream-header/useCurtainState.ts index 8e9b8711..6216f15c 100644 --- a/src/app/components/stream-header/useCurtainState.ts +++ b/src/app/components/stream-header/useCurtainState.ts @@ -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; - // 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('closed'); const [activeForm, setActiveForm] = useState(null); const [formHeightPx, setFormHeightPx] = useState(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(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, diff --git a/src/app/features/create-chat/useCreateDirect.ts b/src/app/features/create-chat/useCreateDirect.ts new file mode 100644 index 00000000..48095a7e --- /dev/null +++ b/src/app/features/create-chat/useCreateDirect.ts @@ -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 { + 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] + ); +} diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 772ef6fe..2b8b88e7 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -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 ( @@ -94,6 +104,12 @@ export function Search({ requestClose }: SearchProps) { - {/* ── 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. */} + {directoryLoading ? ( + + ) : ( + query.length > 0 && ( + + + + ) + )} - {roomsToRender.length === 0 && ( + {isEmpty && !directoryLoading && ( + {result && ( + + {t('Search.other_server_hint')} + + )} )} - {roomsToRender.length > 0 && ( + {!isEmpty && (
{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={ @@ -256,9 +305,7 @@ export function Search({ requestClose }: SearchProps) { > - {queryHighlightRegex - ? highlightText(queryHighlightRegex, [room.name]) - : room.name} + {room.name} {dmUsername && ( - @ - {queryHighlightRegex - ? highlightText(queryHighlightRegex, [dmUsername]) - : dmUsername} + @{dmUsername} )} {!dm && perfectParent && perfectParent !== perfectOrphanParent && ( @@ -283,6 +327,108 @@ export function Search({ requestClose }: SearchProps) { ); })} + + {/* ── People (homeserver user directory) ─────────── */} + {peopleToRender.length > 0 && ( + <> + + {t('Search.people')} + + {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 ( + + ) : ( + server && ( + + {server} + + ) + ) + } + before={ + + ( + + {nameInitials(name)} + + )} + /> + + } + > + + + {name} + + + @{username} + + + + ); + })} + + )} + + {/* 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 && ( + + {t('Search.other_server_hint')} + + )}
)} diff --git a/src/app/features/search/useRoomSearch.ts b/src/app/features/search/useRoomSearch.ts index fd5284a6..f9a7ac68 100644 --- a/src/app/features/search/useRoomSearch.ts +++ b/src/app/features/search/useRoomSearch.ts @@ -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(null); const scrollRef = useRef(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([]); + 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(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 = 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 = 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 = useCallback( @@ -173,6 +339,15 @@ export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { [onOpenRoomId] ); + const handleUserClick: MouseEventHandler = 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, diff --git a/src/app/pages/client/direct/DirectCreate.tsx b/src/app/pages/client/direct/DirectCreate.tsx index 60a02454..3329cc6e 100644 --- a/src/app/pages/client/direct/DirectCreate.tsx +++ b/src/app/pages/client/direct/DirectCreate.tsx @@ -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/` 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 ( diff --git a/src/app/state/mobilePagerHeader.ts b/src/app/state/mobilePagerHeader.ts index 683299e5..ff431ff1 100644 --- a/src/app/state/mobilePagerHeader.ts +++ b/src/app/state/mobilePagerHeader.ts @@ -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; };