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);