From f2ce9d4cd9bda4ea656894983dbf105e0953487f Mon Sep 17 00:00:00 2001 From: heaven Date: Mon, 15 Jun 2026 02:57:32 +0300 Subject: [PATCH] refactor(room): rebuild the room and bot action menus onto a shared inline notifications mode-picker tray --- .../components/RoomNotificationSwitcher.tsx | 32 ++++- src/app/features/bots/AiChatMenu.tsx | 32 ++--- src/app/features/bots/BotShellMenu.tsx | 134 +++++++----------- .../room/room-actions/RoomActions.css.ts | 60 +++++++- .../room/room-actions/RoomActions.tsx | 134 ++++++++++++++++-- .../room/room-actions/RoomActionsMenu.tsx | 43 +++--- 6 files changed, 273 insertions(+), 162 deletions(-) diff --git a/src/app/components/RoomNotificationSwitcher.tsx b/src/app/components/RoomNotificationSwitcher.tsx index 9f02debf..88299364 100644 --- a/src/app/components/RoomNotificationSwitcher.tsx +++ b/src/app/components/RoomNotificationSwitcher.tsx @@ -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(); + // 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(null); const open = !!menuCords; const handleToggleMenu: MouseEventHandler = (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', diff --git a/src/app/features/bots/AiChatMenu.tsx b/src/app/features/bots/AiChatMenu.tsx index 907b6648..c9e34795 100644 --- a/src/app/features/bots/AiChatMenu.tsx +++ b/src/app/features/bots/AiChatMenu.tsx @@ -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( ({ 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( ariaHasPopup trailing={} /> - - {(handleOpen, opened, changing) => ( - : } - /> - )} - + ( label={t('Room.leave_room')} onClick={() => setPromptLeave(true)} accent="critical" - ariaPressed={promptLeave} + ariaExpanded={promptLeave} /> diff --git a/src/app/features/bots/BotShellMenu.tsx b/src/app/features/bots/BotShellMenu.tsx index 4ef36bf0..22d7074b 100644 --- a/src/app/features/bots/BotShellMenu.tsx +++ b/src/app/features/bots/BotShellMenu.tsx @@ -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( 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( }; return ( - - - + {promptLeave && ( + setPromptLeave(false)} + /> + )} + +
+ } - radii="300" - > - - {t('Bots.show_chat')} - - - } + /> + } - radii="300" disabled={!unread} - > - - {t('Room.mark_as_read')} - - - - {(handleOpen, opened, changing) => ( - - ) : ( - - ) - } - radii="300" - aria-pressed={opened} - onClick={handleOpen} - > - - {t('Room.notifications')} - - - )} - - - - - - {(promptLeave, setPromptLeave) => ( - <> - setPromptLeave(true)} - variant="Critical" - fill="None" - size="300" - after={} - radii="300" - aria-pressed={promptLeave} - > - - {t('Room.leave_room')} - - - {promptLeave && ( - setPromptLeave(false)} - /> - )} - - )} - - + /> + +
+ + +
+ setPromptLeave(true)} + accent="critical" + ariaExpanded={promptLeave} + /> +
); } diff --git a/src/app/features/room/room-actions/RoomActions.css.ts b/src/app/features/room/room-actions/RoomActions.css.ts index b79e30d0..69ef376b 100644 --- a/src/app/features/room/room-actions/RoomActions.css.ts +++ b/src/app/features/room/room-actions/RoomActions.css.ts @@ -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({ diff --git a/src/app/features/room/room-actions/RoomActions.tsx b/src/app/features/room/room-actions/RoomActions.tsx index e55479c0..9065b8e2 100644 --- a/src/app/features/room/room-actions/RoomActions.tsx +++ b/src/app/features/room/room-actions/RoomActions.tsx @@ -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 ; } -// Muted trailing word (e.g. the current notification mode beside the row). -export function RowTrailingText({ children }: { children: ReactNode }) { - return ( - - {children} - - ); -} - // Subtle Fleet hairline between row groups. export function ActionSectionLine() { return
; @@ -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 ( + + ); +} + +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. */} + setExpanded(!expanded)} + ariaExpanded={expanded} + trailing={ + changing ? ( + + ) : ( + + ) + } + /> + {expanded && ( +
+ {NOTIFICATION_MODES.map((m) => ( + + ))} +
+ )} + + ); +} diff --git a/src/app/features/room/room-actions/RoomActionsMenu.tsx b/src/app/features/room/room-actions/RoomActionsMenu.tsx index 1f876e44..184ad397 100644 --- a/src/app/features/room/room-actions/RoomActionsMenu.tsx +++ b/src/app/features/room/room-actions/RoomActionsMenu.tsx @@ -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( ({ room, callView, botControlRoom, onPin, requestClose }, ref) => { const { t } = useTranslation(); @@ -56,8 +58,6 @@ export const RoomActionsMenu = forwardRef( 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( onClick={handleMarkAsRead} disabled={!unread} /> - - {(handleOpen, opened, changing) => ( - : } - /> - )} - + ( icon={Icons.RecentClock} label={t('Room.jump_to_time')} onClick={() => setPromptJump(true)} - ariaPressed={promptJump} + ariaExpanded={promptJump} ariaHasPopup trailing={} /> @@ -187,7 +176,7 @@ export const RoomActionsMenu = forwardRef( label={t('Room.invite')} onClick={() => setInvitePrompt(true)} accent="primary" - ariaPressed={invitePrompt} + ariaExpanded={invitePrompt} />
@@ -201,7 +190,7 @@ export const RoomActionsMenu = forwardRef( onClick={() => setPromptLeave(true)} accent="critical" disabled={callView} - ariaPressed={promptLeave} + ariaExpanded={promptLeave} />