feat(ai-bot): rebuild the @ai overflow menu to match 1-1 chats, restyle the chats-list panel, and slim the notification button
This commit is contained in:
parent
ae387c735d
commit
f41ea049cc
5 changed files with 95 additions and 147 deletions
|
|
@ -56,8 +56,17 @@ export function RoomNotificationModeSwitcher({
|
|||
|
||||
const [menuCords, setMenuCords] = useState<RectCords>();
|
||||
|
||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
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.
|
||||
if (open) {
|
||||
setMenuCords(undefined);
|
||||
} else {
|
||||
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||
}
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
|
|
@ -117,7 +126,7 @@ export function RoomNotificationModeSwitcher({
|
|||
</FocusTrap>
|
||||
}
|
||||
>
|
||||
{children(handleOpenMenu, !!menuCords, changing)}
|
||||
{children(handleToggleMenu, open, changing)}
|
||||
</PopOut>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,21 +1,17 @@
|
|||
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, Spinner } from 'folds';
|
||||
import type { Room } from 'matrix-js-sdk';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
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 {
|
||||
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';
|
||||
// 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 * as css from '../room/room-actions/RoomActions.css';
|
||||
|
||||
type AiChatMenuProps = {
|
||||
room: Room;
|
||||
|
|
@ -25,130 +21,81 @@ type AiChatMenuProps = {
|
|||
onOpenPrivacy: () => void;
|
||||
};
|
||||
|
||||
// The native AI conversation surface's ⋮ menu. Deliberately NOT BotShellMenu: there is no widget
|
||||
// here, so there is no "Show chat" toggle (it would be meaningless). It carries the conversation
|
||||
// actions (New chat / History / Privacy & data) plus the generic room actions (mark read /
|
||||
// notifications / leave)
|
||||
// that apply to any DM — reusing the same shared switcher / leave-prompt primitives so behaviour
|
||||
// can't drift from the rest of the app.
|
||||
// The native AI conversation surface's ⋮ menu. Same popup as the 1:1 chat overflow menu
|
||||
// (RoomActionsMenu: `Menu variant="Surface"` + ActionRow rows + ActionSectionLine), carrying the
|
||||
// AI conversation actions (New chat / Chats / Privacy & data) plus the generic room actions
|
||||
// (notifications / leave). No "Mark as read" — useless here — and no widget "Show chat".
|
||||
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||
|
||||
const handleMarkAsRead = () => {
|
||||
markAsRead(mx, room.roomId, hideActivity);
|
||||
requestClose();
|
||||
};
|
||||
const [promptLeave, setPromptLeave] = useState(false);
|
||||
|
||||
return (
|
||||
<Menu ref={ref} style={{ maxWidth: toRem(264), 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.Plus}
|
||||
label={t('Bots.conversations.new_chat')}
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
requestClose();
|
||||
}}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.Plus} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Bots.conversations.new_chat')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<MenuItem
|
||||
/>
|
||||
<ActionRow
|
||||
icon={Icons.RecentClock}
|
||||
label={t('Bots.conversations.title')}
|
||||
onClick={() => {
|
||||
onOpenHistory();
|
||||
requestClose();
|
||||
}}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.RecentClock} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Bots.conversations.title')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
<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>
|
||||
ariaHasPopup
|
||||
trailing={<RowChevron />}
|
||||
/>
|
||||
<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}
|
||||
<ActionRow
|
||||
icon={Icons.Bell}
|
||||
label={t('Room.notifications')}
|
||||
onClick={handleOpen}
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Room.notifications')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
ariaPressed={opened}
|
||||
ariaHasPopup
|
||||
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
|
||||
/>
|
||||
)}
|
||||
</RoomNotificationModeSwitcher>
|
||||
<MenuItem
|
||||
<ActionRow
|
||||
icon={Icons.ShieldLock}
|
||||
label={t('Bots.privacy.menu')}
|
||||
onClick={() => {
|
||||
onOpenPrivacy();
|
||||
requestClose();
|
||||
}}
|
||||
size="300"
|
||||
after={<Icon size="100" src={Icons.ShieldLock} />}
|
||||
radii="300"
|
||||
>
|
||||
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
|
||||
{t('Bots.privacy.menu')}
|
||||
</Text>
|
||||
</MenuItem>
|
||||
</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>
|
||||
ariaHasPopup
|
||||
trailing={<RowChevron />}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ActionSectionLine />
|
||||
<div className={css.PopoutGroup}>
|
||||
<ActionRow
|
||||
icon={Icons.ArrowGoLeft}
|
||||
label={t('Room.leave_room')}
|
||||
onClick={() => setPromptLeave(true)}
|
||||
accent="critical"
|
||||
ariaPressed={promptLeave}
|
||||
/>
|
||||
</div>
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { globalStyle, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { VOJO_HORSESHOE_GAP_PX } from '../../styles/horseshoe';
|
||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||
import { ChatComposer } from '../room/RoomView.css';
|
||||
|
||||
// Bridge widgets centre their body content in a 960px column (apps/widget-telegram/src/styles.css
|
||||
// `.app { max-width: 960px; margin: 0 auto }`, mirrored by the host hero's `BotShell.HeroInner`).
|
||||
|
|
@ -53,6 +54,13 @@ export const Main = style({
|
|||
flexDirection: 'column',
|
||||
});
|
||||
|
||||
// Hide the composer/input form entirely while the chats list (History panel) is open; the chat
|
||||
// messages stay visible. `ChatComposer` marks both the new-chat composer and the in-thread
|
||||
// composer, so both are removed.
|
||||
globalStyle(`${Main}[data-history-open] ${ChatComposer}`, {
|
||||
display: 'none',
|
||||
});
|
||||
|
||||
// Chat-history panel, anchored to the RIGHT edge of the body, slid off-screen until toggled. Sits
|
||||
// above the Main content (zIndex 2) over a click-to-close Backdrop (zIndex 1). Raised
|
||||
// Surface.Container tone + a soft cast shadow lift it off the surface so the open/close reads as a
|
||||
|
|
@ -62,28 +70,30 @@ export const History = style({
|
|||
insetBlock: 0,
|
||||
right: 0,
|
||||
zIndex: 2,
|
||||
width: '50%',
|
||||
maxWidth: toRem(440),
|
||||
minWidth: toRem(300),
|
||||
// ~1.1× narrower than before (440→400 / 300→280 / 50%→45%).
|
||||
width: '45%',
|
||||
maxWidth: toRem(400),
|
||||
minWidth: toRem(280),
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
// Composer/input-form tone (Surface.Container = #0d0e11 — the same fill the message input uses).
|
||||
backgroundColor: color.Surface.Container,
|
||||
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
|
||||
// No border — the panel separates from the chat via its own cast shadow + the rounded corner.
|
||||
// Round the inner (top-left) corner with the app-wide horseshoe radius, matching the Direct-tab
|
||||
// chat list (PageNav content) and the composer card.
|
||||
borderTopLeftRadius: toRem(VOJO_HORSESHOE_RADIUS_PX),
|
||||
transform: 'translateX(100%)',
|
||||
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||
selectors: {
|
||||
// Shadow ONLY when open. The base `translateX(100%)` parks the panel off the right edge, but its
|
||||
// cast shadow (offset -12px, i.e. leftward, + 40px blur) reaches back INTO the body and Body's
|
||||
// `overflow: hidden` can't clip it (the leak lands inside the box, not past its edge) — so a base
|
||||
// shadow paints a dark sliver on the right edge even while the drawer is closed.
|
||||
// No cast shadow — the panel separates from the chat via its darker bg + rounded corner only.
|
||||
'&[data-open]': {
|
||||
transform: 'translateX(0)',
|
||||
boxShadow: '-12px 0 40px rgba(0, 0, 0, 0.32)',
|
||||
},
|
||||
},
|
||||
'@media': {
|
||||
'(prefers-reduced-motion: reduce)': { transition: 'none' },
|
||||
'(max-width: 600px)': { width: '86%', maxWidth: 'none' },
|
||||
// Narrower on native so there's a bigger gap on the left of the chats list.
|
||||
'(max-width: 600px)': { width: '78%', maxWidth: 'none' },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -150,9 +160,8 @@ export const Backdrop = style({
|
|||
margin: 0,
|
||||
appearance: 'none',
|
||||
cursor: 'default',
|
||||
// Gentler scrim than the stock 0.4 — the panel's own shadow already separates it from the
|
||||
// surface, so the chat behind only needs a light dim, not a heavy black-out.
|
||||
backgroundColor: 'rgba(0, 0, 0, 0.28)',
|
||||
// No scrim — the chat behind must not dim when the chats list opens (click-to-close only).
|
||||
backgroundColor: 'transparent',
|
||||
});
|
||||
|
||||
// History row: a rounded card holding the conversation title and a muted last-activity time.
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ export function BotConversations({ preset, room, rootId }: BotConversationsProps
|
|||
</div>
|
||||
</aside>
|
||||
|
||||
<div className={css.Main}>
|
||||
<div className={css.Main} data-history-open={historyOpen || undefined}>
|
||||
{rootId ? (
|
||||
<ThreadDrawer
|
||||
key={`${room.roomId}/${rootId}`}
|
||||
|
|
|
|||
|
|
@ -29,14 +29,7 @@ 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 { ActionRow, ActionSectionLine, BotWidgetActionRow, RowChevron } from './RoomActions';
|
||||
import * as css from './RoomActions.css';
|
||||
|
||||
type RoomActionsMenuProps = {
|
||||
|
|
@ -65,7 +58,6 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
|||
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();
|
||||
|
|
@ -148,16 +140,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
|||
onClick={handleOpen}
|
||||
ariaPressed={opened}
|
||||
ariaHasPopup
|
||||
trailing={
|
||||
changing ? (
|
||||
<Spinner size="100" variant="Secondary" />
|
||||
) : (
|
||||
<>
|
||||
<RowTrailingText>{notificationModeLabel}</RowTrailingText>
|
||||
<RowChevron />
|
||||
</>
|
||||
)
|
||||
}
|
||||
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
|
||||
/>
|
||||
)}
|
||||
</RoomNotificationModeSwitcher>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue