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 [menuCords, setMenuCords] = useState<RectCords>();
|
||||||
|
|
||||||
const handleOpenMenu: MouseEventHandler<HTMLButtonElement> = (evt) => {
|
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());
|
setMenuCords(evt.currentTarget.getBoundingClientRect());
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleClose = () => {
|
const handleClose = () => {
|
||||||
|
|
@ -117,7 +126,7 @@ export function RoomNotificationModeSwitcher({
|
||||||
</FocusTrap>
|
</FocusTrap>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{children(handleOpenMenu, !!menuCords, changing)}
|
{children(handleToggleMenu, open, changing)}
|
||||||
</PopOut>
|
</PopOut>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,17 @@
|
||||||
import React, { forwardRef } from 'react';
|
import React, { forwardRef, useState } from 'react';
|
||||||
import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds';
|
import { Icons, Menu, Spinner } from 'folds';
|
||||||
import type { Room } from 'matrix-js-sdk';
|
import type { Room } from 'matrix-js-sdk';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 {
|
import {
|
||||||
getRoomNotificationMode,
|
getRoomNotificationMode,
|
||||||
getRoomNotificationModeIcon,
|
|
||||||
useRoomsNotificationPreferencesContext,
|
useRoomsNotificationPreferencesContext,
|
||||||
} from '../../hooks/useRoomsNotificationPreferences';
|
} from '../../hooks/useRoomsNotificationPreferences';
|
||||||
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
|
||||||
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
|
||||||
import { UseStateProvider } from '../../components/UseStateProvider';
|
// Reuse the EXACT row vocabulary + popout chrome of the 1:1 chat's ⋮ menu (RoomActionsMenu) so the
|
||||||
import { markAsRead } from '../../utils/notifications';
|
// 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 = {
|
type AiChatMenuProps = {
|
||||||
room: Room;
|
room: Room;
|
||||||
|
|
@ -25,119 +21,20 @@ type AiChatMenuProps = {
|
||||||
onOpenPrivacy: () => void;
|
onOpenPrivacy: () => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
// The native AI conversation surface's ⋮ menu. Deliberately NOT BotShellMenu: there is no widget
|
// The native AI conversation surface's ⋮ menu. Same popup as the 1:1 chat overflow menu
|
||||||
// here, so there is no "Show chat" toggle (it would be meaningless). It carries the conversation
|
// (RoomActionsMenu: `Menu variant="Surface"` + ActionRow rows + ActionSectionLine), carrying the
|
||||||
// actions (New chat / History / Privacy & data) plus the generic room actions (mark read /
|
// AI conversation actions (New chat / Chats / Privacy & data) plus the generic room actions
|
||||||
// notifications / leave)
|
// (notifications / leave). No "Mark as read" — useless here — and no widget "Show chat".
|
||||||
// that apply to any DM — reusing the same shared switcher / leave-prompt primitives so behaviour
|
|
||||||
// can't drift from the rest of the app.
|
|
||||||
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||||
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
|
({ room, requestClose, onNewChat, onOpenHistory, onOpenPrivacy }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
|
||||||
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
|
|
||||||
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
|
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||||
|
|
||||||
const handleMarkAsRead = () => {
|
const [promptLeave, setPromptLeave] = useState(false);
|
||||||
markAsRead(mx, room.roomId, hideActivity);
|
|
||||||
requestClose();
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Menu ref={ref} style={{ maxWidth: toRem(264), width: '100vw' }}>
|
<Menu ref={ref} variant="Surface" className={css.PopoutMenu}>
|
||||||
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
|
|
||||||
<MenuItem
|
|
||||||
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
|
|
||||||
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>
|
|
||||||
<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>
|
|
||||||
<MenuItem
|
|
||||||
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 && (
|
{promptLeave && (
|
||||||
<LeaveRoomPrompt
|
<LeaveRoomPrompt
|
||||||
roomId={room.roomId}
|
roomId={room.roomId}
|
||||||
|
|
@ -145,10 +42,60 @@ export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
|
||||||
onCancel={() => setPromptLeave(false)}
|
onCancel={() => setPromptLeave(false)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
|
||||||
|
<div className={css.PopoutGroup}>
|
||||||
|
<ActionRow
|
||||||
|
icon={Icons.Plus}
|
||||||
|
label={t('Bots.conversations.new_chat')}
|
||||||
|
onClick={() => {
|
||||||
|
onNewChat();
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<ActionRow
|
||||||
|
icon={Icons.RecentClock}
|
||||||
|
label={t('Bots.conversations.title')}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenHistory();
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
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 />}
|
||||||
|
/>
|
||||||
)}
|
)}
|
||||||
</UseStateProvider>
|
</RoomNotificationModeSwitcher>
|
||||||
</Box>
|
<ActionRow
|
||||||
|
icon={Icons.ShieldLock}
|
||||||
|
label={t('Bots.privacy.menu')}
|
||||||
|
onClick={() => {
|
||||||
|
onOpenPrivacy();
|
||||||
|
requestClose();
|
||||||
|
}}
|
||||||
|
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>
|
</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 { 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
|
// 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`).
|
// `.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',
|
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
|
// 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
|
// 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
|
// 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,
|
insetBlock: 0,
|
||||||
right: 0,
|
right: 0,
|
||||||
zIndex: 2,
|
zIndex: 2,
|
||||||
width: '50%',
|
// ~1.1× narrower than before (440→400 / 300→280 / 50%→45%).
|
||||||
maxWidth: toRem(440),
|
width: '45%',
|
||||||
minWidth: toRem(300),
|
maxWidth: toRem(400),
|
||||||
|
minWidth: toRem(280),
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
flexDirection: 'column',
|
flexDirection: 'column',
|
||||||
|
// Composer/input-form tone (Surface.Container = #0d0e11 — the same fill the message input uses).
|
||||||
backgroundColor: color.Surface.Container,
|
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%)',
|
transform: 'translateX(100%)',
|
||||||
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
|
transition: 'transform 220ms cubic-bezier(0.22, 1, 0.36, 1)',
|
||||||
selectors: {
|
selectors: {
|
||||||
// Shadow ONLY when open. The base `translateX(100%)` parks the panel off the right edge, but its
|
// No cast shadow — the panel separates from the chat via its darker bg + rounded corner only.
|
||||||
// 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.
|
|
||||||
'&[data-open]': {
|
'&[data-open]': {
|
||||||
transform: 'translateX(0)',
|
transform: 'translateX(0)',
|
||||||
boxShadow: '-12px 0 40px rgba(0, 0, 0, 0.32)',
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'@media': {
|
'@media': {
|
||||||
'(prefers-reduced-motion: reduce)': { transition: 'none' },
|
'(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,
|
margin: 0,
|
||||||
appearance: 'none',
|
appearance: 'none',
|
||||||
cursor: 'default',
|
cursor: 'default',
|
||||||
// Gentler scrim than the stock 0.4 — the panel's own shadow already separates it from the
|
// No scrim — the chat behind must not dim when the chats list opens (click-to-close only).
|
||||||
// surface, so the chat behind only needs a light dim, not a heavy black-out.
|
backgroundColor: 'transparent',
|
||||||
backgroundColor: 'rgba(0, 0, 0, 0.28)',
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// History row: a rounded card holding the conversation title and a muted last-activity time.
|
// 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>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<div className={css.Main}>
|
<div className={css.Main} data-history-open={historyOpen || undefined}>
|
||||||
{rootId ? (
|
{rootId ? (
|
||||||
<ThreadDrawer
|
<ThreadDrawer
|
||||||
key={`${room.roomId}/${rootId}`}
|
key={`${room.roomId}/${rootId}`}
|
||||||
|
|
|
||||||
|
|
@ -29,14 +29,7 @@ import { getViaServers } from '../../../plugins/via-servers';
|
||||||
import { copyToClipboard } from '../../../utils/dom';
|
import { copyToClipboard } from '../../../utils/dom';
|
||||||
import { markAsRead } from '../../../utils/notifications';
|
import { markAsRead } from '../../../utils/notifications';
|
||||||
import { JumpToTime } from '../jump-to-time';
|
import { JumpToTime } from '../jump-to-time';
|
||||||
import {
|
import { ActionRow, ActionSectionLine, BotWidgetActionRow, RowChevron } from './RoomActions';
|
||||||
ActionRow,
|
|
||||||
ActionSectionLine,
|
|
||||||
BotWidgetActionRow,
|
|
||||||
RowChevron,
|
|
||||||
RowTrailingText,
|
|
||||||
useNotificationModeLabel,
|
|
||||||
} from './RoomActions';
|
|
||||||
import * as css from './RoomActions.css';
|
import * as css from './RoomActions.css';
|
||||||
|
|
||||||
type RoomActionsMenuProps = {
|
type RoomActionsMenuProps = {
|
||||||
|
|
@ -65,7 +58,6 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
const canInvite = permissions.action('invite', mx.getSafeUserId());
|
||||||
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
const notificationPreferences = useRoomsNotificationPreferencesContext();
|
||||||
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
|
||||||
const notificationModeLabel = useNotificationModeLabel(notificationMode);
|
|
||||||
const pinnedEvents = useRoomPinnedEvents(room);
|
const pinnedEvents = useRoomPinnedEvents(room);
|
||||||
const { navigateRoom } = useRoomNavigate();
|
const { navigateRoom } = useRoomNavigate();
|
||||||
const openSettings = useOpenRoomSettings();
|
const openSettings = useOpenRoomSettings();
|
||||||
|
|
@ -148,16 +140,7 @@ export const RoomActionsMenu = forwardRef<HTMLDivElement, RoomActionsMenuProps>(
|
||||||
onClick={handleOpen}
|
onClick={handleOpen}
|
||||||
ariaPressed={opened}
|
ariaPressed={opened}
|
||||||
ariaHasPopup
|
ariaHasPopup
|
||||||
trailing={
|
trailing={changing ? <Spinner size="100" variant="Secondary" /> : <RowChevron />}
|
||||||
changing ? (
|
|
||||||
<Spinner size="100" variant="Secondary" />
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<RowTrailingText>{notificationModeLabel}</RowTrailingText>
|
|
||||||
<RowChevron />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</RoomNotificationModeSwitcher>
|
</RoomNotificationModeSwitcher>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue