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:
parent
0ff06e577b
commit
c2f6baa712
6 changed files with 464 additions and 343 deletions
|
|
@ -151,7 +151,7 @@ Use **`useIsOneOnOne()`** from `hooks/useRoom.ts` whenever you need the 1:1 vs g
|
|||
|
||||
| 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-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/`. |
|
||||
|
|
|
|||
|
|
@ -1,37 +1,25 @@
|
|||
/* eslint-disable react/destructuring-assignment */
|
||||
import React, { forwardRef, MouseEventHandler, useState } from 'react';
|
||||
import React, { MouseEventHandler, useState } from 'react';
|
||||
import {
|
||||
Avatar,
|
||||
Badge,
|
||||
Box,
|
||||
Icon,
|
||||
IconButton,
|
||||
Icons,
|
||||
Line,
|
||||
Menu,
|
||||
MenuItem,
|
||||
PopOut,
|
||||
RectCords,
|
||||
Spinner,
|
||||
Text,
|
||||
Tooltip,
|
||||
TooltipProvider,
|
||||
config,
|
||||
toRem,
|
||||
} from 'folds';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtom, useAtomValue, useSetAtom } from 'jotai';
|
||||
import { useAtomValue } from 'jotai';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { PageHeader } from '../../components/page';
|
||||
import { RoomAvatar, RoomIcon } from '../../components/room-avatar';
|
||||
import { UserAvatar } from '../../components/user-avatar';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
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 { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
|
||||
import { useStateEvent } from '../../hooks/useStateEvent';
|
||||
|
|
@ -42,20 +30,8 @@ import { useDmCallVisible } from '../../hooks/useDmCallVisible';
|
|||
import { useRoomMemberCount } from '../../hooks/useRoomMemberCount';
|
||||
import { Presence, useUserPresence } from '../../hooks/useUserPresence';
|
||||
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 { useOpenRoomSettings } from '../../state/hooks/roomSettings';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
getRoomNotificationModeIcon,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { useLivekitSupport } from '../../hooks/useLivekitSupport';
|
||||
import { useCallMembers, useCallSession } from '../../hooks/useCall';
|
||||
import { useSwitchOrStartDmCall } from '../../hooks/useSwitchOrStartDmCall';
|
||||
|
|
@ -65,327 +41,22 @@ import {
|
|||
useOpenRoomMembersSheet,
|
||||
useRoomMembersSheetState,
|
||||
} from '../../state/hooks/roomMembersSheet';
|
||||
import { settingsAtom } from '../../state/settings';
|
||||
import { callEmbedAtom } from '../../state/callEmbed';
|
||||
import { searchModalAtom } from '../../state/searchModal';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import { RoomSettingsPage } from '../../state/roomSettings';
|
||||
import { StateEvent } from '../../../types/matrix/room';
|
||||
import {
|
||||
getCanonicalAliasOrRoomId,
|
||||
getMxIdLocalPart,
|
||||
getMxIdServer,
|
||||
guessDmRoomUserId,
|
||||
isRoomAlias,
|
||||
mxcUrlToHttp,
|
||||
} from '../../utils/matrix';
|
||||
import { copyToClipboard } from '../../utils/dom';
|
||||
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 { findBotPresetForRoom, isCatalogBotControlRoom } from '../bots/room';
|
||||
import { botFailedAtomFamily, botShowChatAtomFamily } from '../bots/botExperienceState';
|
||||
import { getBotPath } from '../../pages/pathUtils';
|
||||
import { JumpToTime } from './jump-to-time';
|
||||
import { isCatalogBotControlRoom } from '../bots/room';
|
||||
import { RoomPinMenu } from './room-pin-menu';
|
||||
import { RoomActionsMenu } from './room-actions';
|
||||
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 }) {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
|
|
@ -502,7 +173,6 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
|
||||
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
|
||||
const [pinMenuAnchor, setPinMenuAnchor] = useState<RectCords>();
|
||||
const setSearchOpen = useSetAtom(searchModalAtom);
|
||||
const openUserRoomProfile = useOpenUserRoomProfile();
|
||||
const openRoomMembersSheet = useOpenRoomMembersSheet();
|
||||
const closeRoomMembersSheet = useCloseRoomMembersSheet();
|
||||
|
|
@ -511,15 +181,11 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
const parentSpace = useSpaceOptionally();
|
||||
const openSettings = useOpenRoomSettings();
|
||||
|
||||
// The ⋮ overflow opens the RoomActionsMenu in an anchored PopOut — same
|
||||
// chrome on desktop and mobile.
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
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) => {
|
||||
if (!peerUserId) return;
|
||||
openUserRoomProfile(
|
||||
|
|
@ -752,11 +418,10 @@ export function RoomViewHeaderDm({ callView }: { callView?: boolean }) {
|
|||
escapeDeactivates: stopPropagation,
|
||||
}}
|
||||
>
|
||||
<RoomMenu
|
||||
<RoomActionsMenu
|
||||
room={room}
|
||||
callView={callView}
|
||||
botControlRoom={isBotControlRoom}
|
||||
onSearch={handleSearch}
|
||||
onPin={(cords) => setPinMenuAnchor(cords)}
|
||||
requestClose={() => setMenuAnchor(undefined)}
|
||||
/>
|
||||
|
|
|
|||
99
src/app/features/room/room-actions/RoomActions.css.ts
Normal file
99
src/app/features/room/room-actions/RoomActions.css.ts
Normal 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),
|
||||
});
|
||||
128
src/app/features/room/room-actions/RoomActions.tsx
Normal file
128
src/app/features/room/room-actions/RoomActions.tsx
Normal 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];
|
||||
}
|
||||
227
src/app/features/room/room-actions/RoomActionsMenu.tsx
Normal file
227
src/app/features/room/room-actions/RoomActionsMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
2
src/app/features/room/room-actions/index.ts
Normal file
2
src/app/features/room/room-actions/index.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
export * from './RoomActions';
|
||||
export * from './RoomActionsMenu';
|
||||
Loading…
Add table
Reference in a new issue