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 |
|
| 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/`. |
|
||||||
|
|
|
||||||
|
|
@ -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)}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
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