New room list: add notification decoration (#29552)

* chore: update @compound-web

* feat(notification decoration): add NotificationDecoration component

* feat(room list item): get notification state in view model

* feat(room list item): use notification decoration in RoomListItemView

* test(notification decoration): add tests

* test(room list item view model): add a11yLabel tests

* test(room list item): update tests

* test(e2e): add decoration tests
This commit is contained in:
Florian Duros
2025-03-26 14:32:02 +01:00
committed by GitHub
parent f3f05874fa
commit bbd798ef36
17 changed files with 563 additions and 66 deletions

View File

@@ -92,7 +92,7 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^7.7.2",
"@vector-im/compound-web": "^7.9.0",
"@vector-im/matrix-wysiwyg": "2.38.2",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",

View File

@@ -13,6 +13,9 @@ test.describe("Room list", () => {
test.use({
displayName: "Alice",
labsFlags: ["feature_new_room_list"],
botCreateOpts: {
displayName: "BotBob",
},
});
/**
@@ -26,6 +29,10 @@ test.describe("Room list", () => {
test.beforeEach(async ({ page, app, user }) => {
// The notification toast is displayed above the search section
await app.closeNotificationToast();
});
test.describe("Room list", () => {
test.beforeEach(async ({ page, app, user }) => {
for (let i = 0; i < 30; i++) {
await app.client.createRoom({ name: `room${i}` });
}
@@ -94,3 +101,123 @@ test.describe("Room list", () => {
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
});
});
test.describe("Notification decoration", () => {
test("should render the invitation decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
await bot.createRoom({
name: "invited room",
invite: [user.userId],
is_direct: true,
});
const invitedRoom = roomListView.getByRole("gridcell", { name: "invited room" });
await expect(invitedRoom).toBeVisible();
await expect(invitedRoom).toMatchScreenshot("room-list-item-invited.png");
});
test("should render the regular decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "2 notifications" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await bot.sendMessage(roomId, "I am a robot. Beep.");
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "2 notifications" });
await expect(room).toBeVisible();
await expect(room.getByTestId("notification-decoration")).toHaveText("2");
await expect(room).toMatchScreenshot("room-list-item-notification.png");
});
test("should render the mention decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "mention" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
const clientBot = await bot.prepareClient();
await clientBot.evaluate(
async (client, { roomId, userId }) => {
await client.sendMessage(roomId, {
// @ts-ignore ignore usage of MsgType.text
"msgtype": "m.text",
"body": "User",
"format": "org.matrix.custom.html",
"formatted_body": `<a href="https://matrix.to/#/${userId}">User</a>`,
"m.mentions": {
user_ids: [userId],
},
});
},
{ roomId, userId: user.userId },
);
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "mention" });
await expect(room).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-mention.png");
});
test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "activity" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await app.viewRoomById(roomId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
await app.settings.openUserSettings("Notifications");
await page.getByText("Show all activity in the room list (dots or number of unread messages)").click();
await app.settings.closeDialog();
await bot.sendMessage(roomId, "I am a robot. Beep.");
const room = roomListView.getByRole("gridcell", { name: "activity" });
await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-activity.png");
});
test("should render a mark as unread decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "mark as unread" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
const room = roomListView.getByRole("gridcell", { name: "mark as unread" });
await room.hover();
await room.getByRole("button", { name: "More Options" }).click();
await page.getByRole("menuitem", { name: "mark as unread" }).click();
// Remove hover on the room list item
await roomListView.hover();
await expect(room).toMatchScreenshot("room-list-item-mark-as-unread.png");
});
test("should render silent decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
const roomListView = getRoomList(page);
const roomId = await app.client.createRoom({ name: "silent" });
await app.client.inviteUser(roomId, bot.credentials.userId);
await bot.joinRoom(roomId);
await app.viewRoomById(roomId);
await app.settings.openRoomSettings("Notifications");
await page.getByText("Off").click();
await app.settings.closeDialog();
const room = roomListView.getByRole("gridcell", { name: "silent" });
await expect(room.getByTestId("notification-decoration")).toBeVisible();
await expect(room).toMatchScreenshot("room-list-item-silent.png");
});
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 KiB

View File

@@ -5,13 +5,16 @@
* Please see LICENSE files in the repository root for full details.
*/
import { useCallback } from "react";
import { useCallback, useMemo } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import dispatcher from "../../../dispatcher/dispatcher";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { Action } from "../../../dispatcher/actions";
import { hasAccessToOptionsMenu } from "./utils";
import { _t } from "../../../languageHandler";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
export interface RoomListItemViewState {
/**
@@ -22,6 +25,14 @@ export interface RoomListItemViewState {
* Open the room having given roomId.
*/
openRoom: () => void;
/**
* The a11y label for the room list item.
*/
a11yLabel: string;
/**
* The notification state of the room.
*/
notificationState: RoomNotificationState;
}
/**
@@ -31,6 +42,8 @@ export interface RoomListItemViewState {
export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
// incoming: Check notification menu rights
const showHoverMenu = hasAccessToOptionsMenu(room);
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
const a11yLabel = getA11yLabel(room, notificationState);
// Actions
@@ -43,7 +56,38 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
}, [room]);
return {
notificationState,
showHoverMenu,
openRoom,
a11yLabel,
};
}
/**
* Get the a11y label for the room list item
* @param room
* @param notificationState
*/
function getA11yLabel(room: Room, notificationState: RoomNotificationState): string {
if (notificationState.isUnsetMessage) {
return _t("a11y|room_messsage_not_sent", {
roomName: room.name,
});
} else if (notificationState.invited) {
return _t("a11y|room_n_unread_invite", {
roomName: room.name,
});
} else if (notificationState.isMention) {
return _t("a11y|room_n_unread_messages_mentions", {
roomName: room.name,
count: notificationState.count,
});
} else if (notificationState.hasUnreadCount) {
return _t("a11y|room_n_unread_messages", {
roomName: room.name,
count: notificationState.count,
});
} else {
return _t("room_list|room|open_room", { roomName: room.name });
}
}

View File

@@ -0,0 +1,59 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type HTMLProps, type JSX } from "react";
import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/mention";
import ErrorIcon from "@vector-im/compound-design-tokens/assets/web/icons/error";
import NotificationOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/notifications-off-solid";
import { UnreadCounter, Unread } from "@vector-im/compound-web";
import { Flex } from "../../utils/Flex";
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
interface NotificationDecorationProps extends HTMLProps<HTMLDivElement> {
/**
* The notification state of the room or thread.
*/
notificationState: RoomNotificationState;
}
/**
* Displays the notification decoration for a room or a thread.
*/
export function NotificationDecoration({
notificationState,
...props
}: NotificationDecorationProps): JSX.Element | null {
const {
hasAnyNotificationOrActivity,
isUnsetMessage,
invited,
isMention,
isActivityNotification,
isNotification,
count,
muted,
} = notificationState;
if (!hasAnyNotificationOrActivity && !muted) return null;
return (
<Flex
align="center"
justify="center"
gap="var(--cpd-space-1x)"
{...props}
data-testid="notification-decoration"
>
{isUnsetMessage && <ErrorIcon width="20px" height="20px" fill="var(--cpd-color-icon-critical-primary)" />}
{invited && <UnreadCounter count={1} />}
{isMention && <MentionIcon width="20px" height="20px" fill="var(--cpd-color-icon-accent-primary)" />}
{(isMention || isNotification) && <UnreadCounter count={count || null} />}
{isActivityNotification && <Unread />}
{muted && <NotificationOffIcon width="20px" height="20px" fill="var(--cpd-color-icon-tertiary)" />}
</Flex>
);
}

View File

@@ -12,8 +12,8 @@ import classNames from "classnames";
import { useRoomListItemViewModel } from "../../../viewmodels/roomlist/RoomListItemViewModel";
import DecoratedRoomAvatar from "../../avatars/DecoratedRoomAvatar";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { RoomListItemMenuView } from "./RoomListItemMenuView";
import { NotificationDecoration } from "../NotificationDecoration";
interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElement> {
/**
@@ -46,7 +46,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
})}
type="button"
aria-selected={isSelected}
aria-label={_t("room_list|room|open_room", { roomName: room.name })}
aria-label={vm.a11yLabel}
onClick={() => vm.openRoom()}
onMouseOver={() => setIsHover(true)}
onMouseOut={() => setIsHover(false)}
@@ -65,7 +65,7 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
>
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
<span title={room.name}>{room.name}</span>
{showHoverDecoration && (
{showHoverDecoration ? (
<RoomListItemMenuView
room={room}
setMenuOpen={(isOpen) => {
@@ -74,6 +74,11 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
else setTimeout(() => setIsMenuOpen(isOpen), 0);
}}
/>
) : (
<>
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel variable */}
<NotificationDecoration notificationState={vm.notificationState} aria-hidden={true} />
</>
)}
</Flex>
</Flex>

