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:
parent
23cebcd38f
commit
94ec309120
8 changed files with 124 additions and 101 deletions
|
|
@ -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}
|
||||
>
|
||||
<Icon size="100" src={Icons.Plus} />
|
||||
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
|
|
|
|||
|
|
@ -89,9 +89,8 @@ export const iconsCluster = style({
|
|||
// 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.
|
||||
// child (DirectSelfRow / 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
|
||||
|
|
|
|||
|
|
@ -16,7 +16,11 @@ import { BOTS_PATH, CHANNELS_PATH, DIRECT_PATH } from '../../pages/paths';
|
|||
import { isNativePlatform } from '../../utils/capacitor';
|
||||
import { useBotPresets } from '../../features/bots/catalog';
|
||||
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 { channelsWorkspaceSheetAtom } from '../../state/channelsWorkspaceSheet';
|
||||
import * as css from './StreamHeader.css';
|
||||
|
|
@ -45,9 +49,9 @@ type StreamHeaderProps = {
|
|||
// `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).
|
||||
// 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;
|
||||
// Stable identifier used to persist the curtain's pinned overlay
|
||||
// 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
|
||||
// empty state and a chosen workspace.
|
||||
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 navigate = useNavigate();
|
||||
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.
|
||||
// Engages only when the chat list has no scrollable content;
|
||||
// additionally bails on touches that start inside the bottom-
|
||||
// pinned slot (DirectSelfRow / WorkspaceFooter / ChannelCreate
|
||||
// have their own drag-to-open bottom sheets) and on touches
|
||||
// that start while pinned (unpin is HANDLE-only — the user has
|
||||
// to grab the dedicated affordance to release the lock).
|
||||
// pinned slot (DirectSelfRow / WorkspaceFooter have their own
|
||||
// drag-to-open bottom sheets) and on touches that start while
|
||||
// pinned (unpin is HANDLE-only — the user has to grab the
|
||||
// dedicated affordance to release the lock).
|
||||
//
|
||||
// Both hooks share `handleVisual` (mirrors desktop
|
||||
// `PageNavResizeHandle`: `dragging` lights up the grabber pill;
|
||||
|
|
@ -203,8 +219,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
openChat,
|
||||
closeForm: close,
|
||||
isFormActive: isActive,
|
||||
primaryAction: primaryAction ?? null,
|
||||
}),
|
||||
[openSearch, openChat, close, isActive]
|
||||
[openSearch, openChat, close, isActive, primaryAction]
|
||||
);
|
||||
|
||||
const setPagerCurtain = useSetAtom(mobilePagerCurtainAtom);
|
||||
|
|
@ -360,13 +377,19 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
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')}
|
||||
// `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-haspopup="dialog"
|
||||
>
|
||||
<Icon size="100" src={Icons.Plus} />
|
||||
<Icon size="100" src={primaryAction ? primaryAction.iconSrc : Icons.Plus} />
|
||||
</IconButton>
|
||||
<IconButton
|
||||
variant="SurfaceVariant"
|
||||
|
|
@ -429,9 +452,9 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
</div>
|
||||
<div className={css.chipRow}>
|
||||
<Chip
|
||||
iconSrc={Icons.Plus}
|
||||
label={t('Direct.create_chat')}
|
||||
onClick={openChat}
|
||||
iconSrc={primaryAction ? primaryAction.iconSrc : Icons.Plus}
|
||||
label={primaryAction ? primaryAction.label : t('Direct.create_chat')}
|
||||
onClick={primaryAction ? primaryAction.onClick : openChat}
|
||||
hidden={curtain.snap !== 'peek'}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -485,8 +508,8 @@ export function StreamHeader({ scrollRef, children, bottomPinned, pinKey }: Stre
|
|||
</div>
|
||||
)}
|
||||
{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
|
||||
|
|
|
|||
|
|
@ -30,9 +30,9 @@ type Args = {
|
|||
// hook has already armed for that touch).
|
||||
handleRef: MutableRefObject<HTMLDivElement | null>;
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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<HTMLDivElement>(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<StreamHeaderPrimaryAction>(
|
||||
() => ({
|
||||
iconSrc: Icons.Plus,
|
||||
label: t('Channels.workspace_switcher_create_space'),
|
||||
onClick: () => openCreateSpaceModal(),
|
||||
}),
|
||||
[t, openCreateSpaceModal]
|
||||
);
|
||||
return (
|
||||
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||
{/* 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. */}
|
||||
<StreamHeader scrollRef={scrollRef} pinKey="channels">
|
||||
<StreamHeader scrollRef={scrollRef} pinKey="channels" primaryAction={primaryAction}>
|
||||
<ChannelsLanding />
|
||||
</StreamHeader>
|
||||
</PageNav>
|
||||
|
|
@ -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<HTMLDivElement>(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<StreamHeaderPrimaryAction>(
|
||||
() => ({
|
||||
iconSrc: Icons.Plus,
|
||||
label: t('Channels.create_channel'),
|
||||
onClick: () => openCreateRoomModal(space.roomId, CreateRoomType.TextRoom),
|
||||
}),
|
||||
[t, openCreateRoomModal, space.roomId]
|
||||
);
|
||||
|
||||
return (
|
||||
<PageNav resizable surface={inPagerMode ? undefined : 'surfaceVariant'}>
|
||||
<ChannelsWorkspaceHorseshoe space={space}>
|
||||
<StreamHeader
|
||||
scrollRef={scrollRef}
|
||||
pinKey="channels"
|
||||
bottomPinned={
|
||||
<>
|
||||
<ChannelCreateRow space={space} />
|
||||
<WorkspaceFooter space={space} />
|
||||
</>
|
||||
}
|
||||
primaryAction={primaryAction}
|
||||
bottomPinned={<WorkspaceFooter space={space} />}
|
||||
>
|
||||
<PageNavContent scrollRef={scrollRef}>
|
||||
<ChannelsList scrollRef={scrollRef} />
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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<MobilePagerCurtainControls | null>(null);
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue