From 6ca6b69d485e0d6d98393d5021060759d5a8fb9f Mon Sep 17 00:00:00 2001 From: heaven Date: Wed, 20 May 2026 01:59:04 +0300 Subject: [PATCH] feat(stream-header): contextual Plus on Channels opens create-channel inside workspace and create-community on landing via StreamHeader.primaryAction --- .../MobileTabsPagerHeader.tsx | 20 ++++-- .../stream-header/StreamHeader.css.ts | 5 +- .../components/stream-header/StreamHeader.tsx | 61 +++++++++++++------ .../stream-header/useCurtainBodyGesture.ts | 14 ++--- .../client/channels/ChannelCreateRow.tsx | 57 ----------------- src/app/pages/client/channels/Channels.tsx | 47 +++++++++++--- .../ChannelsWorkspaceHorseshoe.css.ts | 4 +- src/app/state/mobilePagerHeader.ts | 17 ++++++ 8 files changed, 124 insertions(+), 101 deletions(-) delete mode 100644 src/app/pages/client/channels/ChannelCreateRow.tsx diff --git a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx index 2a50e3ca..e41fb1ce 100644 --- a/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx +++ b/src/app/components/mobile-tabs-pager/MobileTabsPagerHeader.tsx @@ -81,6 +81,13 @@ export function MobileTabsPagerHeader({ 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. + const primaryAction = curtainControls?.primaryAction ?? null; // The static header does NOT translate to follow the curtain. It // stays put; the curtain physically rises ABOVE it via z-stack — see @@ -171,14 +178,19 @@ export function MobileTabsPagerHeader({ fill="None" size="400" radii="Pill" - onClick={openChat} - aria-label={t('Direct.create_chat')} - aria-controls={INLINE_FORM_ID} + onClick={primaryAction ? primaryAction.onClick : openChat} + aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')} + // See StreamHeader's matching IconButton: drop only + // `aria-controls` when the override opens a portal + // Modal (no in-subtree form to point at). The + // override IS a dialog opener, so `aria-haspopup` + + // `aria-expanded={false}` stay accurate either way. + aria-controls={primaryAction ? undefined : INLINE_FORM_ID} aria-expanded={false} aria-haspopup="dialog" disabled={iconsDisabled} > - +
@@ -485,8 +508,8 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre )} {children} - {/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is - kept mounted across snaps so the curtain reads as a self- + {/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) 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 diff --git a/src/app/components/stream-header/useCurtainBodyGesture.ts b/src/app/components/stream-header/useCurtainBodyGesture.ts index c9b8319f..0bb23cbc 100644 --- a/src/app/components/stream-header/useCurtainBodyGesture.ts +++ b/src/app/components/stream-header/useCurtainBodyGesture.ts @@ -30,9 +30,9 @@ type Args = { // hook has already armed for that touch). handleRef: MutableRefObject; // The `bottomPinned` slot at the bottom of the curtain (hosts - // DirectSelfRow, ChannelCreateRow, WorkspaceFooter). These rows - // open their own bottom sheets via vertical drag, so a touch that - // starts there must NOT engage the curtain body — otherwise the + // DirectSelfRow, WorkspaceFooter). These rows open their own bottom + // sheets via vertical drag, so a touch that starts there must NOT + // engage the curtain body — otherwise the // user's «pull settings up» gesture would also pin the curtain // and the two motions would visually fight. `null` is fine (the // surface has no bottomPinned content); the contains() check is @@ -165,10 +165,10 @@ export function useCurtainBodyGesture({ const target = e.target as Node | null; if (target && handleRef.current?.contains(target)) return; // Hand off to the bottomPinned region (DirectSelfRow, - // WorkspaceFooter, ChannelCreateRow). Those rows host their - // own drag-to-open bottom sheets — engaging the curtain - // gesture here would pin the curtain in parallel with the - // sheet opening, and the two motions would visually fight. + // WorkspaceFooter). Those rows host their own drag-to-open + // bottom sheets — engaging the curtain gesture here would pin + // the curtain in parallel with the sheet opening, and the two + // motions would visually fight. if (target && bottomPinnedRef.current?.contains(target)) return; // Scroll-aware bail: leave a scrollable chat list to its native // vertical scroll. Skipped in form-* snaps because the visible diff --git a/src/app/pages/client/channels/ChannelCreateRow.tsx b/src/app/pages/client/channels/ChannelCreateRow.tsx deleted file mode 100644 index ff866daf..00000000 --- a/src/app/pages/client/channels/ChannelCreateRow.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 { Room } from 'matrix-js-sdk'; -import { NavButton, NavItem, NavItemContent } from '../../../components/nav'; -import { useOpenCreateRoomModal } from '../../../state/hooks/createRoomModal'; -import { CreateRoomType } from '../../../components/create-room/types'; - -const ROW_MIN_HEIGHT = toRem(56); - -type ChannelCreateRowProps = { - space: Room; -}; - -export function ChannelCreateRow({ space }: ChannelCreateRowProps) { - const { t } = useTranslation(); - const openCreateRoomModal = useOpenCreateRoomModal(); - - return ( - - - openCreateRoomModal(space.roomId, CreateRoomType.TextRoom)} - aria-label={t('Channels.create_channel')} - > - - - - - - - - {t('Channels.create_channel')} - - - - - - - - ); -} diff --git a/src/app/pages/client/channels/Channels.tsx b/src/app/pages/client/channels/Channels.tsx index 86e01e9a..45db0c72 100644 --- a/src/app/pages/client/channels/Channels.tsx +++ b/src/app/pages/client/channels/Channels.tsx @@ -1,10 +1,15 @@ -import React, { useEffect, useRef } from 'react'; +import React, { useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Icons } from 'folds'; import { useSpace } from '../../../hooks/useSpace'; import { PageNav, PageNavContent } from '../../../components/page'; import { StreamHeader } from '../../../components/stream-header'; import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext'; +import { StreamHeaderPrimaryAction } from '../../../state/mobilePagerHeader'; +import { useOpenCreateRoomModal } from '../../../state/hooks/createRoomModal'; +import { useOpenCreateSpaceModal } from '../../../state/hooks/createSpaceModal'; +import { CreateRoomType } from '../../../components/create-room/types'; import { ChannelsList } from './ChannelsList'; -import { ChannelCreateRow } from './ChannelCreateRow'; import { ChannelsLanding } from './ChannelsLanding'; import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe'; import { WorkspaceFooter } from './WorkspaceFooter'; @@ -25,19 +30,34 @@ import { ACTIVE_SPACE_KEY } from './useActiveSpace'; // it in `PageNavContent` (block layout inside Scroll) would collapse // the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`. export function ChannelsRootNav() { + const { t } = useTranslation(); const scrollRef = useRef(null); + const openCreateSpaceModal = useOpenCreateSpaceModal(); // Skip PageNav surface in pager mode so the pager's static header // tabs (sitting behind the swipe strip in DOM order) show through // until covered by the rising curtain. See Direct.tsx for the same // pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader` // for the full overlay contract. const inPagerMode = useMobilePagerPane() !== null; + // Landing has no active workspace yet, so the Plus slot launches + // workspace creation — mirrors the landing's own CTA, just promoted + // into the tabs row so the action is reachable without scrolling + // and matches the workspace-selected variant's «one Plus on the tab» + // contract. + const primaryAction = useMemo( + () => ({ + iconSrc: Icons.Plus, + label: t('Channels.workspace_switcher_create_space'), + onClick: () => openCreateSpaceModal(), + }), + [t, openCreateSpaceModal] + ); return ( {/* Shared `pinKey` with the workspace-listing `Channels` below so pin/unpin persists when the user toggles between the landing CTA and a chosen workspace within the Channels tab. */} - + @@ -54,9 +74,11 @@ export function ChannelsRootNav() { // 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 { t } = useTranslation(); const space = useSpace(); const scrollRef = useRef(null); const inPagerMode = useMobilePagerPane() !== null; + const openCreateRoomModal = useOpenCreateRoomModal(); // Persist URL-driven active space so cold-starts at /channels/ resume on // the same workspace. `useActiveSpace` (in ChannelsLanding) reads the @@ -70,18 +92,25 @@ export function Channels() { } }, [space.roomId]); + // Plus on the tabs row creates a channel inside the active workspace. + // Single entry point for «create» on the Channels tab. + const primaryAction = useMemo( + () => ({ + iconSrc: Icons.Plus, + label: t('Channels.create_channel'), + onClick: () => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom), + }), + [t, openCreateRoomModal, space.roomId] + ); + return ( - - - - } + primaryAction={primaryAction} + bottomPinned={} > diff --git a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts index 26a16fb2..a7ab82e5 100644 --- a/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts +++ b/src/app/pages/client/channels/ChannelsWorkspaceHorseshoe.css.ts @@ -41,8 +41,8 @@ export const container = style({ marginTop: 'calc(-1 * var(--vojo-safe-top, 0px))', }); -// Wrapped children (StreamHeader → ChannelsList → ChannelCreateRow → -// WorkspaceFooter). Stays put — the bottom is carved away by an animated +// Wrapped children (StreamHeader → ChannelsList → 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 // sheet is active, so without an opaque bg the void would bleed through diff --git a/src/app/state/mobilePagerHeader.ts b/src/app/state/mobilePagerHeader.ts index 5f690bb2..683299e5 100644 --- a/src/app/state/mobilePagerHeader.ts +++ b/src/app/state/mobilePagerHeader.ts @@ -1,4 +1,17 @@ import { atom } from 'jotai'; +import { IconSrc } from 'folds'; + +// Per-tab «primary action» published by a pane's StreamHeader. When +// present it replaces the default Plus / «new chat» button in the +// static header (and the matching peek-chip in the per-pane StreamHeader). +// Channels uses this to surface «create channel» (inside a workspace) +// or «create community» (on the landing) instead of the DM-creation +// path that Direct keeps as default. +export type StreamHeaderPrimaryAction = { + iconSrc: IconSrc; + label: string; + onClick: () => void; +}; // Controls exposed by the active pane's curtain to the shared static // tabs row at the top of MobileTabsPager. The pager hoists the tabs + @@ -17,6 +30,10 @@ export type MobilePagerCurtainControls = { 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`. + primaryAction: StreamHeaderPrimaryAction | null; }; export const mobilePagerCurtainAtom = atom(null);