View File

@@ -12,6 +12,16 @@
"other": "%(count)s unread messages including mentions."
},
"recent_rooms": "Recent rooms",
"room_messsage_not_sent": "Open room %(roomName)s with an unset message.",
"room_n_unread_invite": "Open room %(roomName)s invitation.",
"room_n_unread_messages": {
"one": "Open room %(roomName)s with 1 unread message.",
"other": "Open room %(roomName)s with %(count)s unread messages."
},
"room_n_unread_messages_mentions": {
"one": "Open room %(roomName)s with 1 unread mention.",
"other": "Open room %(roomName)s with %(count)s unread messages including mentions."
},
"room_name": "Room %(name)s",
"room_status_bar": "Room status bar",
"seek_bar_label": "Audio seek bar",

View File

@@ -14,6 +14,8 @@ import { Action } from "../../../../../src/dispatcher/actions";
import { useRoomListItemViewModel } from "../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
import { createTestClient, mkStubRoom } from "../../../../test-utils";
import { hasAccessToOptionsMenu } from "../../../../../src/components/viewmodels/roomlist/utils";
import { RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
import { RoomNotificationStateStore } from "../../../../../src/stores/notifications/RoomNotificationStateStore";
jest.mock("../../../../../src/components/viewmodels/roomlist/utils", () => ({
hasAccessToOptionsMenu: jest.fn().mockReturnValue(false),
@@ -46,4 +48,49 @@ describe("RoomListItemViewModel", () => {
const { result: vm } = renderHook(() => useRoomListItemViewModel(room));
expect(vm.current.showHoverMenu).toBe(true);
});
describe("a11yLabel", () => {
let notificationState: RoomNotificationState;
beforeEach(() => {
notificationState = new RoomNotificationState(room, false);
jest.spyOn(RoomNotificationStateStore.instance, "getRoomState").mockReturnValue(notificationState);
});
it.each([
{
label: "unset message",
mock: () => jest.spyOn(notificationState, "isUnsetMessage", "get").mockReturnValue(true),
expected: "Open room roomName with an unset message.",
},
{
label: "invitation",
mock: () => jest.spyOn(notificationState, "invited", "get").mockReturnValue(true),
expected: "Open room roomName invitation.",
},
{
label: "mention",
mock: () => {
jest.spyOn(notificationState, "isMention", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
},
expected: "Open room roomName with 3 unread messages including mentions.",
},
{
label: "unread",
mock: () => {
jest.spyOn(notificationState, "hasUnreadCount", "get").mockReturnValue(true);
jest.spyOn(notificationState, "count", "get").mockReturnValue(3);
},
expected: "Open room roomName with 3 unread messages.",
},
{
label: "default",
expected: "Open room roomName",
},
])("should return the $label label", ({ mock, expected }) => {
mock?.();
const { result: vm } = renderHook(() => useRoomListItemViewModel(room));
expect(vm.current.a11yLabel).toBe(expected);
});
});
});

View File

@@ -0,0 +1,62 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen } from "jest-matrix-react";
import { type RoomNotificationState } from "../../../../../src/stores/notifications/RoomNotificationState";
import { NotificationDecoration } from "../../../../../src/components/views/rooms/NotificationDecoration";
describe("<NotificationDecoration />", () => {
it("should not render if RoomNotificationState.isSilent=true", () => {
const state = { hasAnyNotificationOrActivity: false } as RoomNotificationState;
render(<NotificationDecoration notificationState={state} />);
expect(screen.queryByTestId("notification-decoration")).toBeNull();
});
it("should render the unset message decoration", () => {
const state = { hasAnyNotificationOrActivity: true, isUnsetMessage: true } as RoomNotificationState;
const { asFragment } = render(<NotificationDecoration notificationState={state} />);
expect(asFragment()).toMatchSnapshot();
});
it("should render the invitation decoration", () => {
const state = { hasAnyNotificationOrActivity: true, invited: true } as RoomNotificationState;
const { asFragment } = render(<NotificationDecoration notificationState={state} />);
expect(asFragment()).toMatchSnapshot();
});
it("should render the mention decoration", () => {
const state = { hasAnyNotificationOrActivity: true, isMention: true, count: 1 } as RoomNotificationState;
const { asFragment } = render(<NotificationDecoration notificationState={state} />);
expect(asFragment()).toMatchSnapshot();
});
it("should render the notification decoration", () => {
const state = { hasAnyNotificationOrActivity: true, isNotification: true, count: 1 } as RoomNotificationState;
const { asFragment } = render(<NotificationDecoration notificationState={state} />);
expect(asFragment()).toMatchSnapshot();
});
it("should render the notification decoration without count", () => {
const state = { hasAnyNotificationOrActivity: true, isNotification: true, count: 0 } as RoomNotificationState;
const { asFragment } = render(<NotificationDecoration notificationState={state} />);
expect(asFragment()).toMatchSnapshot();
});
it("should render the activity decoration", () => {
const state = { hasAnyNotificationOrActivity: true, isActivityNotification: true } as RoomNotificationState;
const { asFragment } = render(<NotificationDecoration notificationState={state} />);
expect(asFragment()).toMatchSnapshot();
});
it("should render the muted decoration", () => {
const state = { hasAnyNotificationOrActivity: true, muted: true } as RoomNotificationState;
const { asFragment } = render(<NotificationDecoration notificationState={state} />);
expect(asFragment()).toMatchSnapshot();
});
});

View File

@@ -18,26 +18,32 @@ import {
type RoomListItemViewState,
useRoomListItemViewModel,
} from "../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel";
import { RoomNotificationState } from "../../../../../../src/stores/notifications/RoomNotificationState";
jest.mock("../../../../../../src/components/viewmodels/roomlist/RoomListItemViewModel", () => ({
useRoomListItemViewModel: jest.fn(),
}));
describe("<RoomListItemView />", () => {
const defaultValue: RoomListItemViewState = {
openRoom: jest.fn(),
showHoverMenu: false,
};
let defaultValue: RoomListItemViewState;
let matrixClient: MatrixClient;
let room: Room;
beforeEach(() => {
mocked(useRoomListItemViewModel).mockReturnValue(defaultValue);
matrixClient = stubClient();
room = mkRoom(matrixClient, "room1");
DMRoomMap.makeShared(matrixClient);
jest.spyOn(DMRoomMap.shared(), "getUserIdForRoomId").mockReturnValue(null);
defaultValue = {
openRoom: jest.fn(),
showHoverMenu: false,
notificationState: new RoomNotificationState(room, false),
a11yLabel: "Open room room1",
};
mocked(useRoomListItemViewModel).mockReturnValue(defaultValue);
});
test("should render a room item", () => {

View File

@@ -0,0 +1,137 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<NotificationDecoration /> should render the activity decoration 1`] = `
<DocumentFragment>
<div
class="mx_Flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<div
class="_unread_1k06b_8"
>
<div />
</div>
</div>
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the invitation decoration 1`] = `
<DocumentFragment>
<div
class="mx_Flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<span
class="_unread-counter_1ibqq_8"
>
1
</span>
</div>
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the mention decoration 1`] = `
<DocumentFragment>
<div
class="mx_Flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-accent-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 4a8 8 0 1 0 0 16 1 1 0 1 1 0 2C6.477 22 2 17.523 2 12S6.477 2 12 2s10 4.477 10 10v1.5a3.5 3.5 0 0 1-6.396 1.966A5 5 0 1 1 17 12v1.5a1.5 1.5 0 0 0 3 0V12a8 8 0 0 0-8-8m3 8a3 3 0 1 0-6 0 3 3 0 0 0 6 0"
/>
</svg>
<span
class="_unread-counter_1ibqq_8"
>
1
</span>
</div>
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the muted decoration 1`] = `
<DocumentFragment>
<div
class="mx_Flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-tertiary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="m4.917 2.083 17 17a1 1 0 0 1-1.414 1.414L19.006 19H4.414c-.89 0-1.337-1.077-.707-1.707L5 16v-6s0-2.034 1.096-3.91L3.504 3.498a1 1 0 0 1 1.414-1.414M19 13.35 9.136 3.484C9.93 3.181 10.874 3 12 3c7 0 7 7 7 7z"
/>
<path
d="M10 20h4a2 2 0 0 1-4 0"
/>
</svg>
</div>
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the notification decoration 1`] = `
<DocumentFragment>
<div
class="mx_Flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<span
class="_unread-counter_1ibqq_8"
>
1
</span>
</div>
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the notification decoration without count 1`] = `
<DocumentFragment>
<div
class="mx_Flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<div
class="_unread-counter_1ibqq_8"
/>
</div>
</DocumentFragment>
`;
exports[`<NotificationDecoration /> should render the unset message decoration 1`] = `
<DocumentFragment>
<div
class="mx_Flex"
data-testid="notification-decoration"
style="--mx-flex-display: flex; --mx-flex-direction: row; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-1x); --mx-flex-wrap: nowrap;"
>
<svg
fill="var(--cpd-color-icon-critical-primary)"
height="20px"
viewBox="0 0 24 24"
width="20px"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 17q.424 0 .713-.288A.97.97 0 0 0 13 16a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 15a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 16q0 .424.287.712.288.288.713.288m0-4q.424 0 .713-.287A.97.97 0 0 0 13 12V8a.97.97 0 0 0-.287-.713A.97.97 0 0 0 12 7a.97.97 0 0 0-.713.287A.97.97 0 0 0 11 8v4q0 .424.287.713.288.287.713.287m0 9a9.7 9.7 0 0 1-3.9-.788 10.1 10.1 0 0 1-3.175-2.137q-1.35-1.35-2.137-3.175A9.7 9.7 0 0 1 2 12q0-2.075.788-3.9a10.1 10.1 0 0 1 2.137-3.175q1.35-1.35 3.175-2.137A9.7 9.7 0 0 1 12 2q2.075 0 3.9.788a10.1 10.1 0 0 1 3.175 2.137q1.35 1.35 2.137 3.175A9.7 9.7 0 0 1 22 12a9.7 9.7 0 0 1-.788 3.9 10.1 10.1 0 0 1-2.137 3.175q-1.35 1.35-3.175 2.137A9.7 9.7 0 0 1 12 22m0-2q3.35 0 5.675-2.325T20 12t-2.325-5.675T12 4 6.325 6.325 4 12t2.325 5.675T12 20"
/>
</svg>
</div>
</DocumentFragment>
`;

View File

@@ -3597,10 +3597,10 @@
resolved "https://registry.yarnpkg.com/@vector-im/compound-design-tokens/-/compound-design-tokens-4.0.1.tgz#5c4ea7ad664d8e6206dc42e41649c80ef060a760"
integrity sha512-V4AsK1FVFxZ6DmmCoeAi8FyvE7ODMlXPWjqRGotcnVaoGNrDQrVz2ZGV85DCz5ISxB3iynYASe6OXsDVXT1zFA==
"@vector-im/compound-web@^7.7.2":
version "7.7.2"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.7.2.tgz#07e04a546b86e568b13263092b324efc76398487"
integrity sha512-RhPyKzfPo1HRyFi3wy8oc25IXbLLzTmw6A5QvPJgRlMW+LidwqCCYqmFeZrvWxK3pZPqE7hTJbHgUhGe7kxznw==
"@vector-im/compound-web@^7.9.0":
version "7.9.0"
resolved "https://registry.yarnpkg.com/@vector-im/compound-web/-/compound-web-7.9.0.tgz#72eccdd501e54f7b88317ba927bfeca61af72de0"
integrity sha512-2rBD+1Mit+kOd7+ZPUxdH7y6V1mi7Fga85cyC2cvUeL/sXBn0Q5HuyJ8whmdgLmgZiI4LkLriCFaeogYipKE+Q==
dependencies:
"@floating-ui/react" "^0.27.0"
"@radix-ui/react-context-menu" "^2.2.1"