feat(room): redesign the chat overflow menu as a flat Dawn popout on the composer's dark surface tone, dropping the search row

This commit is contained in:
heaven 2026-06-05 03:16:32 +03:00
parent 0ff06e577b
commit c2f6baa712
6 changed files with 464 additions and 343 deletions

View file

@ -151,7 +151,7 @@ Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs g
| Dir | Purpose | | Dir | Purpose |
|-----|---------| |-----|---------|
| `room/` | Core room view. **RoomTimeline.tsx** (~2516 LOC), **RoomInput.tsx** (~828 LOC), **RoomViewHeader.tsx** (11-line wrapper → **RoomViewHeaderDm.tsx**, ~791 LOC — the real Dawn header for *every* room class; identity area branches 3 ways: 1:1 → peer-profile sheet, group → members sheet, callView → static; subline shows `local:server` + presence for 1:1 or `N members` for groups; phone button via `useDmCallVisible`; search/pinned/invite/leave/settings/jump-to-time live in the `…` `RoomMenu`). Also **ThreadDrawer.tsx** (~1344 LOC, full thread surface with its own composer), `ThreadSummaryCard.tsx`, `RoomView.tsx` (composer-overlay pattern), `RoomViewMembersPanel`/`MembersSidePanel`, `RoomViewProfilePanel`/`ProfileSidePanel`, `RoomViewMediaSidePanel`/`MobileMediaViewerHorseshoe`, `RoomTimelineTyping.tsx`, `EmptyTimeline.tsx`, `RoomTombstone`, `CallChatView`, `CommandAutocomplete`, `room-pin-menu/`, `jump-to-time/`, `reaction-viewer/`. `MembersDrawer.tsx` still exists but is used **only** by lobby + `members-list/`, not Room.tsx. | | `room/` | Core room view. **RoomTimeline.tsx** (~2516 LOC), **RoomInput.tsx** (~828 LOC), **RoomViewHeader.tsx** (11-line wrapper → **RoomViewHeaderDm.tsx**, ~791 LOC — the real Dawn header for *every* room class; identity area branches 3 ways: 1:1 → peer-profile sheet, group → members sheet, callView → static; subline shows `local:server` + presence for 1:1 or `N members` for groups; phone button via `useDmCallVisible`; the `…` overflow opens `room-actions/RoomActionsMenu` in an anchored folds PopOut — same chrome on desktop and mobile — restyled to a flat `ActionRow` vocabulary (`RoomActions.tsx`) on the dark-blue Vojo composer tone (folds Menu `variant="SurfaceVariant"` = #181a20); hosts mark-read/notifications/search/pinned/copy-link/settings/jump-to-time/invite/leave with the same nested popouts/overlays as upstream). Also **ThreadDrawer.tsx** (~1344 LOC, full thread surface with its own composer), `ThreadSummaryCard.tsx`, `RoomView.tsx` (composer-overlay pattern), `RoomViewMembersPanel`/`MembersSidePanel`, `RoomViewProfilePanel`/`ProfileSidePanel`, `RoomViewMediaSidePanel`/`MobileMediaViewerHorseshoe`, `RoomTimelineTyping.tsx`, `EmptyTimeline.tsx`, `RoomTombstone`, `CallChatView`, `CommandAutocomplete`, `room-pin-menu/`, `jump-to-time/`, `reaction-viewer/`. `MembersDrawer.tsx` still exists but is used **only** by lobby + `members-list/`, not Room.tsx. |
| `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? <ChannelLayout/> : <StreamLayout/>`), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `MessageEditor`, `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. | | `room/message/` | `Message.tsx` (~1506 LOC). The Stream/Channel branch is `Message.tsx:1160` (`layout === 'channel' ? <ChannelLayout/> : <StreamLayout/>`), driven by the `layout` prop from `RoomTimeline`. Hosts the edit/delete/react/report/pin/copy-link/source menu, `useDotColor` (Stream rail dot only), thread reply handler. Also `MessageEditor`, `CallMessage`, `SyslineMessage`, `Reactions`, `EncryptedContent`. |
| `room-nav/` | **Three** list-row components now: `RoomNavItem.tsx` (~434 LOC, channels + spaces lists), `DmStreamRow.tsx` (~496 LOC, the Direct-list row), `DirectInviteRow.tsx` (~282 LOC, inline accept/decline invite row in the Direct list). | | `room-nav/` | **Three** list-row components now: `RoomNavItem.tsx` (~434 LOC, channels + spaces lists), `DmStreamRow.tsx` (~496 LOC, the Direct-list row), `DirectInviteRow.tsx` (~282 LOC, inline accept/decline invite row in the Direct list). |
| `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Pairs with `pages/client/bots/`. | | `bots/` | **NEW.** Bridge-bot widget host (a bot's control room = the DM with its mxid). `catalog.ts` loads `BotPreset[]` from `config.json` `bots[]` (validates widget-origin allowlist + command prefix). `useBotRoom.ts` classifies control-room membership into a 6-state union. `BotShell` mounts a `matrix-widget-api` iframe (`BotWidgetEmbed`/`BotWidgetDriver`, tight `m.text`/`m.notice`-only capability allowlist). `botShowChatAtomFamily` toggles widget vs chat-fallback. `room.ts` = single source for portal-vs-control-room (`isBotControlRoom`). Pairs with `pages/client/bots/`. |

View file

@ -1,37 +1,25 @@
/* eslint-disable react/destructuring-assignment */ /* eslint-disable react/destructuring-assignment */
import React, { forwardRef, MouseEventHandler, useState } from 'react'; import React, { MouseEventHandler, useState } from 'react';
import { import {
Avatar, Avatar,
Badge,
Box, Box,
Icon, Icon,
IconButton, IconButton,
Icons, Icons,
Line,
Menu,
MenuItem,
PopOut, PopOut,
RectCords, RectCords,
Spinner,
Text, Text,
Tooltip, Tooltip,
TooltipProvider, TooltipProvider,
config,
toRem,
} from 'folds'; } from 'folds';
import FocusTrap from 'focus-trap-react'; import FocusTrap from 'focus-trap-react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAtom, useAtomValue, useSetAtom } from 'jotai'; import { useAtomValue } from 'jotai';
import { Room } from 'matrix-js-sdk'; import { Room } from 'matrix-js-sdk';
import { PageHeader } from '../../components/page'; import { PageHeader } from '../../components/page';
import { RoomAvatar, RoomIcon } from '../../components/room-avatar'; import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
import { UserAvatar } from '../../components/user-avatar'; import { UserAvatar } from '../../components/user-avatar';
import { UseStateProvider } from '../../components/UseStateProvider';
import { BackRouteHandler } from '../../components/BackRouteHandler'; import { BackRouteHandler } from '../../components/BackRouteHandler';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { InviteUserPrompt } from '../../components/invite-user-prompt';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { ContainerColor } from '../../styles/ContainerColor.css'; import { ContainerColor } from '../../styles/ContainerColor.css';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { useStateEvent } from '../../hooks/useStateEvent'; import { useStateEvent } from '../../hooks/useStateEvent';
@ -42,20 +30,8 @@ import { useDmCallVisible } from '../../hooks/useDmCallVisible';
import { useRoomMemberCount } from '../../hooks/useRoomMemberCount'; import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
import { Presence, useUserPresence } from '../../hooks/useUserPresence'; import { Presence, useUserPresence } from '../../hooks/useUserPresence';
import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta'; import { useRoomAvatar, useRoomName } from '../../hooks/useRoomMeta';
import { useRoomNavigate } from '../../hooks/useRoomNavigate';
import { useRoomUnread } from '../../state/hooks/unread';
import { useRoomPinnedEvents } from '../../hooks/useRoomPinnedEvents';
import { usePowerLevelsContext } from '../../hooks/usePowerLevels';
import { useRoomCreators } from '../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../hooks/useRoomPermissions';
import { useSetting } from '../../state/hooks/settings';
import { useSpaceOptionally } from '../../hooks/useSpace'; import { useSpaceOptionally } from '../../hooks/useSpace';
import { useOpenRoomSettings } from '../../state/hooks/roomSettings'; import { useOpenRoomSettings } from '../../state/hooks/roomSettings';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { useLivekitSupport } from '../../hooks/useLivekitSupport';
import { useCallMembers, useCallSession } from '../../hooks/useCall'; import { useCallMembers, useCallSession } from '../../hooks/useCall';
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall'; import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
@ -65,327 +41,22 @@ import {
useOpenRoomMembersSheet, useOpenRoomMembersSheet,
useRoomMembersSheetState, useRoomMembersSheetState,
} from '../../state/hooks/roomMembersSheet'; } from '../../state/hooks/roomMembersSheet';
import { settingsAtom } from '../../state/settings';
import { callEmbedAtom } from '../../state/callEmbed'; import { callEmbedAtom } from '../../state/callEmbed';
import { searchModalAtom } from '../../state/searchModal';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import { RoomSettingsPage } from '../../state/roomSettings'; import { RoomSettingsPage } from '../../state/roomSettings';
import { StateEvent } from '../../../types/matrix/room'; import { StateEvent } from '../../../types/matrix/room';
import { import {
getCanonicalAliasOrRoomId,
getMxIdLocalPart, getMxIdLocalPart,
getMxIdServer, getMxIdServer,
guessDmRoomUserId, guessDmRoomUserId,
isRoomAlias,
mxcUrlToHttp, mxcUrlToHttp,
} from '../../utils/matrix'; } from '../../utils/matrix';
import { copyToClipboard } from '../../utils/dom';
import { stopPropagation } from '../../utils/keyboard'; import { stopPropagation } from '../../utils/keyboard';
import { markAsRead } from '../../utils/notifications';
import { getMatrixToRoom } from '../../plugins/matrix-to';
import { getViaServers } from '../../plugins/via-servers';
import { useBotPresets } from '../bots/catalog'; import { useBotPresets } from '../bots/catalog';
import { findBotPresetForRoom, isCatalogBotControlRoom } from '../bots/room'; import { isCatalogBotControlRoom } from '../bots/room';
import { botFailedAtomFamily, botShowChatAtomFamily } from '../bots/botExperienceState';
import { getBotPath } from '../../pages/pathUtils';
import { JumpToTime } from './jump-to-time';
import { RoomPinMenu } from './room-pin-menu'; import { RoomPinMenu } from './room-pin-menu';
import { RoomActionsMenu } from './room-actions';
import * as css from './RoomViewHeaderDm.css'; import * as css from './RoomViewHeaderDm.css';
// Single bot-aware menu item rendered at the top of RoomMenu when the
// current room is a Vojo bot control DM. Reads `botFailedAtomFamily` to
// label correctly («Retry widget» when a prior load failed, «Show widget»
// otherwise) and clears both atoms on click.
//
// IMPORTANT: this menu surfaces in BOTH `/bots/:botId` (chat-fallback) and
// `/direct/:roomId` (regular DM that happens to be a bot's control room).
// In the second case clearing the atoms alone is invisible — the user is
// not on the route that observes them. We navigate to `/bots/:botId` so
// the BotShell actually mounts.
function BotShowWidgetMenuItem({
roomId,
botId,
requestClose,
}: {
roomId: string;
botId: string;
requestClose: () => void;
}) {
const { t } = useTranslation();
const navigate = useNavigate();
const setShowChat = useSetAtom(botShowChatAtomFamily(roomId));
const [failed, setFailed] = useAtom(botFailedAtomFamily(roomId));
const handleClick = () => {
setFailed(false);
setShowChat(false);
navigate(getBotPath(botId));
requestClose();
};
return (
<MenuItem
onClick={handleClick}
size="300"
after={<Icon size="100" src={Icons.Terminal} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')}
</Text>
</MenuItem>
);
}
type RoomMenuProps = {
room: Room;
callView?: boolean;
// When true the room is a Vojo bot control DM rendered in chat-fallback
// mode. The menu prepends a «Show widget / Retry widget» item so the
// user can return to BotShell without hunting for an overlay button.
// Other items stay standard — bots in chat-fallback should look like
// normal rooms beyond that one affordance.
botControlRoom?: boolean;
onSearch: () => void;
onPin: (cords: RectCords) => void;
requestClose: () => void;
};
const RoomMenu = forwardRef<HTMLDivElement, RoomMenuProps>(
({ room, callView, botControlRoom, onSearch, onPin, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const { navigateRoom } = useRoomNavigate();
const pinnedEvents = useRoomPinnedEvents(room);
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
// Look up the matching bot preset only when this menu IS the bot
// variant. The lookup walks the preset list once; cheap. Returns
// undefined for non-bot rooms — we won't render the menu item.
const bots = useBotPresets();
const botPreset = botControlRoom ? findBotPresetForRoom(mx, room, bots) : undefined;
const [invitePrompt, setInvitePrompt] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleSearch = () => {
onSearch();
requestClose();
};
const handleOpenPinned: MouseEventHandler<HTMLButtonElement> = (evt) => {
onPin(evt.currentTarget.getBoundingClientRect());
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
const handleInvite = () => {
setInvitePrompt(true);
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(220), width: '100vw' }}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
{botControlRoom && botPreset && (
<>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<BotShowWidgetMenuItem
roomId={room.roomId}
botId={botPreset.id}
requestClose={requestClose}
/>
</Box>
<Line variant="Surface" size="300" />
</>
)}
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={handleSearch}
size="300"
after={<Icon size="100" src={Icons.Search} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.search')}
</Text>
</MenuItem>
<MenuItem
onClick={handleOpenPinned}
size="300"
after={
<Box gap="100" alignItems="Center">
{pinnedEvents.length > 0 && (
<Badge variant="Secondary" size="400" fill="Solid" radii="Pill">
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<Icon size="100" src={Icons.Pin} />
</Box>
}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.pinned_messages')}
</Text>
</MenuItem>
{canInvite && !botControlRoom && (
<MenuItem
onClick={handleInvite}
variant="Primary"
fill="None"
size="300"
after={<Icon size="100" src={Icons.UserPlus} />}
radii="300"
aria-pressed={invitePrompt}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.invite')}
</Text>
</MenuItem>
)}
<MenuItem
onClick={handleCopyLink}
size="300"
after={<Icon size="100" src={Icons.Link} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.copy_link')}
</Text>
</MenuItem>
<MenuItem
onClick={handleOpenSettings}
size="300"
after={<Icon size="100" src={Icons.Setting} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.room_settings')}
</Text>
</MenuItem>
<UseStateProvider initial={false}>
{(promptJump, setPromptJump) => (
<>
<MenuItem
onClick={() => setPromptJump(true)}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
aria-pressed={promptJump}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.jump_to_time')}
</Text>
</MenuItem>
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
disabled={callView}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);
function DmCallButton({ room }: { room: Room }) { function DmCallButton({ room }: { room: Room }) {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
@ -502,7 +173,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
const [menuAnchor, setMenuAnchor] = useState<RectCords>(); const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>(); const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
const setSearchOpen = useSetAtom(searchModalAtom);
const openUserRoomProfile = useOpenUserRoomProfile(); const openUserRoomProfile = useOpenUserRoomProfile();
const openRoomMembersSheet = useOpenRoomMembersSheet(); const openRoomMembersSheet = useOpenRoomMembersSheet();
const closeRoomMembersSheet = useCloseRoomMembersSheet(); const closeRoomMembersSheet = useCloseRoomMembersSheet();
@ -511,15 +181,11 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
const parentSpace = useSpaceOptionally(); const parentSpace = useSpaceOptionally();
const openSettings = useOpenRoomSettings(); const openSettings = useOpenRoomSettings();
// The ⋮ overflow opens the RoomActionsMenu in an anchored PopOut — same
// chrome on desktop and mobile.
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => { const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect()); setMenuAnchor(evt.currentTarget.getBoundingClientRect());
}; };
const handleSearch = () => {
// P4 routes in-room search through the global cmd+K modal — there is no
// /direct/{roomId}/search/ page yet (see desired_features.md §26). The
// modal lets the user pick the target room manually.
setSearchOpen(true);
};
const handlePeerProfile: MouseEventHandler<HTMLButtonElement> = (evt) => { const handlePeerProfile: MouseEventHandler<HTMLButtonElement> = (evt) => {
if (!peerUserId) return; if (!peerUserId) return;
openUserRoomProfile( openUserRoomProfile(
@ -752,11 +418,10 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
escapeDeactivates: stopPropagation, escapeDeactivates: stopPropagation,
}} }}
> >
<RoomMenu <RoomActionsMenu
room={room} room={room}
callView={callView} callView={callView}
botControlRoom={isBotControlRoom} botControlRoom={isBotControlRoom}
onSearch={handleSearch}
onPin={(cords) => setPinMenuAnchor(cords)} onPin={(cords) => setPinMenuAnchor(cords)}
requestClose={() => setMenuAnchor(undefined)} requestClose={() => setMenuAnchor(undefined)}
/> />

View file

@ -0,0 +1,99 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// ===========================================================================
// Room overflow (⋮) menu — the anchored folds PopOut on both desktop and
// mobile. Flat transparent rows on the popout's deep Vojo input-field tone
// (Surface.Container #0d0e11 — the chat composer fill, set via the Menu's
// `variant="Surface"` in RoomActionsMenu), separated by single 1px hairlines.
// Single accent: violet on Invite, brick on Leave, green on the e2ee chip.
// ===========================================================================
// Subtle Fleet hairline between row groups, inset from the edges.
export const SectionLine = style({
height: toRem(1),
flexShrink: 0,
backgroundColor: color.Surface.ContainerLine,
margin: `${toRem(4)} ${config.space.S200}`,
});
// The flat action row. Reset <button>; hover/active tones lift off the
// #181a20 popout surface; accent colour (Invite/Leave) is applied inline.
export const ActionRow = style({
width: '100%',
minHeight: toRem(40),
display: 'flex',
alignItems: 'center',
gap: config.space.S300,
padding: `0 ${config.space.S300}`,
borderRadius: toRem(8),
background: 'transparent',
border: 'none',
textAlign: 'left',
font: 'inherit',
color: color.Surface.OnContainer,
cursor: 'pointer',
selectors: {
'&:hover:not(:disabled)': { backgroundColor: color.Surface.ContainerHover },
'&:active:not(:disabled)': { backgroundColor: color.Surface.ContainerActive },
'&:disabled': {
cursor: 'default',
opacity: config.opacity.P300,
},
'&:focus-visible': {
outline: `${toRem(2)} solid ${color.Primary.Main}`,
outlineOffset: toRem(-2),
},
},
});
// Fixed leading-icon slot — keeps every label hard-aligned. `currentColor`,
// so accent rows tint the icon through the row's inline colour.
export const ActionRowIcon = style({
flexShrink: 0,
width: toRem(20),
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
});
export const ActionRowLabel = style({
flexGrow: 1,
minWidth: 0,
overflow: 'hidden',
whiteSpace: 'nowrap',
textOverflow: 'ellipsis',
});
// Trailing cluster (mode word / badge / chevron).
export const ActionRowTrailing = style({
flexShrink: 0,
display: 'inline-flex',
alignItems: 'center',
gap: config.space.S100,
});
export const ActionRowTrailingText = style({
color: color.Surface.OnContainer,
opacity: config.opacity.P400,
});
export const ActionRowChevron = style({
color: color.Surface.OnContainer,
opacity: config.opacity.P300,
});
// Popout container: width bump from the legacy 220px so the Notifications
// mode word + Pinned badge breathe. Background/border/shadow come from the
// folds Menu `variant="Surface"` in RoomActionsMenu.
export const PopoutMenu = style({
width: toRem(280),
maxWidth: '100vw',
});
export const PopoutGroup = style({
display: 'flex',
flexDirection: 'column',
padding: config.space.S100,
gap: toRem(1),
});

View file

@ -0,0 +1,128 @@
import React, { MouseEventHandler, ReactNode } from 'react';
import { Icon, Icons, IconSrc, Text, color } from 'folds';
import { useTranslation } from 'react-i18next';
import { useNavigate } from 'react-router-dom';
import { useAtom, useSetAtom } from 'jotai';
import { RoomNotificationMode } from '../../../hooks/useRoomsNotificationPreferences';
import { botFailedAtomFamily, botShowChatAtomFamily } from '../../bots/botExperienceState';
import { getBotPath } from '../../../pages/pathUtils';
import * as css from './RoomActions.css';
// Shared row vocabulary for the room overflow (⋮) menu. One chrome only — the
// anchored folds PopOut — used on both desktop and mobile (the old native
// bottom sheet was removed). Rows are flat on the popout's dark-blue Vojo
// surface (SurfaceVariant.Container, the composer tone) with single-accent
// discipline: violet on Invite, brick on Leave, green on the e2ee chip.
type ActionRowProps = {
icon: IconSrc;
iconFilled?: boolean;
label: string;
onClick?: MouseEventHandler<HTMLButtonElement>;
trailing?: ReactNode;
accent?: 'primary' | 'critical';
disabled?: boolean;
ariaPressed?: boolean;
ariaHasPopup?: boolean;
};
// The one flat row: a reset <button> with a fixed-width leading icon slot, an
// ellipsising label, and an optional trailing cluster (mode word / badge /
// chevron). Accent tints both the icon (via currentColor) and the label.
export function ActionRow({
icon,
iconFilled,
label,
onClick,
trailing,
accent,
disabled,
ariaPressed,
ariaHasPopup,
}: ActionRowProps) {
let accentColor: string | undefined;
if (accent === 'primary') accentColor = color.Primary.Main;
else if (accent === 'critical') accentColor = color.Critical.Main;
return (
<button
type="button"
className={css.ActionRow}
onClick={onClick}
disabled={disabled}
aria-pressed={ariaPressed}
aria-haspopup={ariaHasPopup}
style={accentColor ? { color: accentColor } : undefined}
>
<span className={css.ActionRowIcon}>
<Icon size="100" src={icon} filled={iconFilled} />
</span>
<Text as="span" size="T300" className={css.ActionRowLabel}>
{label}
</Text>
{trailing && <span className={css.ActionRowTrailing}>{trailing}</span>}
</button>
);
}
// Chevron for rows that open a sub-surface (notifications, pinned, jump, invite).
export function RowChevron() {
return <Icon className={css.ActionRowChevron} size="100" src={Icons.ChevronRight} />;
}
// Muted trailing word (e.g. the current notification mode beside the row).
export function RowTrailingText({ children }: { children: ReactNode }) {
return (
<Text as="span" size="T200" className={css.ActionRowTrailingText}>
{children}
</Text>
);
}
// Subtle Fleet hairline between row groups.
export function ActionSectionLine() {
return <div aria-hidden className={css.SectionLine} />;
}
type BotWidgetActionRowProps = {
roomId: string;
botId: string;
requestClose: () => void;
};
// Bot-control DM only: jump back to the BotShell widget. Navigates to
// /bots/:botId so the shell mounts even from a plain /direct route that
// happens to be the bot's control room.
export function BotWidgetActionRow({ roomId, botId, requestClose }: BotWidgetActionRowProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const setShowChat = useSetAtom(botShowChatAtomFamily(roomId));
const [failed, setFailed] = useAtom(botFailedAtomFamily(roomId));
const handleClick = () => {
setFailed(false);
setShowChat(false);
navigate(getBotPath(botId));
requestClose();
};
return (
<ActionRow
icon={Icons.Terminal}
label={t(failed ? 'Bots.retry_widget' : 'Bots.show_widget')}
onClick={handleClick}
trailing={<RowChevron />}
/>
);
}
// Labels the Notifications row's current mode.
export function useNotificationModeLabel(mode: RoomNotificationMode): string {
const { t } = useTranslation();
const labels: Record<RoomNotificationMode, string> = {
[RoomNotificationMode.Unset]: t('Inbox.notif_default'),
[RoomNotificationMode.AllMessages]: t('Inbox.notif_all_messages'),
[RoomNotificationMode.SpecialMessages]: t('Inbox.notif_mentions_keywords'),
[RoomNotificationMode.Mute]: t('Inbox.notif_mute'),
};
return labels[mode];
}

View file

@ -0,0 +1,227 @@
import React, { MouseEventHandler, forwardRef, useState } from 'react';
import { Badge, Icons, Menu, RectCords, Spinner, Text } from 'folds';
import { useTranslation } from 'react-i18next';
import { Room } from 'matrix-js-sdk';
import { useMatrixClient } from '../../../hooks/useMatrixClient';
import { useSetting } from '../../../state/hooks/settings';
import { settingsAtom } from '../../../state/settings';
import { useRoomUnread } from '../../../state/hooks/unread';
import { roomToUnreadAtom } from '../../../state/room/roomToUnread';
import { usePowerLevelsContext } from '../../../hooks/usePowerLevels';
import { useRoomCreators } from '../../../hooks/useRoomCreators';
import { useRoomPermissions } from '../../../hooks/useRoomPermissions';
import { useRoomPinnedEvents } from '../../../hooks/useRoomPinnedEvents';
import { useRoomNavigate } from '../../../hooks/useRoomNavigate';
import { useSpaceOptionally } from '../../../hooks/useSpace';
import { useOpenRoomSettings } from '../../../state/hooks/roomSettings';
import {
getRoomNotificationMode,
useRoomsNotificationPreferencesContext,
} from '../../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../../components/RoomNotificationSwitcher';
import { useBotPresets } from '../../bots/catalog';
import { findBotPresetForRoom } from '../../bots/room';
import { LeaveRoomPrompt } from '../../../components/leave-room-prompt';
import { InviteUserPrompt } from '../../../components/invite-user-prompt';
import { getCanonicalAliasOrRoomId, isRoomAlias } from '../../../utils/matrix';
import { getMatrixToRoom } from '../../../plugins/matrix-to';
import { getViaServers } from '../../../plugins/via-servers';
import { copyToClipboard } from '../../../utils/dom';
import { markAsRead } from '../../../utils/notifications';
import { JumpToTime } from '../jump-to-time';
import {
ActionRow,
ActionSectionLine,
BotWidgetActionRow,
RowChevron,
RowTrailingText,
useNotificationModeLabel,
} from './RoomActions';
import * as css from './RoomActions.css';
type RoomActionsMenuProps = {
room: Room;
callView?: boolean;
botControlRoom?: boolean;
onPin: (cords: RectCords) => void;
requestClose: () => void;
};
// Content of the room overflow (⋮) popout, on both desktop and mobile. The
// folds Menu `variant="Surface"` paints the deep Vojo input-field tone
// (#0d0e11 = Surface.Container — the exact fill of the chat message composer,
// see RoomView.css.ts). Nested sub-flows are the same as upstream:
// Notifications → the anchored RoomNotificationModeSwitcher PopOut; Pinned →
// the RoomPinMenu PopOut via onPin(cords); Invite/Jump/Leave → centered overlays.
export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
({ room, callView, botControlRoom, onPin, requestClose }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const powerLevels = usePowerLevelsContext();
const creators = useRoomCreators(room);
const permissions = useRoomPermissions(creators, powerLevels);
const canInvite = permissions.action('invite', mx.getSafeUserId());
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const notificationModeLabel = useNotificationModeLabel(notificationMode);
const pinnedEvents = useRoomPinnedEvents(room);
const { navigateRoom } = useRoomNavigate();
const openSettings = useOpenRoomSettings();
const parentSpace = useSpaceOptionally();
const bots = useBotPresets();
const botPreset = botControlRoom ? findBotPresetForRoom(mx, room, bots) : undefined;
const [invitePrompt, setInvitePrompt] = useState(false);
const [promptJump, setPromptJump] = useState(false);
const [promptLeave, setPromptLeave] = useState(false);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
const handleOpenPinned: MouseEventHandler<HTMLButtonElement> = (evt) => {
onPin(evt.currentTarget.getBoundingClientRect());
requestClose();
};
const handleCopyLink = () => {
const roomIdOrAlias = getCanonicalAliasOrRoomId(mx, room.roomId);
const viaServers = isRoomAlias(roomIdOrAlias) ? undefined : getViaServers(room);
copyToClipboard(getMatrixToRoom(roomIdOrAlias, viaServers));
requestClose();
};
const handleOpenSettings = () => {
openSettings(room.roomId, parentSpace?.roomId);
requestClose();
};
return (
<Menu ref={ref} variant="Surface" className={css.PopoutMenu}>
{invitePrompt && (
<InviteUserPrompt
room={room}
requestClose={() => {
setInvitePrompt(false);
requestClose();
}}
/>
)}
{promptJump && (
<JumpToTime
onSubmit={(eventId) => {
setPromptJump(false);
navigateRoom(room.roomId, eventId);
requestClose();
}}
onCancel={() => setPromptJump(false)}
/>
)}
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
<div className={css.PopoutGroup}>
{botControlRoom && botPreset && (
<BotWidgetActionRow
roomId={room.roomId}
botId={botPreset.id}
requestClose={requestClose}
/>
)}
<ActionRow
icon={Icons.CheckTwice}
label={t('Room.mark_as_read')}
onClick={handleMarkAsRead}
disabled={!unread}
/>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<ActionRow
icon={Icons.Bell}
label={t('Room.notifications')}
onClick={handleOpen}
ariaPressed={opened}
ariaHasPopup
trailing={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<>
<RowTrailingText>{notificationModeLabel}</RowTrailingText>
<RowChevron />
</>
)
}
/>
)}
</RoomNotificationModeSwitcher>
<ActionRow
icon={Icons.Pin}
label={t('Room.pinned_messages')}
onClick={handleOpenPinned}
ariaHasPopup
trailing={
<>
{pinnedEvents.length > 0 && (
<Badge variant="Secondary" size="400" fill="Solid" radii="Pill">
<Text as="span" size="L400">
{pinnedEvents.length}
</Text>
</Badge>
)}
<RowChevron />
</>
}
/>
<ActionRow icon={Icons.Link} label={t('Room.copy_link')} onClick={handleCopyLink} />
<ActionRow
icon={Icons.RecentClock}
label={t('Room.jump_to_time')}
onClick={() => setPromptJump(true)}
ariaPressed={promptJump}
ariaHasPopup
trailing={<RowChevron />}
/>
<ActionRow
icon={Icons.Setting}
label={t('Room.room_settings')}
onClick={handleOpenSettings}
/>
</div>
{canInvite && !botControlRoom && (
<>
<ActionSectionLine />
<div className={css.PopoutGroup}>
<ActionRow
icon={Icons.UserPlus}
label={t('Room.invite')}
onClick={() => setInvitePrompt(true)}
accent="primary"
ariaPressed={invitePrompt}
/>
</div>
</>
)}
<ActionSectionLine />
<div className={css.PopoutGroup}>
<ActionRow
icon={Icons.ArrowGoLeft}
label={t('Room.leave_room')}
onClick={() => setPromptLeave(true)}
accent="critical"
disabled={callView}
ariaPressed={promptLeave}
/>
</div>
</Menu>
);
}
);

View file

@ -0,0 +1,2 @@
export * from './RoomActions';
export * from './RoomActionsMenu';