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
240bb54c29
commit
6ca6b69d48
8 changed files with 124 additions and 101 deletions
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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 { 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} />
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue