refactor(room): rebuild the room and bot action menus onto a shared inline notifications mode-picker tray
This commit is contained in:
parent
0956e58cfd
commit
f2ce9d4cd9
6 changed files with 273 additions and 162 deletions
|
|
@ -1,5 +1,5 @@
|
|||
import { Box, config, Icon, Menu, MenuItem, PopOut, RectCords, Text } from 'folds';
|
||||
import React, { MouseEventHandler, ReactNode, useMemo, useState } from 'react';
|
||||
import React, { MouseEventHandler, ReactNode, useMemo, useRef, useState } from 'react';
|
||||
import FocusTrap from 'focus-trap-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { stopPropagation } from '../utils/keyboard';
|
||||
|
|
@ -55,22 +55,37 @@ export function RoomNotificationModeSwitcher({
|
|||
const changing = modeState.status === AsyncStatus.Loading;
|
||||
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
// The trigger element of the currently-open popout. Lets the FocusTrap
|
||||
// treat clicks on the trigger as "ours" instead of outside-clicks.
|
||||
const anchorElRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const open = !!menuCords;
|
||||
const handleToggleMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
// Second click on the trigger CLOSES the popout instead of re-anchoring (reopening) it. `open`
|
||||
// is the pre-click render value: even though focus-trap's `clickOutsideDeactivates` also fires
|
||||
// `onDeactivate` for this same click, both paths resolve to "close" — so there's no reopen race
|
||||
// regardless of listener order.
|
||||
// Second click on the trigger CLOSES the popout instead of re-anchoring
|
||||
// (reopening) it. The naive version was broken: focus-trap's
|
||||
// `clickOutsideDeactivates` fires on MOUSEDOWN, flushing `open=false`
|
||||
// and re-rendering before the trigger's CLICK handler ran — so the
|
||||
// handler's closure always saw the closed state and re-opened. The
|
||||
// `isAnchorTarget` guards below exclude the trigger from the trap's
|
||||
// outside-click handling entirely, so this handler is the single owner
|
||||
// of the trigger interaction and `open` really is the pre-click value.
|
||||
if (open) {
|
||||
setMenuCords(undefined);
|
||||
anchorElRef.current = null;
|
||||
} else {
|
||||
anchorElRef.current = evt.currentTarget;
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setMenuCords(undefined);
|
||||
anchorElRef.current = null;
|
||||
};
|
||||
|
||||
const isAnchorTarget = (evt: MouseEvent | TouchEvent): boolean => {
|
||||
const anchor = anchorElRef.current;
|
||||
return !!anchor && evt.target instanceof Node && anchor.contains(evt.target);
|
||||
};
|
||||
|
||||
const handleSelect = (mode: RoomNotificationMode) => {
|
||||
|
|
@ -90,7 +105,12 @@ export function RoomNotificationModeSwitcher({
|
|||
focusTrapOptions={{
|
||||
initialFocus: false,
|
||||
onDeactivate: handleClose,
|
||||
clickOutsideDeactivates: true,
|
||||
// Trigger clicks are NOT outside-clicks: deactivating on the
|
||||
// trigger's mousedown closed the menu before the click could
|
||||
// toggle, and the toggle re-opened it (the reopen bug). The
|
||||
// trigger's own click handler owns open/close instead.
|
||||
clickOutsideDeactivates: (evt) => !isAnchorTarget(evt),
|
||||
allowOutsideClick: (evt) => isAnchorTarget(evt),
|
||||
isKeyForward: (evt: KeyboardEvent) =>
|
||||
evt.key === 'ArrowDown' || evt.key === 'ArrowRight',
|
||||
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp' || evt.key === 'ArrowLeft',
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import React, { forwardRef, useState } from 'react';
|
||||
import { Icons, Menu, Spinner } from 'folds';
|
||||
import { Icons, Menu } from 'folds';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
// Reuse the EXACT row vocabulary + popout chrome of the 1:1 chat's ⋮ menu (RoomActionsMenu) so the
|
||||
// AI surface's overflow menu is the same popup, styled identically — only the action set differs.
|
||||
import { ActionRow, ActionSectionLine, RowChevron } from '../room/room-actions/RoomActions';
|
||||
import {
|
||||
ActionRow,
|
||||
ActionSectionLine,
|
||||
NotificationsActionRow,
|
||||
RowChevron,
|
||||
} from '../room/room-actions/RoomActions';
|
||||
import * as css from '../room/room-actions/RoomActions.css';
|
||||
|
||||
type AiChatMenuProps = {
|
||||
|
|
@ -28,9 +28,6 @@ type AiChatMenuProps = {
|
|||
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||
|
||||
const [promptLeave, setPromptLeave] = useState(false);
|
||||
|
||||
return (
|
||||
|
|
@ -62,18 +59,7 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
|||
ariaHasPopup
|
||||
trailing={<RowChevron />}
|
||||
/>
|
||||
<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" /> : <RowChevron />}
|
||||
/>
|
||||
)}
|
||||
</RoomNotificationModeSwitcher>
|
||||
<NotificationsActionRow roomId={room.roomId} />
|
||||
<ActionRow
|
||||
icon={Icons.ShieldLock}
|
||||
label={t('Bots.privacy.menu')}
|
||||
|
|
@ -93,7 +79,7 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
|||
label={t('Room.leave_room')}
|
||||
onClick={() => setPromptLeave(true)}
|
||||
accent="critical"
|
||||
ariaPressed={promptLeave}
|
||||
ariaExpanded={promptLeave}
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { forwardRef } from 'react';
|
||||
import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds';
|
||||
import React, { forwardRef, useState } from 'react';
|
||||
import { Icons, Menu } from 'folds';
|
||||
import { useSetAtom } from 'jotai';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
|
@ -8,27 +8,30 @@ import { useSetting } from '../../state/hooks/settings';
|
|||
import { settingsAtom } from '../../state/settings';
|
||||
import { useRoomUnread } from '../../state/hooks/unread';
|
||||
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
|
||||
import {
|
||||
getRoomNotificationMode,
|
||||
getRoomNotificationModeIcon,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
} from '../../hooks/useRoomsNotificationPreferences';
|
||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
||||
import { markAsRead } from '../../utils/notifications';
|
||||
import { botShowChatAtomFamily } from './botExperienceState';
|
||||
// Same row vocabulary + popout chrome as the 1:1 chat's ⋮ menu
|
||||
// (RoomActionsMenu) and the AI chat's AiChatMenu — one popup style across
|
||||
// every surface, only the action set differs.
|
||||
import {
|
||||
ActionRow,
|
||||
ActionSectionLine,
|
||||
NotificationsActionRow,
|
||||
RowChevron,
|
||||
} from '../room/room-actions/RoomActions';
|
||||
import * as css from '../room/room-actions/RoomActions.css';
|
||||
|
||||
type BotShellMenuProps = {
|
||||
room: Room;
|
||||
requestClose: () => void;
|
||||
};
|
||||
|
||||
// Slim dropdown for the bot hero's «Настроить» button. Only items that make
|
||||
// sense in a 1:1 with a bridge bot:
|
||||
// The bridge-bot widget hero's ⋮ menu. Only items that make sense in a 1:1
|
||||
// with a bridge bot:
|
||||
// - Show chat (chat-fallback toggle, switches BotExperienceHost branch)
|
||||
// - Mark as read
|
||||
// - Notifications (shared switcher)
|
||||
// - Notifications (inline mode picker)
|
||||
// - Leave room
|
||||
// Search / Pinned / Invite / Copy-link / Room-settings / Jump-to-date are
|
||||
// dropped — none apply to a bridge bot's control DM.
|
||||
|
|
@ -38,12 +41,12 @@ export const BotShellMenu = forwardRef<HTMLDivElement, BotShellMenuProps>(
|
|||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||
// Setter-only — the menu's only job around showChat is to flip it.
|
||||
// useSetAtom skips the value-subscription that useAtom registers.
|
||||
const setShowChat = useSetAtom(botShowChatAtomFamily(room.roomId));
|
||||
|
||||
const [promptLeave, setPromptLeave] = useState(false);
|
||||
|
||||
const handleShowChat = () => {
|
||||
setShowChat(true);
|
||||
requestClose();
|
||||
|
|
@ -54,80 +57,41 @@ export const BotShellMenu = forwardRef<HTMLDivElement, BotShellMenuProps>(
|
|||
};
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(240), width: '100vw' }}>
|
||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
||||
<MenuItem
|
||||
<Menu ref={ref} variant="Surface" className={css.PopoutMenu}>
|
||||
{promptLeave && (
|
||||
<LeaveRoomPrompt
|
||||
roomId={room.roomId}
|
||||
onDone={requestClose}
|
||||
onCancel={() => setPromptLeave(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={css.PopoutGroup}>
|
||||
<ActionRow
|
||||
icon={Icons.Message}
|
||||
label={t('Bots.show_chat')}
|
||||
onClick={handleShowChat}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Message} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Bots.show_chat')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
trailing={<RowChevron />}
|
||||
/>
|
||||
<ActionRow
|
||||
icon={Icons.CheckTwice}
|
||||
label={t('Room.mark_as_read')}
|
||||
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 }}>
|
||||
<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}
|
||||
>
|
||||
<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>
|
||||
/>
|
||||
<NotificationsActionRow roomId={room.roomId} />
|
||||
</div>
|
||||
|
||||
<ActionSectionLine />
|
||||
<div className={css.PopoutGroup}>
|
||||
<ActionRow
|
||||
icon={Icons.ArrowGoLeft}
|
||||
label={t('Room.leave_room')}
|
||||
onClick={() => setPromptLeave(true)}
|
||||
accent="critical"
|
||||
ariaExpanded={promptLeave}
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -73,16 +73,57 @@ export const ActionRowTrailing = style({
|
|||
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,
|
||||
});
|
||||
|
||||
// Inline expansion group under a parent ActionRow (the Notifications mode
|
||||
// picker). Indented past the parent's fixed icon slot so the sub-labels
|
||||
// align with the parent label — reads as a nested tray INSIDE the popout
|
||||
// instead of a second anchored PopOut (which rendered half-hidden over the
|
||||
// menu on mobile and re-opened on the second tap — see NotificationsActionRow).
|
||||
export const SubRowGroup = style({
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: toRem(1),
|
||||
paddingLeft: `calc(${toRem(20)} + ${config.space.S300})`,
|
||||
});
|
||||
|
||||
// A mode option row — same flat vocabulary as ActionRow, just shorter.
|
||||
export const SubRow = style({
|
||||
width: '100%',
|
||||
minHeight: toRem(36),
|
||||
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),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const SubRowSelectedIcon = style({
|
||||
flexShrink: 0,
|
||||
color: color.Primary.Main,
|
||||
});
|
||||
|
||||
// Popout container: width bump from the legacy 220px so the Notifications
|
||||
// mode word + Pinned badge breathe; softer mobile corner radius overriding
|
||||
// the folds Menu default. Background/border/shadow come from the folds Menu
|
||||
|
|
@ -91,7 +132,14 @@ export const PopoutMenu = style({
|
|||
width: toRem(280),
|
||||
maxWidth: '100vw',
|
||||
borderRadius: toRem(16),
|
||||
overflow: 'hidden',
|
||||
// Scroll, don't clip: the inline Notifications tray grows the menu AFTER
|
||||
// the folds PopOut has fixed its position (PopOut never repositions on
|
||||
// content growth), so on short viewports the tail rows would otherwise
|
||||
// extend past the viewport bottom unreachable. The max-height keeps the
|
||||
// frozen `top` valid; overflowY:auto still clips to the rounded corners.
|
||||
maxHeight: 'calc(100dvh - 5rem)',
|
||||
overflowY: 'auto',
|
||||
overflowX: 'hidden',
|
||||
});
|
||||
|
||||
export const PopoutGroup = style({
|
||||
|
|
|
|||
|
|
@ -1,9 +1,16 @@
|
|||
import React, { MouseEventHandler, ReactNode } from 'react';
|
||||
import { Icon, Icons, IconSrc, Text, color } from 'folds';
|
||||
import React, { MouseEventHandler, ReactNode, useState } from 'react';
|
||||
import { Icon, Icons, IconSrc, Spinner, 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 {
|
||||
getRoomNotificationMode,
|
||||
getRoomNotificationModeIcon,
|
||||
RoomNotificationMode,
|
||||
useRoomsNotificationPreferencesContext,
|
||||
useSetRoomNotificationPreference,
|
||||
} from '../../../hooks/useRoomsNotificationPreferences';
|
||||
import { AsyncStatus } from '../../../hooks/useAsyncCallback';
|
||||
import { botFailedAtomFamily, botShowChatAtomFamily } from '../../bots/botExperienceState';
|
||||
import { getBotPath } from '../../../pages/pathUtils';
|
||||
import * as css from './RoomActions.css';
|
||||
|
|
@ -22,7 +29,7 @@ type ActionRowProps = {
|
|||
trailing?: ReactNode;
|
||||
accent?: 'primary' | 'critical';
|
||||
disabled?: boolean;
|
||||
ariaPressed?: boolean;
|
||||
ariaExpanded?: boolean;
|
||||
ariaHasPopup?: boolean;
|
||||
};
|
||||
|
||||
|
|
@ -37,7 +44,7 @@ export function ActionRow({
|
|||
trailing,
|
||||
accent,
|
||||
disabled,
|
||||
ariaPressed,
|
||||
ariaExpanded,
|
||||
ariaHasPopup,
|
||||
}: ActionRowProps) {
|
||||
let accentColor: string | undefined;
|
||||
|
|
@ -50,7 +57,7 @@ export function ActionRow({
|
|||
className={css.ActionRow}
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
aria-pressed={ariaPressed}
|
||||
aria-expanded={ariaExpanded}
|
||||
aria-haspopup={ariaHasPopup}
|
||||
style={accentColor ? { color: accentColor } : undefined}
|
||||
>
|
||||
|
|
@ -70,15 +77,6 @@ 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} />;
|
||||
|
|
@ -126,3 +124,109 @@ export function useNotificationModeLabel(mode: RoomNotificationMode): string {
|
|||
};
|
||||
return labels[mode];
|
||||
}
|
||||
|
||||
const NOTIFICATION_MODES: RoomNotificationMode[] = [
|
||||
RoomNotificationMode.Unset,
|
||||
RoomNotificationMode.AllMessages,
|
||||
RoomNotificationMode.SpecialMessages,
|
||||
RoomNotificationMode.Mute,
|
||||
];
|
||||
|
||||
function NotificationModeSubRow({
|
||||
mode,
|
||||
selected,
|
||||
disabled,
|
||||
onSelect,
|
||||
}: {
|
||||
mode: RoomNotificationMode;
|
||||
selected: boolean;
|
||||
disabled: boolean;
|
||||
onSelect: (mode: RoomNotificationMode) => void;
|
||||
}) {
|
||||
const label = useNotificationModeLabel(mode);
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={css.SubRow}
|
||||
onClick={() => onSelect(mode)}
|
||||
disabled={disabled}
|
||||
aria-pressed={selected}
|
||||
>
|
||||
<span className={css.ActionRowIcon}>
|
||||
<Icon size="100" src={getRoomNotificationModeIcon(mode)} filled={selected} />
|
||||
</span>
|
||||
<Text
|
||||
as="span"
|
||||
size="T300"
|
||||
className={css.ActionRowLabel}
|
||||
style={selected ? { fontWeight: 600 } : undefined}
|
||||
>
|
||||
{label}
|
||||
</Text>
|
||||
{selected && <Icon className={css.SubRowSelectedIcon} size="100" src={Icons.Check} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
type NotificationsActionRowProps = {
|
||||
roomId: string;
|
||||
};
|
||||
|
||||
// Notifications row for the ⋮ popout menus. The mode picker expands INLINE
|
||||
// (an indented tray of the four modes under the row) instead of the old
|
||||
// nested anchored PopOut, which rendered half-hidden on top of the menu on
|
||||
// mobile. A plain boolean expansion has no anchor and no second FocusTrap,
|
||||
// so the whole class of nested-trap positioning/toggle foot-guns is out.
|
||||
export function NotificationsActionRow({ roomId }: NotificationsActionRowProps) {
|
||||
const { t } = useTranslation();
|
||||
const preferences = useRoomsNotificationPreferencesContext();
|
||||
const mode = getRoomNotificationMode(preferences, roomId);
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const { modeState, setMode } = useSetRoomNotificationPreference(roomId);
|
||||
const changing = modeState.status === AsyncStatus.Loading;
|
||||
|
||||
const handleSelect = (nextMode: RoomNotificationMode) => {
|
||||
if (changing) return;
|
||||
setMode(nextMode, mode);
|
||||
setExpanded(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* No current-mode word in the trailing slot — it crowded the row
|
||||
label on narrow popouts; the expanded tray's check mark is the
|
||||
mode indicator. */}
|
||||
<ActionRow
|
||||
icon={Icons.Bell}
|
||||
label={t('Room.notifications')}
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
ariaExpanded={expanded}
|
||||
trailing={
|
||||
changing ? (
|
||||
<Spinner size="100" variant="Secondary" />
|
||||
) : (
|
||||
<Icon
|
||||
className={css.ActionRowChevron}
|
||||
size="100"
|
||||
src={expanded ? Icons.ChevronTop : Icons.ChevronBottom}
|
||||
/>
|
||||
)
|
||||
}
|
||||
/>
|
||||
{expanded && (
|
||||
<div className={css.SubRowGroup}>
|
||||
{NOTIFICATION_MODES.map((m) => (
|
||||
<NotificationModeSubRow
|
||||
key={m}
|
||||
mode={m}
|
||||
selected={m === mode}
|
||||
disabled={changing}
|
||||
onSelect={handleSelect}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React, { MouseEventHandler, forwardRef, useState } from 'react';
|
||||
import { Badge, Icons, Menu, RectCords, Spinner, Text } from 'folds';
|
||||
import { Badge, Icons, Menu, RectCords, Text } from 'folds';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Room } from 'matrix-js-sdk';
|
||||
import { useMatrixClient } from '../../../hooks/useMatrixClient';
|
||||
|
|
@ -14,11 +14,6 @@ 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';
|
||||
|
|
@ -29,7 +24,13 @@ 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 } from './RoomActions';
|
||||
import {
|
||||
ActionRow,
|
||||
ActionSectionLine,
|
||||
BotWidgetActionRow,
|
||||
NotificationsActionRow,
|
||||
RowChevron,
|
||||
} from './RoomActions';
|
||||
import * as css from './RoomActions.css';
|
||||
|
||||
type RoomActionsMenuProps = {
|
||||
|
|
@ -43,9 +44,10 @@ type RoomActionsMenuProps = {
|
|||
// 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.
|
||||
// see RoomView.css.ts). Nested sub-flows: Notifications expands INLINE
|
||||
// (NotificationsActionRow — the old nested PopOut was half-hidden on mobile
|
||||
// and re-opened on second tap); 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();
|
||||
|
|
@ -56,8 +58,6 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
|||
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 pinnedEvents = useRoomPinnedEvents(room);
|
||||
const { navigateRoom } = useRoomNavigate();
|
||||
const openSettings = useOpenRoomSettings();
|
||||
|
|
@ -132,18 +132,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
|||
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" /> : <RowChevron />}
|
||||
/>
|
||||
)}
|
||||
</RoomNotificationModeSwitcher>
|
||||
<NotificationsActionRow roomId={room.roomId} />
|
||||
<ActionRow
|
||||
icon={Icons.Pin}
|
||||
label={t('Room.pinned_messages')}
|
||||
|
|
@ -167,7 +156,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
|||
icon={Icons.RecentClock}
|
||||
label={t('Room.jump_to_time')}
|
||||
onClick={() => setPromptJump(true)}
|
||||
ariaPressed={promptJump}
|
||||
ariaExpanded={promptJump}
|
||||
ariaHasPopup
|
||||
trailing={<RowChevron />}
|
||||
/>
|
||||
|
|
@ -187,7 +176,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
|||
label={t('Room.invite')}
|
||||
onClick={() => setInvitePrompt(true)}
|
||||
accent="primary"
|
||||
ariaPressed={invitePrompt}
|
||||
ariaExpanded={invitePrompt}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
|
|
@ -201,7 +190,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
|||
onClick={() => setPromptLeave(true)}
|
||||
accent="critical"
|
||||
disabled={callView}
|
||||
ariaPressed={promptLeave}
|
||||
ariaExpanded={promptLeave}
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue