diff --git a/public/locales/en.json b/public/locales/en.json index 6e1193d9..ee0e114f 100644 --- a/public/locales/en.json +++ b/public/locales/en.json @@ -392,7 +392,8 @@ "e2e_encryption": "End-to-End Encryption", "e2e_encryption_desc": "Once this feature is enabled, it can't be disabled after the room is created.", "rate_limited": "Server rate-limited your request for {{minutes}} minutes!", - "create": "Create" + "create": "Create", + "close": "Close" }, "Channels": { "no_spaces_title": "No communities yet", diff --git a/public/locales/ru.json b/public/locales/ru.json index 20ea9ff7..4e20a631 100644 --- a/public/locales/ru.json +++ b/public/locales/ru.json @@ -394,7 +394,8 @@ "e2e_encryption": "Сквозное шифрование", "e2e_encryption_desc": "После включения эту функцию нельзя отключить после создания комнаты.", "rate_limited": "Сервер ограничил частоту запросов на {{minutes}} мин.!", - "create": "Создать" + "create": "Создать", + "close": "Закрыть" }, "Channels": { "no_spaces_title": "Пока нет сообществ", diff --git a/src/app/components/stream-header/Chip.tsx b/src/app/components/stream-header/Chip.tsx new file mode 100644 index 00000000..417e4783 --- /dev/null +++ b/src/app/components/stream-header/Chip.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { Icon, IconSrc } from 'folds'; +import * as css from './StreamHeader.css'; + +type ChipProps = { + iconSrc: IconSrc; + label: string; + onClick: () => void; + // When the curtain covers the chip its row is `height: 0` / + // `overflow: hidden`. We also flip `tabIndex` so keyboard users + // can't focus the invisible button on desktop (where peek-drag is + // unavailable). Re-enabled when the row is revealed. + hidden: boolean; +}; + +// Pill-shaped reveal button shown when the user drags the curtain down +// to a peek stage. Same geometry as the inline form's input bar so the +// transition chip → input feels like a content swap, not a layout move. +export function Chip({ iconSrc, label, onClick, hidden }: ChipProps) { + return ( + + ); +} diff --git a/src/app/components/stream-header/Segment.tsx b/src/app/components/stream-header/Segment.tsx new file mode 100644 index 00000000..21101ca8 --- /dev/null +++ b/src/app/components/stream-header/Segment.tsx @@ -0,0 +1,28 @@ +import React, { forwardRef } from 'react'; +import * as css from './StreamHeader.css'; + +type SegmentProps = { + active: boolean; + disabled?: boolean; + label: string; + onClick?: () => void; +}; + +// Tab segment for the StreamHeader row. Active state is communicated +// by a violet dot (folds `Primary.Main`) and a heavier font weight. +export const Segment = forwardRef( + ({ active, disabled, label, onClick }, ref) => ( + + ) +); +Segment.displayName = 'Segment'; diff --git a/src/app/components/stream-header/StreamHeader.css.ts b/src/app/components/stream-header/StreamHeader.css.ts new file mode 100644 index 00000000..cf1b8867 --- /dev/null +++ b/src/app/components/stream-header/StreamHeader.css.ts @@ -0,0 +1,233 @@ +import { style } from '@vanilla-extract/css'; +import { recipe } from '@vanilla-extract/recipes'; +import { color, toRem } from 'folds'; +import { + CHIP_GAP_PX, + CURTAIN_BREATHER_PX, + CURTAIN_RADIUS_PX, + CURTAIN_SNAP_EASING, + CURTAIN_SNAP_MS, + TABS_ROW_PX, +} from './geometry'; + +// Stage. Position-relative anchor. The header itself paints the +// light-blue backdrop; the curtain is layered ABOVE it via z-index. +export const stage = style({ + position: 'relative', + flex: 1, + minHeight: 0, + display: 'flex', + flexDirection: 'column', + backgroundColor: color.SurfaceVariant.Container, +}); + +// Header — always-rendered strip carrying tabs row + (optional) chip +// reveal area + (optional) active form. The curtain slides on top of +// the area BELOW the tabs row to cover/reveal those children. +export const header = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + display: 'flex', + flexDirection: 'column', + // Higher than `stage`, lower than `curtain` so the curtain occludes + // everything below the tabs row when raised. + zIndex: 1, + backgroundColor: color.SurfaceVariant.Container, +}); + +// Tabs row. Stays fully visible regardless of curtain position +// because the curtain's `top` floor equals `TABS_ROW_PX`. +export const tabsRow = style({ + flexShrink: 0, + height: toRem(TABS_ROW_PX), + display: 'flex', + alignItems: 'center', + padding: `0 ${toRem(8)}`, +}); + +export const tabsCluster = style({ + display: 'flex', + alignItems: 'center', + gap: toRem(4), + alignSelf: 'stretch', +}); + +export const iconsCluster = style({ + display: 'flex', + alignItems: 'center', + gap: toRem(4), + flexShrink: 0, +}); + +// Curtain. Layered above the header (z-index higher). Its top edge +// moves with the snap state (and live finger drag); its bottom edge +// is anchored to the stage bottom so the curtain's `bottomPinned` +// child (DirectSelfRow / ChannelCreateRow / WorkspaceFooter) stays +// glued to the visible viewport bottom regardless of where the +// curtain's top is. +// +// Only the TOP corners are rounded: the bottom is meant to read as +// continuous with the always-visible bottomPinned row (DirectSelfRow +// is the curtain's last flex child) — adding `borderBottomRadius` +// would crop the row's corners against the curtain's +// `overflow: hidden`, which visually reads as «a light-blue strip +// cuts into the row». +// +// Live finger tracking and snap commits both flow through React state +// updates to `top` so the transition is always coordinated with the +// rendered position — disabled during drag, restored on commit. +export const curtain = style({ + position: 'absolute', + left: 0, + right: 0, + bottom: 0, + zIndex: 2, + display: 'flex', + flexDirection: 'column', + minHeight: 0, + overflow: 'hidden', + backgroundColor: color.Background.Container, + borderTopLeftRadius: toRem(CURTAIN_RADIUS_PX), + borderTopRightRadius: toRem(CURTAIN_RADIUS_PX), + transition: `top ${CURTAIN_SNAP_MS}ms ${CURTAIN_SNAP_EASING}`, + // Hint the compositor while the curtain is moving. Cheap since the + // curtain is the only element in this stacking context that animates. + willChange: 'top', +}); + +// Wrapper around `bottomPinned` inside the curtain. Anchored to the +// curtain's flex-bottom by virtue of being the last child. The TSX +// applies a `transform: translateY(keyboardH)` to this element when +// the on-screen keyboard rises (via `VisualViewport.height` shrink) +// so the row stays at its ORIGINAL viewport-bottom position — under +// the keyboard, clipped by the curtain's `overflow: hidden`. Without +// this compensation, `interactive-widget=resizes-content` (global +// meta — load-bearing for the room composer) shrinks the layout +// viewport, dragging every `bottom: 0` element up over the inline +// form. The DirectSelfRow ending up immediately above the keyboard +// blocks the user's view of the form they're typing into. +export const bottomPinnedSlot = style({ + flexShrink: 0, + // Compositor hint — the transform is applied/cleared on every + // VisualViewport resize while a keyboard is open. + willChange: 'transform', +}); + +// Segment button (Direct / Channels / Bots). +export const segment = recipe({ + base: { + appearance: 'none', + border: 'none', + background: 'transparent', + color: color.Background.OnContainer, + cursor: 'pointer', + padding: `${toRem(8)} ${toRem(10)}`, + borderRadius: toRem(8), + font: 'inherit', + fontSize: toRem(14), + lineHeight: 1.2, + display: 'inline-flex', + alignItems: 'center', + gap: toRem(8), + whiteSpace: 'nowrap', + fontWeight: 500, + WebkitAppearance: 'none', + }, + variants: { + active: { + true: { fontWeight: 600 }, + }, + disabled: { + true: { opacity: 0.45, cursor: 'default' }, + }, + }, +}); + +// Active-state dot inside each segment. +export const segmentDot = recipe({ + base: { + width: toRem(6), + height: toRem(6), + borderRadius: '50%', + flexShrink: 0, + }, + variants: { + active: { + true: { backgroundColor: color.Primary.Main }, + false: { backgroundColor: 'transparent' }, + }, + }, +}); + +// Chip row — outer clip-strip. Each row reveals from underneath the +// curtain when the user drags down to the corresponding peek stage. +// +// The `marginBottom` math is load-bearing for the snap-top +// calculation: the resting `top` of `peek1`/`peek2` lands the curtain +// exactly where the next row would have begun, so the breather never +// "steals" pixels from the next chip's paddingTop. Two different +// values: +// - default (chip-to-chip): `CHIP_GAP_PX` — tighter, so the two +// pills read as a related pair when both are revealed at peek2. +// - `:last-child` (chip-to-curtain): `CURTAIN_BREATHER_PX` — wider, +// so the curtain's rounded top has comfortable air above the +// chip pill it lands above. +export const chipRow = style({ + height: toRem(56), + marginBottom: toRem(CHIP_GAP_PX), + paddingLeft: toRem(24), + paddingRight: toRem(24), + paddingTop: toRem(8), + display: 'flex', + alignItems: 'flex-start', + selectors: { + '&:last-child': { + marginBottom: toRem(CURTAIN_BREATHER_PX), + }, + }, +}); + +// The chip pill itself. +export const chip = style({ + appearance: 'none', + border: 'none', + display: 'flex', + alignItems: 'center', + gap: toRem(10), + width: '100%', + height: toRem(48), + padding: `${toRem(8)} ${toRem(14)}`, + borderRadius: toRem(20), + font: 'inherit', + fontSize: toRem(14), + textAlign: 'left', + cursor: 'pointer', + backgroundColor: color.Background.Container, + color: color.Background.OnContainer, + WebkitAppearance: 'none', +}); + +export const chipPlaceholder = style({ + opacity: 0.65, + whiteSpace: 'nowrap', +}); + +// Active form area in the header. Outer is `position: relative`; the +// inner mounted form fills it with `top: 0` so the form's first +// element (input bar) sits flush at the line where chips would +// otherwise live. +export const formArea = style({ + position: 'relative', + flexShrink: 0, + overflow: 'hidden', +}); + +export const formInner = style({ + position: 'absolute', + top: 0, + left: 0, + right: 0, + padding: `${toRem(8)} ${toRem(24)} ${toRem(12)}`, +}); diff --git a/src/app/components/stream-header/StreamHeader.tsx b/src/app/components/stream-header/StreamHeader.tsx new file mode 100644 index 00000000..2824e665 --- /dev/null +++ b/src/app/components/stream-header/StreamHeader.tsx @@ -0,0 +1,291 @@ +import React, { + MutableRefObject, + ReactNode, + TransitionEvent, + useCallback, + useEffect, + useMemo, + useState, +} from 'react'; +import { useTranslation } from 'react-i18next'; +import { useMatch, useNavigate } from 'react-router-dom'; +import { Box, Icon, IconButton, Icons, toRem } from 'folds'; +import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths'; +import { isNativePlatform } from '../../utils/capacitor'; +import { useBotPresets } from '../../features/bots/catalog'; +import * as css from './StreamHeader.css'; +import { CHIP_ROW_PX, TABS_ROW_PX } from './geometry'; +import { Segment } from './Segment'; +import { Chip } from './Chip'; +import { isFormSnap, snapTopPx, useCurtainState } from './useCurtainState'; +import { useCurtainGesture } from './useCurtainGesture'; +import { InlineNewChatForm } from './forms/InlineNewChatForm'; +import { InlineRoomSearch } from './forms/InlineRoomSearch'; + +const INLINE_FORM_ID = 'stream-header-inline-form'; + +type StreamHeaderProps = { + // Scroll viewport that hosts the chat list under the curtain. The + // curtain's children (`children` prop) render inside an element that + // receives `scrollRef` automatically — the parent doesn't need to + // wire it. The ref is used by the touch gesture to recognise list + // scrollTop=0 and engage the peek-reveal. + scrollRef: MutableRefObject; + // Curtain contents — the chat list. The list is rendered inside an + // `overflow: auto` div that the gesture hook listens to. + children: ReactNode; + // Optional row(s) pinned to the bottom of the curtain (DirectSelfRow, + // ChannelCreateRow, WorkspaceFooter). Hidden while a form is active + // so the on-screen keyboard's viewport resize doesn't push them up + // over the form (see commit 14ed080). + bottomPinned?: ReactNode; +}; + +export function StreamHeader({ scrollRef, children, bottomPinned }: StreamHeaderProps) { + const { t } = useTranslation(); + const navigate = useNavigate(); + const bots = useBotPresets(); + const navOpts = useMemo(() => ({ replace: isNativePlatform() }), []); + + const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false }); + const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false }); + const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false }); + const showBotsSegment = bots.length > 0 || !!botsMatch; + + const curtain = useCurtainState(); + + useCurtainGesture({ + scrollRef, + snap: curtain.snap, + setLiveDrag: curtain.setLiveDrag, + commit: curtain.commit, + }); + + const isActive = isFormSnap(curtain.snap); + const openSearch = useCallback(() => curtain.open('search'), [curtain]); + const openChat = useCallback(() => curtain.open('chat'), [curtain]); + const { close } = curtain; + + // Curtain's `top` is the resting snap position plus the live drag + // delta. React-driven (no inline DOM writes), so finger-tracking and + // commit happen in the same render pipeline and there's no + // intermediate "snap back, then animate" flash on release. + const curtainTop = snapTopPx(curtain.snap, curtain.formHeightPx) + curtain.liveDragPx; + + // After the curtain settles at `closed`, unmount any lingering form. + // Guarded so unrelated transitionend events (e.g. children's own + // transitions bubbling up) don't drop the form mid-animation. + const onCurtainTransitionEnd = useCallback( + (evt: TransitionEvent) => { + if (evt.target !== evt.currentTarget) return; + if (evt.propertyName !== 'top') return; + curtain.acknowledgeClosed(); + }, + [curtain] + ); + + // On-screen keyboard detection via VisualViewport API. Global + // viewport-meta is `interactive-widget=resizes-content` (load- + // bearing for the room composer's keyboard-follow behaviour), which + // shrinks the layout viewport when a soft keyboard appears. Any + // `bottom: 0` child — including DirectSelfRow inside the curtain — + // rises with the shrunken viewport and ends up sitting RIGHT ABOVE + // the keyboard, blocking the inline form the user is typing into. + // + // Fix: when the keyboard is up, collapse the `bottomPinned` slot + // to zero height so it neither claims flex space at the curtain + // bottom nor renders above the keyboard. The user perceives the + // keyboard as overlaying everything below the form (matching their + // mental model: "клавиатура рисуется поверх кнопок и чатов, кнопка + // настройки остаётся прибитой снизу"). The row reappears the moment + // the keyboard retracts. + // + // The reference-height tracking mirrors `AuthLayout.tsx`: bump the + // reference upward on every grow (so rotation / keyboard-close + // events stay self-correcting) and treat a meaningful shrink (>= + // KEYBOARD_PROBE_PX) as «keyboard is up». The probe avoids + // spurious flips on small browser-chrome animations. + const [keyboardOpen, setKeyboardOpen] = useState(false); + useEffect(() => { + const vv = window.visualViewport; + if (!vv) return undefined; + const KEYBOARD_PROBE_PX = 100; + let referenceH = vv.height; + let rafId: number | null = null; + const apply = () => { + rafId = null; + if (vv.height > referenceH) referenceH = vv.height; + setKeyboardOpen(referenceH - vv.height >= KEYBOARD_PROBE_PX); + }; + const onResize = () => { + if (rafId === null) rafId = requestAnimationFrame(apply); + }; + apply(); + vv.addEventListener('resize', onResize); + return () => { + if (rafId !== null) cancelAnimationFrame(rafId); + vv.removeEventListener('resize', onResize); + }; + }, []); + + return ( +
+
+ {/* ── Tabs row + action icons (always visible) ─────────── */} +
+
+ navigate(DIRECT_PATH, navOpts)} + /> + navigate(CHANNELS_PATH, navOpts)} + /> + {showBotsSegment && ( + navigate(BOTS_PATH, navOpts)} + /> + )} +
+ + {isActive ? ( + + + + ) : ( +
+ + + + + + +
+ )} +
+ + {/* ── Chips vs form ────────────────────────────────────── + Mutually exclusive. While a form is mounted (including the + curtain's close-snap window before `acknowledgeClosed`), the + chips stay unrendered so the form doesn't visually jump + from y = TABS_ROW_PX to y = TABS_ROW_PX + 2·CHIP_ROW_PX + mid-animation. + + When chips are rendered: always present in their fixed + header positions; the curtain occludes them by z-stacking. + As the user drags the curtain down, the chips reveal from + underneath naturally. `Chip.hidden` only controls keyboard + focus (the chip paints normally; the curtain's z-index does + the visual hiding). */} + {curtain.activeForm ? ( +
+
+ {curtain.activeForm === 'search' && } + {curtain.activeForm === 'chat' && } +
+
+ ) : ( + <> +
+
+
+
+ + )} +
+ + {/* ── Curtain layer ───────────────────────────────────── + Renders ABOVE the header (z-index higher). `top` combines the + snap-derived resting position with the live finger drag — one + React-controlled inline style, no ref-based DOM writes. The + transition is disabled during the drag and restored on commit + so the snap commit animates smoothly without an intermediate + "snap back then animate forward" flash. */} +
+ {children} + {/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is + kept mounted across snaps so the curtain reads as a self- + contained "screen" with its bottom row always pinned to + the stage bottom. While the on-screen keyboard is up the + slot collapses to `height: 0` so it neither paints nor + claims flex space above the keyboard (see the + `keyboardOpen` effect above for the rationale). */} + {bottomPinned && ( +
+ {bottomPinned} +
+ )} +
+
+ ); +} + +export { TABS_ROW_PX, CHIP_ROW_PX }; diff --git a/src/app/components/stream-header/forms/InlineNewChatForm.tsx b/src/app/components/stream-header/forms/InlineNewChatForm.tsx new file mode 100644 index 00000000..80110f6c --- /dev/null +++ b/src/app/components/stream-header/forms/InlineNewChatForm.tsx @@ -0,0 +1,18 @@ +import React from 'react'; +import { CreateChat } from '../../../features/create-chat'; + +type Props = { + // Called after the form successfully creates or navigates to an + // existing DM. The StreamHeader uses this to close the curtain over + // the form. + onClose: () => void; +}; + +// Thin shell around the shared `CreateChat` form. The legacy +// `/direct/_create` route renders the same component with a Page/ +// PageHero shell; here we only feed it the `onClose` callback and the +// tighter `gap='400'` rhythm so the form fits comfortably under the +// header. +export function InlineNewChatForm({ onClose }: Props) { + return ; +} diff --git a/src/app/components/stream-header/forms/InlineRoomSearch.tsx b/src/app/components/stream-header/forms/InlineRoomSearch.tsx new file mode 100644 index 00000000..40780fda --- /dev/null +++ b/src/app/components/stream-header/forms/InlineRoomSearch.tsx @@ -0,0 +1,244 @@ +import React, { useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Avatar, Box, Icon, Icons, Scroll, Text, color, toRem } from 'folds'; +import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; +import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { RoomAvatar, RoomIcon } from '../../../components/room-avatar'; +import { UnreadBadge, UnreadBadgeCenter } from '../../../components/unread-badge'; +import { getAllParents, getDirectRoomAvatarUrl, getRoomAvatarUrl, guessPerfectParent } from '../../../utils/room'; +import { nameInitials } from '../../../utils/common'; +import { getMxIdLocalPart, getMxIdServer } from '../../../utils/matrix'; +import { highlightText } from '../../../plugins/react-custom-html-parser'; +import { getDmUserId, useRoomSearch } from '../../../features/search/useRoomSearch'; +import { SEARCH_FORM_BASE_PX } from '../geometry'; + +type Props = { + onClose: () => void; +}; + +// Inline search panel mounted in the StreamHeader. Shares all search +// logic with the global Search modal via `useRoomSearch`; only the +// presentation chrome differs (no Modal/Overlay/FocusTrap, a custom +// row layout that matches the inline aesthetic). +export function InlineRoomSearch({ onClose }: Props) { + const { t } = useTranslation(); + const mx = useMatrixClient(); + const useAuthentication = useMediaAuthentication(); + const { navigateRoom, navigateSpace } = useRoomNavigate(); + + const openRoomId = useCallback( + (roomId: string, isSpace: boolean) => { + if (isSpace) navigateSpace(roomId); + else navigateRoom(roomId); + onClose(); + }, + [navigateRoom, navigateSpace, onClose] + ); + + const { + inputRef, + scrollRef, + roomsToRender, + result, + listFocus, + queryHighlightRegex, + handleInputChange, + handleInputKeyDown, + handleRoomClick, + getRoom, + mDirects, + orphanSpaces, + roomToParents, + roomToUnread, + myUserId, + } = useRoomSearch({ onOpenRoomId: openRoomId }); + + // Focus the input on mount. Inline form opens via an explicit user + // action (chip tap or icon click), so this is request-initiated + // focus rather than ambient `autoFocus` — keeps screen readers + // happy. + useEffect(() => { + inputRef.current?.focus(); + }, [inputRef]); + + return ( + + {/* ── Input bar (matches chip geometry: h=48 / r=20 / pad 8/14) + so the chip → input morph reads as a content crossfade. */} + + + + + + {/* ── Result list ────────────────────────────────────── */} + + + {roomsToRender.length === 0 && ( + + + {result ? t('Search.no_match_found') : t('Search.no_rooms')} + + + {result + ? t('Search.no_match_for_query', { query: result.query }) + : t('Search.no_rooms_to_display')} + + + )} + {roomsToRender.length > 0 && ( + + {roomsToRender.map((roomId, index) => { + const room = getRoom(roomId); + if (!room) return null; + + const dm = mDirects.has(roomId); + const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined; + const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined; + const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined; + + const allParents = getAllParents(roomToParents, roomId); + const orphanParents = allParents + ? orphanSpaces.filter((o) => allParents.has(o)) + : undefined; + const perfectOrphanParent = + orphanParents && guessPerfectParent(mx, roomId, orphanParents); + + const exactParents = roomToParents.get(roomId); + const perfectParent = + exactParents && guessPerfectParent(mx, roomId, Array.from(exactParents)); + + const unread = roomToUnread.get(roomId); + const focused = listFocus.index === index; + + return ( + + ); + })} + + )} + + + + ); +} diff --git a/src/app/components/stream-header/geometry.ts b/src/app/components/stream-header/geometry.ts new file mode 100644 index 00000000..b6f054a0 --- /dev/null +++ b/src/app/components/stream-header/geometry.ts @@ -0,0 +1,62 @@ +// ──────────────────────────────────────────────────────────────────── +// StreamHeader geometry — shared constants for the curtain layout. +// +// Mental model: the chats card is a curtain layered ABOVE the header +// (z-index higher). The curtain's `top` is the visible part of the +// header below the always-pinned tabs row. When the curtain is fully +// closed it sits flush under the tabs row (covering chips + form area +// beneath). Dragging it DOWN reveals more of the header from underneath. +// Dragging UP raises the curtain back over the header. +// +// Snap stops (curtain.top, px): +// closed = TABS_ROW_PX +// peek1 = TABS_ROW_PX + CHIP_ROW_PX +// peek2 = TABS_ROW_PX + CHIP_ROW_PX * 2 +// form:* = TABS_ROW_PX + formHeight + CURTAIN_BREATHER_PX +// ──────────────────────────────────────────────────────────────────── + +// Tabs row height. Always visible above the curtain. +export const TABS_ROW_PX = 64; + +// Each peek-chip row. Reveals one chip's pill (h=48) + 8px top breather. +export const CHIP_ROW_PX = 56; + +// Vertical gap BETWEEN two consecutive chip rows. Separate from +// `CURTAIN_BREATHER_PX` so the inter-chip spacing can read tighter +// than the breather between the last chip and the curtain's rounded +// top (the curtain's straight edge against a chip pill needs more +// air to avoid feeling «clamped», while two pills sitting in a +// vertical stack want to read as a pair). +export const CHIP_GAP_PX = 14; + +// Initial estimate for the search form's outer height. The actual +// height is measured at runtime via ResizeObserver and adapts to the +// available viewport so the form never overflows the chats card. +export const SEARCH_FORM_BASE_PX = 360; + +// Breathing strip between the bottom of any header content (revealed +// chip pill, form's last actionable element) and the top of the +// curtain. Painted by the header's `SurfaceVariant.Container` (light- +// blue) so the chip / Create button / search results never visually +// touch the curtain's rounded top — the user reads chips that sit +// flush with the curtain as «зажатые» rather than two separate +// affordances. Not applied at `closed` (nothing to breathe to). +export const CURTAIN_BREATHER_PX = 20; + +// Curtain snap transition. Tuned tight for an in-app reveal — +// emphasized-decelerate territory. +export const CURTAIN_SNAP_MS = 280; +export const CURTAIN_SNAP_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)'; + +// Curtain card top-corner radius. Matches the composer card and the +// horseshoe surfaces elsewhere in the app. +export const CURTAIN_RADIUS_PX = 24; + +// Touch gesture tuning. RUBBER_BAND dampens finger→curtain motion so +// the chip reveal feels resistive; COMMIT_THRESHOLD is the fraction of +// a stage the user must cross on release to advance. +export const RUBBER_BAND = 0.65; +export const DIRECTION_DEAD_ZONE_PX = 10; +export const COMMIT_THRESHOLD = 0.65; +// Pull-up distance (raw finger px) required to close an active form. +export const ACTIVE_CLOSE_THRESHOLD_PX = 100; diff --git a/src/app/components/stream-header/index.ts b/src/app/components/stream-header/index.ts new file mode 100644 index 00000000..42aeed15 --- /dev/null +++ b/src/app/components/stream-header/index.ts @@ -0,0 +1,2 @@ +export { StreamHeader } from './StreamHeader'; +export { TABS_ROW_PX, CHIP_ROW_PX, CURTAIN_SNAP_MS, CURTAIN_SNAP_EASING } from './geometry'; diff --git a/src/app/components/stream-header/useCurtainGesture.ts b/src/app/components/stream-header/useCurtainGesture.ts new file mode 100644 index 00000000..fb899e1f --- /dev/null +++ b/src/app/components/stream-header/useCurtainGesture.ts @@ -0,0 +1,200 @@ +import { MutableRefObject, useEffect, useRef } from 'react'; +import { isNativePlatform } from '../../utils/capacitor'; +import { + ACTIVE_CLOSE_THRESHOLD_PX, + CHIP_ROW_PX, + COMMIT_THRESHOLD, + DIRECTION_DEAD_ZONE_PX, + RUBBER_BAND, +} from './geometry'; +import { CurtainSnap, isFormSnap, isPeekSnap } from './useCurtainState'; + +type Args = { + // The scroll viewport that hosts the chat list inside the curtain. + // Touch events fire here; gestures engage when the list is at + // `scrollTop === 0` (peek path) or any scrollTop (form-close path). + scrollRef: MutableRefObject; + // Current snap stop. Read at touchstart to decide gesture meaning. + // Mirrored into a ref so the listener (bound once) reads fresh values. + snap: CurtainSnap; + // Setter for the live drag delta during touchmove. The hook reads + // `liveDragPx` from the parent state too, so React drives the + // curtain's `top` re-render — no direct DOM writes, no inline-vs- + // React transition coordination. + setLiveDrag: (px: number, dragging: boolean) => void; + // Commit a new snap stop on release. + commit: (next: CurtainSnap) => void; +}; + +// Touch-gesture driver for the curtain. Native-only: on web/PC the +// listeners aren't attached at all. +// +// Peek path: drag down from `closed`/`peek1`/`peek2` rubber-bands the +// live delta and on release commits to the nearest stage. Drag UP from +// peek retreats toward `closed` via the same threshold logic. +// +// Form-close path: drag UP from a form snap tracks the finger 1:1; on +// release past `ACTIVE_CLOSE_THRESHOLD_PX` commits to `closed`. +export function useCurtainGesture({ scrollRef, snap, setLiveDrag, commit }: Args): void { + // Mirror snap into a ref so the listener — bound once via useEffect — + // always reads the freshest value without re-attaching. + const snapRef = useRef(snap); + snapRef.current = snap; + + useEffect(() => { + if (!isNativePlatform()) return undefined; + const list = scrollRef.current; + if (!list) return undefined; + + let startY: number | null = null; + let direction: 'up' | 'down' | null = null; + let engaged = false; + let lastDelta = 0; + + const onTouchStart = (e: TouchEvent) => { + if (e.touches.length !== 1) return; + startY = e.touches[0].clientY; + direction = null; + engaged = false; + lastDelta = 0; + // Peek-reveal requires scrollTop === 0 (no content above to + // scroll). Form-close engages regardless of scrollTop (the form + // is open, the list scroll is the close target). + if (!isFormSnap(snapRef.current) && list.scrollTop !== 0) { + startY = null; + } + }; + + const onTouchMove = (e: TouchEvent) => { + if (e.touches.length !== 1) { + // Second finger landed mid-gesture — abort. + startY = null; + direction = null; + if (engaged) setLiveDrag(0, false); + engaged = false; + lastDelta = 0; + return; + } + if (startY === null) { + // Active mode may re-arm startY here if onTouchStart bailed. + if (isFormSnap(snapRef.current)) { + startY = e.touches[0].clientY; + } else { + return; + } + } + + const delta = e.touches[0].clientY - startY; + const currentSnap = snapRef.current; + + // Resolve a direction once the finger crosses the dead-zone. + if (direction === null) { + if (Math.abs(delta) < DIRECTION_DEAD_ZONE_PX) return; + direction = delta > 0 ? 'down' : 'up'; + + // Direction guards: nothing higher than closed; nothing lower + // than peek2; form snaps only close (up). + if (currentSnap === 'closed' && direction === 'up') { + startY = null; + direction = null; + return; + } + if (currentSnap === 'peek2' && direction === 'down') { + startY = null; + direction = null; + return; + } + if (isFormSnap(currentSnap) && direction === 'down') { + startY = null; + direction = null; + return; + } + } + + engaged = true; + e.preventDefault(); + + if (isFormSnap(currentSnap)) { + // Form close: finger moves UP (delta < 0). Track 1:1, capped + // at 0 so an accidental downward jitter doesn't push the + // curtain below its resting position. + lastDelta = Math.min(0, delta); + } else { + // Peek: rubber-banded BOTH directions. Down (delta > 0) reveals + // more chips; up (delta < 0) retreats toward `closed`. Bounds + // are enforced by the direction guards above plus the snap + // clamp on touchend, so we don't clamp here. + lastDelta = delta * RUBBER_BAND; + } + setLiveDrag(lastDelta, true); + }; + + const onTouchEnd = () => { + if (!engaged) { + startY = null; + direction = null; + return; + } + + const currentSnap = snapRef.current; + let next: CurtainSnap = currentSnap; + + if (isFormSnap(currentSnap)) { + if (Math.abs(lastDelta) >= ACTIVE_CLOSE_THRESHOLD_PX) { + next = 'closed'; + } + } else if (isPeekSnap(currentSnap) || currentSnap === 'closed') { + const progressStages = lastDelta / CHIP_ROW_PX; + let baseStage = 0; + if (currentSnap === 'peek1') baseStage = 1; + else if (currentSnap === 'peek2') baseStage = 2; + if (Math.abs(progressStages) >= COMMIT_THRESHOLD) { + const target = Math.max(0, Math.min(2, Math.round(baseStage + progressStages))); + let resolved: CurtainSnap = 'closed'; + if (target === 1) resolved = 'peek1'; + else if (target === 2) resolved = 'peek2'; + next = resolved; + } + } + + if (next !== currentSnap) { + // commit() also resets liveDragPx + isDragging to 0/false in + // one batched update — React renders the curtain at the new + // resting top with the snap transition re-enabled. + commit(next); + } else { + // No commit: drop the live drag back to 0 with transition + // active so the curtain springs back to its resting position. + setLiveDrag(0, false); + } + + startY = null; + direction = null; + engaged = false; + lastDelta = 0; + }; + + const onTouchCancel = () => { + // System cancel never commits — always snap back to current snap. + if (engaged) setLiveDrag(0, false); + startY = null; + direction = null; + engaged = false; + lastDelta = 0; + }; + + list.addEventListener('touchstart', onTouchStart, { passive: true }); + list.addEventListener('touchmove', onTouchMove, { passive: false }); + list.addEventListener('touchend', onTouchEnd, { passive: true }); + list.addEventListener('touchcancel', onTouchCancel, { passive: true }); + return () => { + list.removeEventListener('touchstart', onTouchStart); + list.removeEventListener('touchmove', onTouchMove); + list.removeEventListener('touchend', onTouchEnd); + list.removeEventListener('touchcancel', onTouchCancel); + }; + // setLiveDrag/commit are stable useCallbacks; scrollRef is stable. + // `snap` is mirrored via snapRef written above on every render. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [scrollRef, setLiveDrag, commit]); +} diff --git a/src/app/components/stream-header/useCurtainState.ts b/src/app/components/stream-header/useCurtainState.ts new file mode 100644 index 00000000..e8c37c46 --- /dev/null +++ b/src/app/components/stream-header/useCurtainState.ts @@ -0,0 +1,211 @@ +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; +import { + CHIP_GAP_PX, + CHIP_ROW_PX, + CURTAIN_BREATHER_PX, + CURTAIN_SNAP_MS, + SEARCH_FORM_BASE_PX, + TABS_ROW_PX, +} from './geometry'; + +// Discrete snap stops for the curtain. The curtain's resting `top` +// is derived from this value plus the live finger drag delta. There +// is no separate «active vs peek» mode flag — the snap encodes both. +export type CurtainSnap = + | 'closed' // curtain flush under tabs row; nothing peeking + | 'peek1' // search chip revealed + | 'peek2' // search + new-chat chips revealed + | 'form-search' // full search form revealed + | 'form-chat'; // full new-chat form revealed + +export const isFormSnap = ( + snap: CurtainSnap +): snap is 'form-search' | 'form-chat' => + snap === 'form-search' || snap === 'form-chat'; + +export const isPeekSnap = (snap: CurtainSnap): snap is 'peek1' | 'peek2' => + snap === 'peek1' || snap === 'peek2'; + +// Form kind currently rendered in the header. Stays set during the +// curtain's close transition so the form has content to slide behind; +// cleared by `acknowledgeClosed` after the snap settles at `closed`. +export type ActiveForm = 'search' | 'chat' | null; + +export type CurtainState = { + snap: CurtainSnap; + activeForm: ActiveForm; + // Live finger delta in px. Added to the snap-derived resting top to + // compute the curtain's visible top. Stays at 0 when no gesture is + // in flight. Positive = finger pulled down (peek reveal); negative = + // finger pulled up (close gesture). + liveDragPx: number; + // True while a touch gesture is in flight. Controls the curtain's + // `top` transition: disabled while dragging (curtain tracks finger + // 1:1), restored on release so the snap commit animates smoothly. + isDragging: boolean; + // Live measured height of the active form's outer; used to compute + // the curtain's resting `top` when `snap === 'form-*'`. `null` while + // no form is mounted. + formHeightPx: number | null; + // Ref pointing at the rendered form's outer — a ResizeObserver + // watches this to feed `formHeightPx`. Consumer attaches it to the + // form's wrapping element. + formMeasureRef: React.RefObject; + // Open a form. Sets `snap` and `activeForm` synchronously. + open: (form: 'search' | 'chat') => void; + // Close the curtain (raise it back to `closed`). Keeps `activeForm` + // set until the snap transition lands so the form stays mounted + // during the slide-up. + close: () => void; + // Commit a snap stop directly. Used by the touch gesture on release. + // Also resets `liveDragPx` and `isDragging` in one batched update. + commit: (next: CurtainSnap) => void; + // Setter for the live drag delta — called from the touch gesture on + // every touchmove. Updates are batched by React inside event handlers. + setLiveDrag: (px: number, dragging: boolean) => void; + // Notify the hook that the curtain reached `closed` so any lingering + // form can be unmounted (called from the curtain element's + // `onTransitionEnd`). + acknowledgeClosed: () => void; +}; + +// Resting `top` (px) of the curtain for a given snap stop and the +// currently measured form height (null falls back to the base). +// Snap-top math mirrors the DOM layout of the chip stack so the +// curtain's resting edge always lands on the boundary of the next +// row (no «next chip pill peeking through» bug). Chip rows are 56px +// each; between them is `CHIP_GAP_PX` (chip-to-chip, tighter); after +// the last revealed chip is `CURTAIN_BREATHER_PX` (chip-to-curtain, +// wider). Forms use just the breather. +export function snapTopPx(snap: CurtainSnap, formH: number | null): number { + switch (snap) { + case 'closed': + return TABS_ROW_PX; + case 'peek1': + return TABS_ROW_PX + CHIP_ROW_PX + CURTAIN_BREATHER_PX; + case 'peek2': + return ( + TABS_ROW_PX + + CHIP_ROW_PX + + CHIP_GAP_PX + + CHIP_ROW_PX + + CURTAIN_BREATHER_PX + ); + case 'form-search': + case 'form-chat': + return TABS_ROW_PX + (formH ?? SEARCH_FORM_BASE_PX) + CURTAIN_BREATHER_PX; + default: + return TABS_ROW_PX; + } +} + +export function useCurtainState(): CurtainState { + const [snap, setSnap] = useState('closed'); + const [activeForm, setActiveForm] = useState(null); + const [formHeightPx, setFormHeightPx] = useState(null); + const [liveDragPx, setLiveDragPx] = useState(0); + const [isDragging, setIsDragging] = useState(false); + + const formMeasureRef = useRef(null); + + const open = useCallback((form: 'search' | 'chat') => { + setActiveForm(form); + setSnap(form === 'search' ? 'form-search' : 'form-chat'); + setLiveDragPx(0); + setIsDragging(false); + }, []); + + const close = useCallback(() => { + setSnap('closed'); + setLiveDragPx(0); + setIsDragging(false); + }, []); + + const commit = useCallback((next: CurtainSnap) => { + setSnap(next); + setLiveDragPx(0); + setIsDragging(false); + if (isFormSnap(next)) { + setActiveForm(next === 'form-search' ? 'search' : 'chat'); + } + // Note: when committing to a non-form snap (peek*/closed) we do + // NOT clear `activeForm` here — it stays set so the closing + // transition has form content beneath. `acknowledgeClosed` clears + // it once the curtain settles at `closed`. + }, []); + + const setLiveDrag = useCallback((px: number, dragging: boolean) => { + setLiveDragPx(px); + setIsDragging(dragging); + }, []); + + const acknowledgeClosed = useCallback(() => { + if (snap === 'closed') setActiveForm(null); + }, [snap]); + + // Measure the form's outer height while it's mounted. We do NOT + // overwrite the measured height to null on unmount — keeping the + // last-known height stable lets the close transition target the + // same `top` value as the open transition (no jump at SNAP_MS-end). + useLayoutEffect(() => { + if (!activeForm) return undefined; + const el = formMeasureRef.current; + if (!el) return undefined; + const measure = () => setFormHeightPx(el.offsetHeight); + measure(); + const ro = new ResizeObserver(measure); + ro.observe(el); + return () => ro.disconnect(); + }, [activeForm]); + + // Safety-net for missed `transitionend` (route unmount mid-anim, + // browser quirks). Once snap settles at `closed`, force-drop the + // form after a generous window past the snap duration. + const timerRef = useRef(null); + useEffect(() => { + if (snap !== 'closed') { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + return undefined; + } + timerRef.current = window.setTimeout(() => { + setActiveForm(null); + }, CURTAIN_SNAP_MS + 200); + return () => { + if (timerRef.current !== null) { + window.clearTimeout(timerRef.current); + timerRef.current = null; + } + }; + }, [snap]); + + return useMemo( + () => ({ + snap, + activeForm, + liveDragPx, + isDragging, + formHeightPx, + formMeasureRef, + open, + close, + commit, + setLiveDrag, + acknowledgeClosed, + }), + [ + snap, + activeForm, + liveDragPx, + isDragging, + formHeightPx, + open, + close, + commit, + setLiveDrag, + acknowledgeClosed, + ] + ); +} diff --git a/src/app/features/create-chat/CreateChat.tsx b/src/app/features/create-chat/CreateChat.tsx index 968eb4f7..dcfe3d1a 100644 --- a/src/app/features/create-chat/CreateChat.tsx +++ b/src/app/features/create-chat/CreateChat.tsx @@ -37,8 +37,17 @@ const FALLBACK_SERVER = 'vojo.chat'; type CreateChatProps = { defaultUserId?: string; + // Called after a successful create or after navigating to an + // already-existing DM. Inline header mount uses this to retract the + // curtain over the form; the legacy `/direct/_create` route leaves it + // unset. + onCreated?: (roomId: string) => void; + // `gap` between form blocks. Default `'500'` matches the legacy + // standalone page. The inline header passes `'400'` for a tighter + // visual. + gap?: '400' | '500'; }; -export function CreateChat({ defaultUserId }: CreateChatProps) { +export function CreateChat({ defaultUserId, onCreated, gap = '500' }: CreateChatProps) { const { t } = useTranslation(); const mx = useMatrixClient(); const alive = useAlive(); @@ -112,6 +121,7 @@ export function CreateChat({ defaultUserId }: CreateChatProps) { if (existing) { usernameInput.value = ''; navigate(getDirectRoomPath(existing.roomId)); + onCreated?.(existing.roomId); return; } @@ -119,12 +129,13 @@ export function CreateChat({ defaultUserId }: CreateChatProps) { if (alive()) { usernameInput.value = ''; navigate(getDirectRoomPath(roomId)); + onCreated?.(roomId); } }); }; return ( - + @@ -194,13 +205,15 @@ export function CreateChat({ defaultUserId }: CreateChatProps) { - {error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED - ? t('Direct.rate_limited', { - minutes: millisecondsToMinutes( - (error.data.retry_after_ms as number | undefined) ?? 0 - ), - }) - : error.message} + {(() => { + if (error instanceof MatrixError && error.name === ErrorCode.M_LIMIT_EXCEEDED) { + const ra = error.data?.retry_after_ms; + return t('Direct.rate_limited', { + minutes: millisecondsToMinutes(typeof ra === 'number' ? ra : 0), + }); + } + return error.message; + })()} diff --git a/src/app/features/search/Search.tsx b/src/app/features/search/Search.tsx index 34a594cf..f7d575d2 100644 --- a/src/app/features/search/Search.tsx +++ b/src/app/features/search/Search.tsx @@ -15,30 +15,11 @@ import { Text, toRem, } from 'folds'; -import React, { - ChangeEventHandler, - KeyboardEventHandler, - MouseEventHandler, - useCallback, - useEffect, - useMemo, - useRef, - useState, -} from 'react'; +import React, { useCallback } from 'react'; import { Trans, useTranslation } from 'react-i18next'; import { isKeyHotkey } from 'is-hotkey'; -import { useAtom, useAtomValue } from 'jotai'; -import { Room } from 'matrix-js-sdk'; -import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList'; +import { useAtom } from 'jotai'; import { useMatrixClient } from '../../hooks/useMatrixClient'; -import { mDirectAtom } from '../../state/mDirectList'; -import { allRoomsAtom } from '../../state/room-list/roomList'; -import { - SearchItemStrGetter, - useAsyncSearch, - UseAsyncSearchOptions, -} from '../../hooks/useAsyncSearch'; -import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { getAllParents, @@ -46,91 +27,17 @@ import { getRoomAvatarUrl, guessPerfectParent, } from '../../utils/room'; -import { highlightText, makeHighlightRegex } from '../../plugins/react-custom-html-parser'; -import { factoryRoomIdByActivity } from '../../utils/sort'; +import { highlightText } from '../../plugins/react-custom-html-parser'; import { nameInitials } from '../../utils/common'; import { useRoomNavigate } from '../../hooks/useRoomNavigate'; -import { useListFocusIndex } from '../../hooks/useListFocusIndex'; -import { getMxIdLocalPart, getMxIdServer, guessDmRoomUserId } from '../../utils/matrix'; -import { roomToParentsAtom } from '../../state/room/roomToParents'; -import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { getMxIdLocalPart, getMxIdServer } from '../../utils/matrix'; import { UnreadBadge, UnreadBadgeCenter } from '../../components/unread-badge'; import { searchModalAtom } from '../../state/searchModal'; import { useKeyDown } from '../../hooks/useKeyDown'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { KeySymbol } from '../../utils/key-symbol'; import { isMacOS } from '../../utils/user-agent'; - -enum SearchRoomType { - Rooms = '#', - Spaces = '*', - Directs = '@', -} - -const getSearchPrefixToRoomType = (prefix: string): SearchRoomType | undefined => { - if (prefix === '#') return SearchRoomType.Rooms; - if (prefix === '*') return SearchRoomType.Spaces; - if (prefix === '@') return SearchRoomType.Directs; - return undefined; -}; - -const useTopActiveRooms = ( - searchRoomType: SearchRoomType | undefined, - rooms: string[], - directs: string[], - spaces: string[] -) => { - const mx = useMatrixClient(); - - return useMemo(() => { - if (searchRoomType === SearchRoomType.Spaces) { - return spaces; - } - if (searchRoomType === SearchRoomType.Directs) { - return [...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20); - } - if (searchRoomType === SearchRoomType.Rooms) { - return [...rooms].sort(factoryRoomIdByActivity(mx)).slice(0, 20); - } - return [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20); - }, [mx, rooms, directs, spaces, searchRoomType]); -}; - -const getDmUserId = ( - roomId: string, - getRoom: (roomId: string) => Room | undefined, - myUserId: string -): string | undefined => { - const room = getRoom(roomId); - const targetUserId = room && guessDmRoomUserId(room, myUserId); - return targetUserId; -}; - -const useSearchTargetRooms = ( - searchRoomType: SearchRoomType | undefined, - rooms: string[], - directs: string[], - spaces: string[] -) => - useMemo(() => { - if (searchRoomType === undefined) { - return [...rooms, ...directs, ...spaces]; - } - if (searchRoomType === SearchRoomType.Rooms) return rooms; - if (searchRoomType === SearchRoomType.Spaces) return spaces; - if (searchRoomType === SearchRoomType.Directs) return directs; - - return []; - }, [rooms, spaces, directs, searchRoomType]); - -const SEARCH_OPTIONS: UseAsyncSearchOptions = { - matchOptions: { - contain: true, - }, - normalizeOptions: { - ignoreWhitespace: false, - }, -}; +import { getDmUserId, useRoomSearch } from './useRoomSearch'; type SearchProps = { requestClose: () => void; @@ -139,109 +46,34 @@ export function Search({ requestClose }: SearchProps) { const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); - const scrollRef = useRef(null); - const inputRef = useRef(null); const { navigateRoom, navigateSpace } = useRoomNavigate(); - const roomToUnread = useAtomValue(roomToUnreadAtom); - const [searchRoomType, setSearchRoomType] = useState(); - - const allRoomsSet = useAllJoinedRoomsSet(); - const getRoom = useGetRoom(allRoomsSet); - - const roomToParents = useAtomValue(roomToParentsAtom); - const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); - const mDirects = useAtomValue(mDirectAtom); - const rooms = useRooms(mx, allRoomsAtom, mDirects); - const spaces = useSpaces(mx, allRoomsAtom); - const directs = useDirects(mx, allRoomsAtom, mDirects); - - const topActiveRooms = useTopActiveRooms(searchRoomType, rooms, directs, spaces); - const targetRooms = useSearchTargetRooms(searchRoomType, rooms, directs, spaces); - - const getTargetStr: SearchItemStrGetter = useCallback( - (roomId: string) => { - const roomName = getRoom(roomId)?.name ?? roomId; - if (mDirects.has(roomId)) { - const targetUserId = getDmUserId(roomId, getRoom, mx.getSafeUserId()); - const targetUsername = targetUserId && getMxIdLocalPart(targetUserId); - if (targetUsername) return [roomName, targetUsername]; - } - return roomName; + const openRoomId = useCallback( + (roomId: string, isSpace: boolean) => { + if (isSpace) navigateSpace(roomId); + else navigateRoom(roomId); + requestClose(); }, - [getRoom, mDirects, mx] + [navigateRoom, navigateSpace, requestClose] ); - const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS); - const roomsToRender = result ? result.items : topActiveRooms; - const listFocus = useListFocusIndex(roomsToRender.length, 0); - - const queryHighlighRegex = result?.query - ? makeHighlightRegex(result.query.split(' ')) - : undefined; - - const openRoomId = (roomId: string, isSpace: boolean) => { - if (isSpace) navigateSpace(roomId); - else navigateRoom(roomId); - requestClose(); - }; - - const handleInputChange: ChangeEventHandler = (evt) => { - listFocus.reset(); - - const target = evt.currentTarget; - let value = target.value.trim(); - const prefix = value.match(/^[#@*]/)?.[0]; - const searchType = typeof prefix === 'string' && getSearchPrefixToRoomType(prefix); - if (searchType) { - value = value.slice(1); - setSearchRoomType(searchType); - } else { - setSearchRoomType(undefined); - } - - if (value === '') { - resetSearch(); - return; - } - search(value); - }; - - const handleInputKeyDown: KeyboardEventHandler = (evt) => { - const roomId = roomsToRender[listFocus.index]; - if (isKeyHotkey('enter', evt) && roomId) { - openRoomId(roomId, spaces.includes(roomId)); - return; - } - if (isKeyHotkey('arrowdown', evt)) { - evt.preventDefault(); - listFocus.next(); - return; - } - if (isKeyHotkey('arrowup', evt)) { - evt.preventDefault(); - listFocus.previous(); - } - }; - - const handleRoomClick: MouseEventHandler = (evt) => { - const target = evt.currentTarget; - const roomId = target.getAttribute('data-room-id'); - const isSpace = target.getAttribute('data-space') === 'true'; - if (!roomId) return; - openRoomId(roomId, isSpace); - }; - - useEffect(() => { - const scrollView = scrollRef.current; - const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`); - - if (focusedItem && scrollView) { - focusedItem.scrollIntoView({ - block: 'center', - }); - } - }, [listFocus.index]); + const { + inputRef, + scrollRef, + roomsToRender, + result, + listFocus, + queryHighlightRegex, + handleInputChange, + handleInputKeyDown, + handleRoomClick, + getRoom, + mDirects, + orphanSpaces, + roomToParents, + roomToUnread, + myUserId, + } = useRoomSearch({ onOpenRoomId: openRoomId }); return ( @@ -305,13 +137,14 @@ export function Search({ requestClose }: SearchProps) { if (!room) return null; const dm = mDirects.has(roomId); - const dmUserId = dm && getDmUserId(roomId, getRoom, mx.getSafeUserId()); - const dmUsername = dmUserId && getMxIdLocalPart(dmUserId); - const dmUserServer = dmUserId && getMxIdServer(dmUserId); + const dmUserId = dm ? getDmUserId(roomId, getRoom, myUserId) : undefined; + const dmUsername = dmUserId ? getMxIdLocalPart(dmUserId) : undefined; + const dmUserServer = dmUserId ? getMxIdServer(dmUserId) : undefined; const allParents = getAllParents(roomToParents, roomId); - const orphanParents = - allParents && orphanSpaces.filter((o) => allParents.has(o)); + const orphanParents = allParents + ? orphanSpaces.filter((o) => allParents.has(o)) + : undefined; const perfectOrphanParent = orphanParents && guessPerfectParent(mx, roomId, orphanParents); @@ -383,15 +216,15 @@ export function Search({ requestClose }: SearchProps) { > - {queryHighlighRegex - ? highlightText(queryHighlighRegex, [room.name]) + {queryHighlightRegex + ? highlightText(queryHighlightRegex, [room.name]) : room.name} {dmUsername && ( @ - {queryHighlighRegex - ? highlightText(queryHighlighRegex, [dmUsername]) + {queryHighlightRegex + ? highlightText(queryHighlightRegex, [dmUsername]) : dmUsername} )} diff --git a/src/app/features/search/index.ts b/src/app/features/search/index.ts index addd5330..8ca005cc 100644 --- a/src/app/features/search/index.ts +++ b/src/app/features/search/index.ts @@ -1 +1,2 @@ export * from './Search'; +export * from './useRoomSearch'; diff --git a/src/app/features/search/useRoomSearch.ts b/src/app/features/search/useRoomSearch.ts new file mode 100644 index 00000000..fd5284a6 --- /dev/null +++ b/src/app/features/search/useRoomSearch.ts @@ -0,0 +1,205 @@ +import { + ChangeEventHandler, + KeyboardEventHandler, + MouseEventHandler, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { isKeyHotkey } from 'is-hotkey'; +import { Room } from 'matrix-js-sdk'; +import { useAtomValue } from 'jotai'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { useDirects, useOrphanSpaces, useRooms, useSpaces } from '../../state/hooks/roomList'; +import { useAllJoinedRoomsSet, useGetRoom } from '../../hooks/useGetRoom'; +import { useListFocusIndex } from '../../hooks/useListFocusIndex'; +import { + SearchItemStrGetter, + useAsyncSearch, + UseAsyncSearchOptions, +} from '../../hooks/useAsyncSearch'; +import { mDirectAtom } from '../../state/mDirectList'; +import { allRoomsAtom } from '../../state/room-list/roomList'; +import { roomToParentsAtom } from '../../state/room/roomToParents'; +import { roomToUnreadAtom } from '../../state/room/roomToUnread'; +import { factoryRoomIdByActivity } from '../../utils/sort'; +import { getMxIdLocalPart, guessDmRoomUserId } from '../../utils/matrix'; +import { makeHighlightRegex } from '../../plugins/react-custom-html-parser'; + +export enum SearchRoomType { + Rooms = '#', + Spaces = '*', + Directs = '@', +} + +const PREFIX_TO_TYPE: Record = { + '#': SearchRoomType.Rooms, + '*': SearchRoomType.Spaces, + '@': SearchRoomType.Directs, +}; + +const SEARCH_OPTIONS: UseAsyncSearchOptions = { + matchOptions: { contain: true }, + normalizeOptions: { ignoreWhitespace: false }, +}; + +export const getDmUserId = ( + roomId: string, + getRoom: (roomId: string) => Room | undefined, + myUserId: string +): string | undefined => { + const room = getRoom(roomId); + return room && guessDmRoomUserId(room, myUserId); +}; + +type UseRoomSearchOptions = { + // Called after the user commits a room selection (click or Enter). + // Modal Search uses this to close itself; the inline header form uses + // it to retract the curtain. The hook itself doesn't navigate — the + // caller decides via `onOpenRoomId(roomId, isSpace)`. + onOpenRoomId: (roomId: string, isSpace: boolean) => void; +}; + +// Single source of truth for the room/space/direct search panel — +// prefix detection (`#`/`*`/`@`), filtered scope, top-active fallback, +// keyboard navigation, focus auto-scroll, and result-highlight regex. +// Used by both the global `Search` modal and the inline header form. +export function useRoomSearch({ onOpenRoomId }: UseRoomSearchOptions) { + const mx = useMatrixClient(); + const inputRef = useRef(null); + const scrollRef = useRef(null); + + const [searchRoomType, setSearchRoomType] = useState(); + + const allRoomsSet = useAllJoinedRoomsSet(); + const getRoom = useGetRoom(allRoomsSet); + const roomToParents = useAtomValue(roomToParentsAtom); + const orphanSpaces = useOrphanSpaces(mx, allRoomsAtom, roomToParents); + const mDirects = useAtomValue(mDirectAtom); + const rooms = useRooms(mx, allRoomsAtom, mDirects); + const spaces = useSpaces(mx, allRoomsAtom); + const directs = useDirects(mx, allRoomsAtom, mDirects); + const roomToUnread = useAtomValue(roomToUnreadAtom); + + const topActiveRooms = useMemo(() => { + if (searchRoomType === SearchRoomType.Spaces) return spaces; + if (searchRoomType === SearchRoomType.Directs) { + return [...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20); + } + if (searchRoomType === SearchRoomType.Rooms) { + return [...rooms].sort(factoryRoomIdByActivity(mx)).slice(0, 20); + } + return [...rooms, ...directs].sort(factoryRoomIdByActivity(mx)).slice(0, 20); + }, [mx, rooms, directs, spaces, searchRoomType]); + + const targetRooms = useMemo(() => { + if (searchRoomType === undefined) return [...rooms, ...directs, ...spaces]; + if (searchRoomType === SearchRoomType.Rooms) return rooms; + if (searchRoomType === SearchRoomType.Spaces) return spaces; + if (searchRoomType === SearchRoomType.Directs) return directs; + return []; + }, [rooms, spaces, directs, searchRoomType]); + + const myUserId = mx.getSafeUserId(); + const getTargetStr: SearchItemStrGetter = useCallback( + (roomId: string) => { + const roomName = getRoom(roomId)?.name ?? roomId; + if (mDirects.has(roomId)) { + const targetUserId = getDmUserId(roomId, getRoom, myUserId); + const targetUsername = targetUserId && getMxIdLocalPart(targetUserId); + if (targetUsername) return [roomName, targetUsername]; + } + return roomName; + }, + [getRoom, mDirects, myUserId] + ); + + const [result, search, resetSearch] = useAsyncSearch(targetRooms, getTargetStr, SEARCH_OPTIONS); + const roomsToRender = result ? result.items : topActiveRooms; + const listFocus = useListFocusIndex(roomsToRender.length, 0); + + const queryHighlightRegex = result?.query + ? makeHighlightRegex(result.query.split(' ')) + : undefined; + + const handleInputChange: ChangeEventHandler = useCallback( + (evt) => { + listFocus.reset(); + const target = evt.currentTarget; + let value = target.value.trim(); + const prefix = value.match(/^[#@*]/)?.[0]; + const nextType = prefix ? PREFIX_TO_TYPE[prefix] : undefined; + setSearchRoomType(nextType); + if (nextType) value = value.slice(1); + if (value === '') { + resetSearch(); + return; + } + search(value); + }, + [listFocus, search, resetSearch] + ); + + const handleInputKeyDown: KeyboardEventHandler = useCallback( + (evt) => { + const roomId = roomsToRender[listFocus.index]; + if (isKeyHotkey('enter', evt) && roomId) { + onOpenRoomId(roomId, spaces.includes(roomId)); + return; + } + if (isKeyHotkey('arrowdown', evt)) { + evt.preventDefault(); + listFocus.next(); + return; + } + if (isKeyHotkey('arrowup', evt)) { + evt.preventDefault(); + listFocus.previous(); + } + }, + [roomsToRender, listFocus, onOpenRoomId, spaces] + ); + + const handleRoomClick: MouseEventHandler = useCallback( + (evt) => { + const target = evt.currentTarget; + const roomId = target.getAttribute('data-room-id'); + const isSpace = target.getAttribute('data-space') === 'true'; + if (!roomId) return; + onOpenRoomId(roomId, isSpace); + }, + [onOpenRoomId] + ); + + // Auto-scroll the highlighted item into view as the user arrows. + useEffect(() => { + const scrollView = scrollRef.current; + const focusedItem = scrollView?.querySelector(`[data-focus-index="${listFocus.index}"]`); + if (focusedItem && scrollView) { + focusedItem.scrollIntoView({ block: 'nearest' }); + } + }, [listFocus.index]); + + return { + inputRef, + scrollRef, + roomsToRender, + searchRoomType, + result, + listFocus, + queryHighlightRegex, + handleInputChange, + handleInputKeyDown, + handleRoomClick, + // Per-row context the consumer needs to render rows: + getRoom, + mDirects, + spaces, + orphanSpaces, + roomToParents, + roomToUnread, + myUserId, + }; +} diff --git a/src/app/features/settings/MobileSettingsHorseshoe.css.ts b/src/app/features/settings/MobileSettingsHorseshoe.css.ts index dab35247..c7ab3862 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.css.ts +++ b/src/app/features/settings/MobileSettingsHorseshoe.css.ts @@ -28,10 +28,10 @@ export const container = style({ }); // === App body === Holds the wrapped children (the DM list — header, -// scroll content, DirectNewChatRow, DirectSelfRow). Fills the -// container via `inset: 0`. Does NOT translate or shrink — the DM -// list stays exactly where it was in the closed state. Instead, the -// bottom of the pane is masked away by an animated `clip-path: +// scroll content, DirectSelfRow). Fills the container via `inset: 0`. +// Does NOT translate or shrink — the DM list stays exactly where it +// was in the closed state. Instead, the bottom of the pane is masked +// away by an animated `clip-path: // inset(...)` with rounded BL/BR corners — the user sees the visible // top portion of the DM list with a rounded carve at the new bottom // edge, exactly like the profile horseshoe shows the chat with a @@ -42,7 +42,7 @@ export const container = style({ // and the DM list's `@tanstack/react-virtual` re-measures items mid- // gesture. Why not `transform: translateY` either: translating moves // the whole pane up off the top of the viewport, including the -// DirectStreamHeader the user wants to keep visible. Clip-path leaves +// StreamHeader the user wants to keep visible. Clip-path leaves // layout unchanged — the DM list keeps its scroll position, its // measured heights, and its top items in place; only the bottom edge // of what's visible gets carved into the void below. diff --git a/src/app/features/settings/MobileSettingsHorseshoe.tsx b/src/app/features/settings/MobileSettingsHorseshoe.tsx index b32239c0..d105ee99 100644 --- a/src/app/features/settings/MobileSettingsHorseshoe.tsx +++ b/src/app/features/settings/MobileSettingsHorseshoe.tsx @@ -7,15 +7,15 @@ // // User-visible behaviour: // -// • The wrapped app body (DirectStreamHeader → DM list → new-chat -// row → DirectSelfRow) stays exactly where it was — no translate, +// • The wrapped app body (StreamHeader → DM list → DirectSelfRow) +// stays exactly where it was — no translate, // no shrink. The bottom of the visible portion is "masked away" // by an animated `clip-path: inset(0 0 BOTTOMpx 0 round 0 0 Rpx // Rpx)` with rounded BL/BR carves at the new visible edge. The // carved area exposes the container's void colour underneath, and // the silhouette below covers the rest of the masked zone. Why // not `transform: translateY`: translating moves the TOP of the -// pane out of the viewport (DirectStreamHeader scrolls off- +// pane out of the viewport (StreamHeader scrolls off- // screen). Why not `flex-shrink + margin-bottom`: the virtualized // DM list (`@tanstack/react-virtual`) re-measures items every // time the scroll container resizes — items above the shrinking diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index b0d53fd8..eab43a6f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -250,7 +250,7 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) } /> - {/* Bots reuses DirectStreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */} + {/* Bots reuses StreamHeader segments. /bots/* is reserved before SPACE_PATH so deep URLs don't fall to /:spaceIdOrAlias/. */} (null); return ( - - - - - {bots.map((preset) => ( - - ))} - - + + + + + {bots.map((preset) => ( + + ))} + + + ); } diff --git a/src/app/pages/client/channels/Channels.tsx b/src/app/pages/client/channels/Channels.tsx index 5662aec7..921c8834 100644 --- a/src/app/pages/client/channels/Channels.tsx +++ b/src/app/pages/client/channels/Channels.tsx @@ -1,48 +1,49 @@ import React, { useEffect, useRef } from 'react'; import { useSpace } from '../../../hooks/useSpace'; import { PageNav, PageNavContent } from '../../../components/page'; -import { DirectStreamHeader } from '../direct/DirectStreamHeader'; +import { StreamHeader } from '../../../components/stream-header'; import { ChannelsList } from './ChannelsList'; import { ChannelCreateRow } from './ChannelCreateRow'; import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe'; import { WorkspaceFooter } from './WorkspaceFooter'; import { ACTIVE_SPACE_KEY } from './useActiveSpace'; -// Stub nav rendered at the /channels/ index route when the user has no -// orphan spaces yet. Provides the segment switcher so they can navigate -// back to DM / Bots without going through the global rail. The list / -// footer panes only make sense once a Space is in context. +// Index route at /channels/ (no space selected). Renders the shared +// StreamHeader so the segment switcher is consistent across surfaces, +// but no list/footer — those need a Space context. export function ChannelsRootNav() { + const scrollRef = useRef(null); return ( - - - -
- + + + + {/* Empty list slot is intentional: StreamHeader needs a real + scroll target on this route for its touch gesture even + though there's no list yet. */} +
+ + ); } -// Channels left pane (Mattermost-style). Renders the segment switcher at -// the top, the joined-rooms hierarchy in the middle (sub-spaces grouped), -// and the workspace identity plate at the bottom. M6 will make the footer -// a dropdown for users with 2+ orphan spaces; in M1 it is read-only. +// Channels left pane (Mattermost-style). Segment switcher at the top, +// joined-rooms hierarchy in the middle, workspace identity plate at +// the bottom. M6 will turn the footer into a dropdown for users with +// 2+ orphan spaces; in M1 it is read-only. // -// Note: we deliberately do NOT call `useNavToActivePathMapper` here. That -// atom is owned by the legacy // tree (Space.tsx) so the rail's -// "click avatar → resume your last in-space path" behaviour stays -// well-defined. Channels has its own segment-level navigation, so a -// rail-click on a channels-active space lands on the legacy lobby — that's -// fine for M1; users can re-enter channels via the DirectStreamHeader. +// Note: deliberately do NOT call `useNavToActivePathMapper` here. That +// atom is owned by the legacy // tree (Space.tsx) so the +// global rail's «click avatar → resume your last in-space path» stays +// well-defined. Channels has its own segment-level navigation. export function Channels() { const space = useSpace(); const scrollRef = useRef(null); // Persist URL-driven active space so cold-starts at /channels/ resume on // the same workspace. `useActiveSpace` (in ChannelsLanding) reads the - // value but never writes it because the index route has no :spaceIdOrAlias - // param — write happens HERE, where RouteSpaceProvider has already - // resolved the space from the URL. + // value but never writes it because the index route has no + // :spaceIdOrAlias param — the write happens here. useEffect(() => { try { localStorage.setItem(ACTIVE_SPACE_KEY, space.roomId); @@ -52,21 +53,21 @@ export function Channels() { }, [space.roomId]); return ( - - {/* The horseshoe wraps the entire channels column so the workspace - switcher sheet slides up from the bottom of THIS PageNav slot - (not the viewport). The wrapped children stay put; their - bottom edge is carved by an animated clip-path with a 12px - void between the carve and the sliding silhouette. See - ChannelsWorkspaceHorseshoe.tsx for the canonical idioms it - inherits from MobileSettingsHorseshoe (commit a7d6fc2). */} + - - - - - - + + + + + } + > + + + + ); diff --git a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts index feae9b9c..31c27f8d 100644 --- a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts +++ b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts @@ -25,7 +25,7 @@ export const container = style({ overflow: 'hidden', }); -// Wrapped children (DirectStreamHeader → ChannelsList → ChannelCreateRow → +// Wrapped children (StreamHeader → ChannelsList → ChannelCreateRow → // WorkspaceFooter). Stays put — the bottom is carved away by an animated // `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is // load-bearing: the container is painted with the void colour when the diff --git a/src/app/pages/client/direct/Direct.tsx b/src/app/pages/client/direct/Direct.tsx index 29f0d560..0112b4c1 100644 --- a/src/app/pages/client/direct/Direct.tsx +++ b/src/app/pages/client/direct/Direct.tsx @@ -21,8 +21,7 @@ import { getRoomNotificationMode, useRoomsNotificationPreferencesContext, } from '../../../hooks/useRoomsNotificationPreferences'; -import { DirectStreamHeader } from './DirectStreamHeader'; -import { DirectNewChatRow } from './DirectNewChatRow'; +import { StreamHeader } from '../../../components/stream-header'; import { DirectSelfRow } from './DirectSelfRow'; import { MobileSettingsHorseshoe } from '../../../features/settings'; @@ -79,6 +78,7 @@ function SpamToggleRow({ spamCount, expanded, onToggle }: SpamToggleRowProps) { aria-expanded={expanded} style={{ appearance: 'none', + WebkitAppearance: 'none', border: 'none', background: 'transparent', cursor: 'pointer', @@ -118,10 +118,13 @@ export function Direct() { // reactions — anything roomToUnread misses. Uses the 'Room.timeline' // literal (= RoomEvent.Timeline) to avoid named imports from matrix-js-sdk // that the project's moduleResolution flags as TS2614 (see - // docs/known-tech-debt-lint/). + // docs/known-tech-debt-lint/). `directs` is read via a ref so the listener + // doesn't re-bind on every set-identity change (heavy sessions can flip + // the set 5-10× during sync). const [, setActivityTick] = useState(0); + const directsRef = useRef(directs); + directsRef.current = directs; useEffect(() => { - const directsSet = new Set(directs); const handleTimeline = ( _ev: unknown, room: { roomId: string } | undefined, @@ -130,7 +133,7 @@ export function Direct() { data: { liveEvent?: boolean } | undefined ) => { if (!room || !data?.liveEvent) return; - if (!directsSet.has(room.roomId)) return; + if (!directsRef.current.includes(room.roomId)) return; setActivityTick((n) => n + 1); }; const emitter = mx as unknown as { @@ -141,7 +144,7 @@ export function Direct() { return () => { emitter.removeListener('Room.timeline', handleTimeline); }; - }, [mx, directs]); + }, [mx]); const selectedRoomId = useSelectedRoom(); @@ -199,98 +202,85 @@ export function Direct() { }); return ( - - {/* On mobile, `MobileSettingsHorseshoe` wraps the whole DM - list (including DirectSelfRow). The bottom-up Settings sheet - opens via tap on DirectSelfRow OR drag-up from the row - (`data-settings-drag-origin` marks the drag-target). The - app body STAYS IN PLACE — only the bottom is carved away by - an animated `clip-path: inset(...)` with rounded BL/BR; the - sheet emerges below with rounded TL/TR, separated by a 12px - void. On non-mobile the wrapper is a pass-through, so the - desktop layout is unchanged. */} + + {/* MobileSettingsHorseshoe wraps the full DM column on mobile so the + Settings sheet can carve into the bottom of this pane. On non-mobile + it's a pass-through. */} - - {noRoomToDisplay ? ( - - ) : ( - - - -
- {virtualizer.getVirtualItems().map((vItem) => { - const item = items[vItem.index]; - if (!item) return null; + }> + {noRoomToDisplay ? ( + + ) : ( + + + +
+ {virtualizer.getVirtualItems().map((vItem) => { + const item = items[vItem.index]; + if (!item) return null; - if (item.kind === 'invite' || item.kind === 'spam-invite') { - const { entry } = item; - const selected = selectedRoomId === entry.roomId; + if (item.kind === 'invite' || item.kind === 'spam-invite') { + const { entry } = item; + const selected = selectedRoomId === entry.roomId; + return ( + + + + ); + } + + if (item.kind === 'spam-toggle') { + return ( + + setSpamExpanded((v) => !v)} + /> + + ); + } + + const { roomId } = item; + const room = mx.getRoom(roomId); + if (!room) return null; + const selected = selectedRoomId === roomId; return ( - ); - } - - if (item.kind === 'spam-toggle') { - return ( - - setSpamExpanded((v) => !v)} - /> - - ); - } - - // kind === 'direct' - const { roomId } = item; - const room = mx.getRoom(roomId); - if (!room) return null; - const selected = selectedRoomId === roomId; - return ( - - - - ); - })} -
-
-
-
- )} - - + })} +
+
+
+
+ )} +
); diff --git a/src/app/pages/client/direct/DirectNewChatRow.tsx b/src/app/pages/client/direct/DirectNewChatRow.tsx deleted file mode 100644 index b835d804..00000000 --- a/src/app/pages/client/direct/DirectNewChatRow.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import React from 'react'; -import { useTranslation } from 'react-i18next'; -import { Avatar, Box, Icon, Icons, Text, color, config, toRem } from 'folds'; -import { useNavigate } from 'react-router-dom'; -import { NavButton, NavItem, NavItemContent } from '../../../components/nav'; -import { getDirectCreatePath } from '../../pathUtils'; -import { useDirectCreateSelected } from '../../../hooks/router/useDirectSelected'; - -const ROW_MIN_HEIGHT = toRem(56); - -export function DirectNewChatRow() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const selected = useDirectCreateSelected(); - - return ( - - - navigate(getDirectCreatePath())}> - - - - - - - - {t('Direct.create_chat')} - - - - - - - - ); -} diff --git a/src/app/pages/client/direct/DirectStreamHeader.tsx b/src/app/pages/client/direct/DirectStreamHeader.tsx deleted file mode 100644 index f33e98f7..00000000 --- a/src/app/pages/client/direct/DirectStreamHeader.tsx +++ /dev/null @@ -1,96 +0,0 @@ -import React, { forwardRef } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useMatch, useNavigate } from 'react-router-dom'; -import { Box, color, toRem } from 'folds'; -import { PageNavHeader } from '../../../components/page'; -import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../paths'; -import { isNativePlatform } from '../../../utils/capacitor'; -import { useBotPresets } from '../../../features/bots/catalog'; - -type SegmentProps = { - active: boolean; - disabled?: boolean; - label: string; - onClick?: () => void; -}; -const Segment = forwardRef( - ({ active, disabled, label, onClick }, ref) => ( - - ) -); - -export function DirectStreamHeader() { - const { t } = useTranslation(); - const navigate = useNavigate(); - const bots = useBotPresets(); - - const directMatch = useMatch({ path: DIRECT_PATH, caseSensitive: true, end: false }); - const botsMatch = useMatch({ path: BOTS_PATH, caseSensitive: true, end: false }); - const channelsMatch = useMatch({ path: CHANNELS_PATH, caseSensitive: true, end: false }); - const showBotsSegment = bots.length > 0 || !!botsMatch; - - const navOpts = { replace: isNativePlatform() }; - - return ( - - - navigate(DIRECT_PATH, navOpts)} - /> - navigate(CHANNELS_PATH, navOpts)} - /> - {showBotsSegment && ( - navigate(BOTS_PATH, navOpts)} - /> - )} - - - ); -}