feat(stream-header): contextual Plus on Channels opens create-channel inside workspace and create-community on landing via StreamHeader.primaryAction

This commit is contained in:
heaven 2026-05-20 01:59:04 +03:00
parent 240bb54c29
commit 6ca6b69d48
8 changed files with 124 additions and 101 deletions

View file

@ -81,6 +81,13 @@ export function MobileTabsPagerHeader({
const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]); const openSearch = useCallback(() => curtainControls?.openSearch(), [curtainControls]);
const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]); const closeForm = useCallback(() => curtainControls?.closeForm(), [curtainControls]);
const iconsDisabled = curtainControls === null; 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 // The static header does NOT translate to follow the curtain. It
// stays put; the curtain physically rises ABOVE it via z-stack — see // stays put; the curtain physically rises ABOVE it via z-stack — see
@ -171,14 +178,19 @@ export function MobileTabsPagerHeader({
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={openChat} onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={t('Direct.create_chat')} aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID} // 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-expanded={false}
aria-haspopup="dialog" aria-haspopup="dialog"
disabled={iconsDisabled} disabled={iconsDisabled}
> >
<Icon size="100" src={Icons.Plus} /> <Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton> </IconButton>
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"

View file

@ -89,9 +89,8 @@ export const iconsCluster = style({
// Curtain. Layered above the header (z-index higher). Its top edge // Curtain. Layered above the header (z-index higher). Its top edge
// moves with the snap state (and live finger drag); its bottom edge // moves with the snap state (and live finger drag); its bottom edge
// is anchored to the stage bottom so the curtain's `bottomPinned` // is anchored to the stage bottom so the curtain's `bottomPinned`
// child (DirectSelfRow / ChannelCreateRow / WorkspaceFooter) stays // child (DirectSelfRow / WorkspaceFooter) stays glued to the visible
// glued to the visible viewport bottom regardless of where the // viewport bottom regardless of where the curtain's top is.
// curtain's top is.
// //
// Only the TOP corners are rounded: the bottom is meant to read as // Only the TOP corners are rounded: the bottom is meant to read as
// continuous with the always-visible bottomPinned row (DirectSelfRow // continuous with the always-visible bottomPinned row (DirectSelfRow

View file

@ -16,7 +16,11 @@ import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
import { isNativePlatform } from '../../utils/capacitor'; import { isNativePlatform } from '../../utils/capacitor';
import { useBotPresets } from '../../features/bots/catalog'; import { useBotPresets } from '../../features/bots/catalog';
import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext'; import { useMobilePagerPane } from '../mobile-tabs-pager/MobilePagerPaneContext';
import { MobilePagerCurtainControls, mobilePagerCurtainAtom } from '../../state/mobilePagerHeader'; import {
MobilePagerCurtainControls,
StreamHeaderPrimaryAction,
mobilePagerCurtainAtom,
} from '../../state/mobilePagerHeader';
import { settingsSheetAtom } from '../../state/settingsSheet'; import { settingsSheetAtom } from '../../state/settingsSheet';
import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet'; import { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
import * as css from './StreamHeader.css'; import * as css from './StreamHeader.css';
@ -45,9 +49,9 @@ type StreamHeaderProps = {
// `overflow: auto` div that the gesture hook listens to. // `overflow: auto` div that the gesture hook listens to.
children: ReactNode; children: ReactNode;
// Optional row(s) pinned to the bottom of the curtain (DirectSelfRow, // Optional row(s) pinned to the bottom of the curtain (DirectSelfRow,
// ChannelCreateRow, WorkspaceFooter). Hidden while a form is active // WorkspaceFooter). Hidden while a form is active so the on-screen
// so the on-screen keyboard's viewport resize doesn't push them up // keyboard's viewport resize doesn't push them up over the form
// over the form (see commit 14ed080). // (see commit 14ed080).
bottomPinned?: ReactNode; bottomPinned?: ReactNode;
// Stable identifier used to persist the curtain's pinned overlay // Stable identifier used to persist the curtain's pinned overlay
// across listing-pane remounts (the user taps into a Room and back, // across listing-pane remounts (the user taps into a Room and back,
@ -58,9 +62,21 @@ type StreamHeaderProps = {
// listing share `"channels"` so pin survives the toggle between // listing share `"channels"` so pin survives the toggle between
// empty state and a chosen workspace. // empty state and a chosen workspace.
pinKey: string; pinKey: string;
// Optional override for the Plus button. When omitted the header
// renders the default «new chat» action that opens InlineNewChatForm
// via the curtain. Channels overrides this with «create channel» /
// «create community» so the same Plus slot launches a contextual
// action instead of the DM-creation form.
primaryAction?: StreamHeaderPrimaryAction;
}; };
export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: StreamHeaderProps) { export function StreamHeader({
scrollRef,
children,
bottomPinned,
pinKey,
primaryAction,
}: StreamHeaderProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const navigate = useNavigate(); const navigate = useNavigate();
const bots = useBotPresets(); const bots = useBotPresets();
@ -144,10 +160,10 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
// reads as physically «heavier» than the handle's crisp pull. // reads as physically «heavier» than the handle's crisp pull.
// Engages only when the chat list has no scrollable content; // Engages only when the chat list has no scrollable content;
// additionally bails on touches that start inside the bottom- // additionally bails on touches that start inside the bottom-
// pinned slot (DirectSelfRow / WorkspaceFooter / ChannelCreate // pinned slot (DirectSelfRow / WorkspaceFooter have their own
// have their own drag-to-open bottom sheets) and on touches // drag-to-open bottom sheets) and on touches that start while
// that start while pinned (unpin is HANDLE-only — the user has // pinned (unpin is HANDLE-only — the user has to grab the
// to grab the dedicated affordance to release the lock). // dedicated affordance to release the lock).
// //
// Both hooks share `handleVisual` (mirrors desktop // Both hooks share `handleVisual` (mirrors desktop
// `PageNavResizeHandle`: `dragging` lights up the grabber pill; // `PageNavResizeHandle`: `dragging` lights up the grabber pill;
@ -203,8 +219,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
openChat, openChat,
closeForm: close, closeForm: close,
isFormActive: isActive, isFormActive: isActive,
primaryAction: primaryAction ?? null,
}), }),
[openSearch, openChat, close, isActive] [openSearch, openChat, close, isActive, primaryAction]
); );
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom); const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
@ -360,13 +377,19 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
fill="None" fill="None"
size="400" size="400"
radii="Pill" radii="Pill"
onClick={openChat} onClick={primaryAction ? primaryAction.onClick : openChat}
aria-label={t('Direct.create_chat')} aria-label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
aria-controls={INLINE_FORM_ID} // `aria-controls` points at the curtain-mounted form
// region — drop it when `primaryAction` opens a portal
// dialog (`Modal` lives outside this subtree, so there
// is nothing to control here). `aria-haspopup="dialog"`
// + `aria-expanded={false}` stay accurate for both
// branches: the override opens a true Modal dialog.
aria-controls={primaryAction ? undefined : INLINE_FORM_ID}
aria-expanded={false} aria-expanded={false}
aria-haspopup="dialog" aria-haspopup="dialog"
> >
<Icon size="100" src={Icons.Plus} /> <Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
</IconButton> </IconButton>
<IconButton <IconButton
variant="SurfaceVariant" variant="SurfaceVariant"
@ -429,9 +452,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
</div> </div>
<div className={css.chipRow}> <div className={css.chipRow}>
<Chip <Chip
iconSrc={Icons.Plus} iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
label={t('Direct.create_chat')} label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
onClick={openChat} onClick={primaryAction ? primaryAction.onClick : openChat}
hidden={curtain.snap !== 'peek'} hidden={curtain.snap !== 'peek'}
/> />
</div> </div>
@ -485,8 +508,8 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
</div> </div>
)} )}
{children} {children}
{/* `bottomPinned` (DirectSelfRow, ChannelCreateRow, etc.) is {/* `bottomPinned` (DirectSelfRow, WorkspaceFooter) is kept
kept mounted across snaps so the curtain reads as a self- mounted across snaps so the curtain reads as a self-
contained "screen" with its bottom row always pinned to contained "screen" with its bottom row always pinned to
the stage bottom. While the on-screen keyboard is up the the stage bottom. While the on-screen keyboard is up the
slot collapses to `height: 0` so it neither paints nor slot collapses to `height: 0` so it neither paints nor

View file

@ -30,9 +30,9 @@ type Args = {
// hook has already armed for that touch). // hook has already armed for that touch).
handleRef: MutableRefObject<HTMLDivElement | null>; handleRef: MutableRefObject<HTMLDivElement | null>;
// The `bottomPinned` slot at the bottom of the curtain (hosts // The `bottomPinned` slot at the bottom of the curtain (hosts
// DirectSelfRow, ChannelCreateRow, WorkspaceFooter). These rows // DirectSelfRow, WorkspaceFooter). These rows open their own bottom
// open their own bottom sheets via vertical drag, so a touch that // sheets via vertical drag, so a touch that starts there must NOT
// starts there must NOT engage the curtain body — otherwise the // engage the curtain body — otherwise the
// user's «pull settings up» gesture would also pin the curtain // user's «pull settings up» gesture would also pin the curtain
// and the two motions would visually fight. `null` is fine (the // and the two motions would visually fight. `null` is fine (the
// surface has no bottomPinned content); the contains() check is // surface has no bottomPinned content); the contains() check is
@ -165,10 +165,10 @@ export function useCurtainBodyGesture({
const target = e.target as Node | null; const target = e.target as Node | null;
if (target && handleRef.current?.contains(target)) return; if (target && handleRef.current?.contains(target)) return;
// Hand off to the bottomPinned region (DirectSelfRow, // Hand off to the bottomPinned region (DirectSelfRow,
// WorkspaceFooter, ChannelCreateRow). Those rows host their // WorkspaceFooter). Those rows host their own drag-to-open
// own drag-to-open bottom sheets — engaging the curtain // bottom sheets — engaging the curtain gesture here would pin
// gesture here would pin the curtain in parallel with the // the curtain in parallel with the sheet opening, and the two
// sheet opening, and the two motions would visually fight. // motions would visually fight.
if (target && bottomPinnedRef.current?.contains(target)) return; if (target && bottomPinnedRef.current?.contains(target)) return;
// Scroll-aware bail: leave a scrollable chat list to its native // Scroll-aware bail: leave a scrollable chat list to its native
// vertical scroll. Skipped in form-* snaps because the visible // vertical scroll. Skipped in form-* snaps because the visible

View file

@ -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 (
<Box
style={{
padding: `${toRem(6)} ${config.space.S100}`,
borderTop: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
}}
>
<NavItem variant="Background" radii="400" style={{ minHeight: ROW_MIN_HEIGHT }}>
<NavButton
onClick={() => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom)}
aria-label={t('Channels.create_channel')}
>
<NavItemContent>
<Box
as="span"
grow="Yes"
alignItems="Center"
gap="300"
style={{
minHeight: ROW_MIN_HEIGHT,
boxSizing: 'border-box',
padding: `${toRem(6)} 0`,
}}
>
<Avatar size="300" radii="400">
<Icon src={Icons.Plus} size="100" />
</Avatar>
<Box as="span" grow="Yes" style={{ minWidth: 0 }}>
<Text as="span" size="T300" truncate style={{ fontWeight: 600 }}>
{t('Channels.create_channel')}
</Text>
</Box>
</Box>
</NavItemContent>
</NavButton>
</NavItem>
</Box>
);
}

View file

@ -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 { useSpace } from '../../../hooks/useSpace';
import { PageNav, PageNavContent } from '../../../components/page'; import { PageNav, PageNavContent } from '../../../components/page';
import { StreamHeader } from '../../../components/stream-header'; import { StreamHeader } from '../../../components/stream-header';
import { useMobilePagerPane } from '../../../components/mobile-tabs-pager/MobilePagerPaneContext'; 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 { ChannelsList } from './ChannelsList';
import { ChannelCreateRow } from './ChannelCreateRow';
import { ChannelsLanding } from './ChannelsLanding'; import { ChannelsLanding } from './ChannelsLanding';
import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe'; import { ChannelsWorkspaceHorseshoe } from './ChannelsWorkspaceHorseshoe';
import { WorkspaceFooter } from './WorkspaceFooter'; import { WorkspaceFooter } from './WorkspaceFooter';
@ -25,19 +30,34 @@ import { ACTIVE_SPACE_KEY } from './useActiveSpace';
// it in `PageNavContent` (block layout inside Scroll) would collapse // it in `PageNavContent` (block layout inside Scroll) would collapse
// the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`. // the centering to top-aligned. Same idiom as `Direct.tsx::DirectEmpty`.
export function ChannelsRootNav() { export function ChannelsRootNav() {
const { t } = useTranslation();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const openCreateSpaceModal = useOpenCreateSpaceModal();
// Skip PageNav surface in pager mode so the pager's static header // Skip PageNav surface in pager mode so the pager's static header
// tabs (sitting behind the swipe strip in DOM order) show through // tabs (sitting behind the swipe strip in DOM order) show through
// until covered by the rising curtain. See Direct.tsx for the same // until covered by the rising curtain. See Direct.tsx for the same
// pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader` // pattern and `mobile-tabs-pager/style.css.ts::pagerStaticHeader`
// for the full overlay contract. // for the full overlay contract.
const inPagerMode = useMobilePagerPane() !== null; 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<StreamHeaderPrimaryAction>(
() => ({
iconSrc: Icons.Plus,
label: t('Channels.workspace_switcher_create_space'),
onClick: () => openCreateSpaceModal(),
}),
[t, openCreateSpaceModal]
);
return ( return (
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
{/* Shared `pinKey` with the workspace-listing `Channels` below {/* Shared `pinKey` with the workspace-listing `Channels` below
so pin/unpin persists when the user toggles between the so pin/unpin persists when the user toggles between the
landing CTA and a chosen workspace within the Channels tab. */} landing CTA and a chosen workspace within the Channels tab. */}
<StreamHeader scrollRef={scrollRef} pinKey="channels"> <StreamHeader scrollRef={scrollRef} pinKey="channels" primaryAction={primaryAction}>
<ChannelsLanding /> <ChannelsLanding />
</StreamHeader> </StreamHeader>
</PageNav> </PageNav>
@ -54,9 +74,11 @@ export function ChannelsRootNav() {
// global rail's «click avatar → resume your last in-space path» stays // global rail's «click avatar → resume your last in-space path» stays
// well-defined. Channels has its own segment-level navigation. // well-defined. Channels has its own segment-level navigation.
export function Channels() { export function Channels() {
const { t } = useTranslation();
const space = useSpace(); const space = useSpace();
const scrollRef = useRef<HTMLDivElement>(null); const scrollRef = useRef<HTMLDivElement>(null);
const inPagerMode = useMobilePagerPane() !== null; const inPagerMode = useMobilePagerPane() !== null;
const openCreateRoomModal = useOpenCreateRoomModal();
// Persist URL-driven active space so cold-starts at /channels/ resume on // Persist URL-driven active space so cold-starts at /channels/ resume on
// the same workspace. `useActiveSpace` (in ChannelsLanding) reads the // the same workspace. `useActiveSpace` (in ChannelsLanding) reads the
@ -70,18 +92,25 @@ export function Channels() {
} }
}, [space.roomId]); }, [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<StreamHeaderPrimaryAction>(
() => ({
iconSrc: Icons.Plus,
label: t('Channels.create_channel'),
onClick: () => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom),
}),
[t, openCreateRoomModal, space.roomId]
);
return ( return (
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}> <PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
<ChannelsWorkspaceHorseshoe space={space}> <ChannelsWorkspaceHorseshoe space={space}>
<StreamHeader <StreamHeader
scrollRef={scrollRef} scrollRef={scrollRef}
pinKey="channels" pinKey="channels"
bottomPinned={ primaryAction={primaryAction}
<> bottomPinned={<WorkspaceFooter space={space} />}
<ChannelCreateRow space={space} />
<WorkspaceFooter space={space} />
</>
}
> >
<PageNavContent scrollRef={scrollRef}> <PageNavContent scrollRef={scrollRef}>
<ChannelsList scrollRef={scrollRef} /> <ChannelsList scrollRef={scrollRef} />

View file

@ -41,8 +41,8 @@ export const container = style({
marginTop: 'calc(-1 * var(--vojo-safe-top, 0px))', marginTop: 'calc(-1 * var(--vojo-safe-top, 0px))',
}); });
// Wrapped children (StreamHeader → ChannelsList → ChannelCreateRow → // Wrapped children (StreamHeader → ChannelsList → WorkspaceFooter).
// WorkspaceFooter). Stays put — the bottom is carved away by an animated // Stays put — the bottom is carved away by an animated
// `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is // `clip-path: inset(...)` with rounded BL/BR. `backgroundColor` is
// load-bearing: the container is painted with the void colour when the // 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 // sheet is active, so without an opaque bg the void would bleed through

View file

@ -1,4 +1,17 @@
import { atom } from 'jotai'; 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 // Controls exposed by the active pane's curtain to the shared static
// tabs row at the top of MobileTabsPager. The pager hoists the tabs + // tabs row at the top of MobileTabsPager. The pager hoists the tabs +
@ -17,6 +30,10 @@ export type MobilePagerCurtainControls = {
openChat: () => void; openChat: () => void;
closeForm: () => void; closeForm: () => void;
isFormActive: boolean; 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<MobilePagerCurtainControls | null>(null); export const mobilePagerCurtainAtom = atom<MobilePagerCurtainControls | null>(null);