Message Pinning: rework the message pinning list in the right panel (#12825)

* Fix pinning event loading after restart

* Update deps

* Replace pinned event list

* Add a dialog to confirm to unpin all messages

* Use `EmptyState` when there is no pinned messages

* Rework `PinnedEventTile` tests

* Add comments and refactor `PinnedMessageCard`

* Rework `PinnedMessageCard` tests

* Add tests for `UnpinAllDialog`

* Add e2e tests for pinned messages

* Replace 3px custom gap by 4px gap

* Use string interpolation for `Pin` action.

* Update playright sceenshot for empty state
This commit is contained in:
Florian Duros 2024-08-16 14:16:06 +02:00 committed by GitHub
parent 88cf643cbd
commit 6f3dc30693
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
22 changed files with 2099 additions and 507 deletions

View file

@ -0,0 +1,46 @@
/*
* Copyright 2024 The Matrix.org Foundation C.I.C.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import React from "react";
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { EventType } from "matrix-js-sdk/src/matrix";
import { UnpinAllDialog } from "../../../../src/components/views/dialogs/UnpinAllDialog";
import { createTestClient } from "../../../test-utils";
describe("<UnpinAllDialog />", () => {
const client = createTestClient();
const roomId = "!room:example.org";
function renderDialog(onFinished = jest.fn()) {
return render(<UnpinAllDialog matrixClient={client} roomId={roomId} onFinished={onFinished} />);
}
it("should render", () => {
const { asFragment } = renderDialog();
expect(asFragment()).toMatchSnapshot();
});
it("should remove all pinned events when clicked on Continue", async () => {
const onFinished = jest.fn();
renderDialog(onFinished);
await userEvent.click(screen.getByText("Continue"));
expect(client.sendStateEvent).toHaveBeenCalledWith(roomId, EventType.RoomPinnedEvents, { pinned: [] }, "");
expect(onFinished).toHaveBeenCalled();
});
});

View file

@ -0,0 +1,66 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<UnpinAllDialog /> should render 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-labelledby="mx_BaseDialog_title"
class="mx_UnpinAllDialog"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header"
>
<h1
class="mx_Heading_h3 mx_Dialog_title mx_UnpinAllDialog_title"
id="mx_BaseDialog_title"
>
Unpin all messages?
</h1>
</div>
<span
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
>
Make sure that you really want to remove all pinned messages. This action cant be undone.
</span>
<div
class="mx_UnpinAllDialog_buttons"
>
<button
class="_button_zt6rp_17 _destructive_zt6rp_111"
data-kind="primary"
data-size="lg"
role="button"
tabindex="0"
>
Continue
</button>
<button
class="_button_zt6rp_17"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Cancel
</button>
</div>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;

View file

@ -15,37 +15,44 @@ limitations under the License.
*/
import React from "react";
import { render, act, RenderResult, fireEvent, waitForElementToBeRemoved, screen } from "@testing-library/react";
import { mocked } from "jest-mock";
import { render, act, RenderResult, waitForElementToBeRemoved, screen } from "@testing-library/react";
import { mocked, MockedObject } from "jest-mock";
import {
MatrixEvent,
RoomStateEvent,
IEvent,
Room,
EventTimelineSet,
IMinimalEvent,
EventType,
RelationType,
MsgType,
M_POLL_KIND_DISCLOSED,
EventTimeline,
MatrixClient,
} from "matrix-js-sdk/src/matrix";
import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent";
import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent";
import { PollEndEvent } from "matrix-js-sdk/src/extensible_events_v1/PollEndEvent";
import { sleep } from "matrix-js-sdk/src/utils";
import userEvent from "@testing-library/user-event";
import { stubClient, mkEvent, mkMessage, flushPromises } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import PinnedMessagesCard from "../../../../src/components/views/right_panel/PinnedMessagesCard";
import { PinnedMessagesCard } from "../../../../src/components/views/right_panel/PinnedMessagesCard";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import Modal from "../../../../src/Modal";
import { UnpinAllDialog } from "../../../../src/components/views/dialogs/UnpinAllDialog";
describe("<PinnedMessagesCard />", () => {
stubClient();
const cli = mocked(MatrixClientPeg.safeGet());
cli.getUserId.mockReturnValue("@alice:example.org");
cli.setRoomAccountData.mockResolvedValue({});
cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] });
let cli: MockedObject<MatrixClient>;
beforeEach(() => {
stubClient();
cli = mocked(MatrixClientPeg.safeGet());
cli.getUserId.mockReturnValue("@alice:example.org");
cli.setRoomAccountData.mockResolvedValue({});
cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] });
});
const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => {
const room = new Room("!room:example.org", cli, "@me:example.org");
@ -53,27 +60,27 @@ describe("<PinnedMessagesCard />", () => {
const pins = () => [...localPins, ...nonLocalPins];
// Insert pin IDs into room state
jest.spyOn(room.currentState, "getStateEvents").mockImplementation((): any =>
mkEvent({
event: true,
type: EventType.RoomPinnedEvents,
content: {
pinned: pins().map((e) => e.getId()),
},
user: "@user:example.org",
room: "!room:example.org",
}),
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockImplementation(
(): any =>
mkEvent({
event: true,
type: EventType.RoomPinnedEvents,
content: {
pinned: pins().map((e) => e.getId()),
},
user: "@user:example.org",
room: "!room:example.org",
}),
);
jest.spyOn(room.currentState, "on");
// Insert local pins into local timeline set
room.getUnfilteredTimelineSet = () =>
({
getTimelineForEvent: () => ({
getEvents: () => localPins,
}),
}) as unknown as EventTimelineSet;
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
true,
);
// poll end event validates against this
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"maySendRedactionForEvent",
).mockReturnValue(true);
// Return all pins over fetchRoomEvent
cli.fetchRoomEvent.mockImplementation((roomId, eventId) => {
@ -86,8 +93,8 @@ describe("<PinnedMessagesCard />", () => {
return room;
};
const mountPins = async (room: Room): Promise<RenderResult> => {
const pins = render(
async function renderMessagePinList(room: Room): Promise<RenderResult> {
const renderResult = render(
<MatrixClientContext.Provider value={cli}>
<PinnedMessagesCard
room={room}
@ -99,22 +106,60 @@ describe("<PinnedMessagesCard />", () => {
// Wait a tick for state updates
await act(() => sleep(0));
return pins;
};
const emitPinUpdates = async (room: Room) => {
const pinListener = mocked(room.currentState).on.mock.calls.find(
([eventName, listener]) => eventName === RoomStateEvent.Events,
)![1];
return renderResult;
}
/**
*
* @param room
*/
async function emitPinUpdate(room: Room) {
await act(async () => {
// Emit the update
// @ts-ignore what is going on here?
pinListener(room.currentState.getStateEvents());
// Wait a tick for state updates
await sleep(0);
const roomState = room.getLiveTimeline().getState(EventTimeline.FORWARDS)!;
roomState.emit(
RoomStateEvent.Events,
new MatrixEvent({ type: EventType.RoomPinnedEvents }),
roomState,
null,
);
});
};
}
/**
* Initialize the pinned messages card with the given pinned messages.
* Return the room, testing library helpers and functions to add and remove pinned messages.
* @param localPins
* @param nonLocalPins
*/
async function initPinnedMessagesCard(localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]) {
const room = mkRoom(localPins, nonLocalPins);
const addLocalPinEvent = async (event: MatrixEvent) => {
localPins.push(event);
await emitPinUpdate(room);
};
const removeLastLocalPinEvent = async () => {
localPins.pop();
await emitPinUpdate(room);
};
const addNonLocalPinEvent = async (event: MatrixEvent) => {
nonLocalPins.push(event);
await emitPinUpdate(room);
};
const removeLastNonLocalPinEvent = async () => {
nonLocalPins.pop();
await emitPinUpdate(room);
};
const renderResult = await renderMessagePinList(room);
return {
...renderResult,
addLocalPinEvent,
removeLastLocalPinEvent,
addNonLocalPinEvent,
removeLastNonLocalPinEvent,
room,
};
}
const pin1 = mkMessage({
event: true,
@ -129,75 +174,66 @@ describe("<PinnedMessagesCard />", () => {
msg: "The second one",
});
it("updates when messages are pinned", async () => {
it("should show spinner whilst loading", async () => {
const room = mkRoom([], [pin1]);
render(
<MatrixClientContext.Provider value={cli}>
<PinnedMessagesCard
room={room}
onClose={jest.fn()}
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
/>
</MatrixClientContext.Provider>,
);
await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar"));
});
it("should show the empty state when there are no pins", async () => {
const { asFragment } = await initPinnedMessagesCard([], []);
expect(screen.getByText("Pin important messages so that they can be easily discovered")).toBeInTheDocument();
expect(asFragment()).toMatchSnapshot();
});
it("should show two pinned messages", async () => {
//const room = mkRoom([pin1], [pin2]);
const { asFragment } = await initPinnedMessagesCard([pin1], [pin2]);
expect(screen.queryAllByRole("listitem")).toHaveLength(2);
expect(asFragment()).toMatchSnapshot();
});
it("should updates when messages are pinned", async () => {
// Start with nothing pinned
const localPins: MatrixEvent[] = [];
const nonLocalPins: MatrixEvent[] = [];
const room = mkRoom(localPins, nonLocalPins);
const pins = await mountPins(room);
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
const { addLocalPinEvent, addNonLocalPinEvent } = await initPinnedMessagesCard([], []);
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
// Pin the first message
localPins.push(pin1);
await emitPinUpdates(room);
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(1);
await addLocalPinEvent(pin1);
expect(screen.getAllByRole("listitem")).toHaveLength(1);
// Pin the second message
nonLocalPins.push(pin2);
await emitPinUpdates(room);
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(2);
await addNonLocalPinEvent(pin2);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
});
it("updates when messages are unpinned", async () => {
it("should updates when messages are unpinned", async () => {
// Start with two pins
const localPins = [pin1];
const nonLocalPins = [pin2];
const room = mkRoom(localPins, nonLocalPins);
const pins = await mountPins(room);
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(2);
const { removeLastLocalPinEvent, removeLastNonLocalPinEvent } = await initPinnedMessagesCard([pin1], [pin2]);
expect(screen.getAllByRole("listitem")).toHaveLength(2);
// Unpin the first message
localPins.pop();
await emitPinUpdates(room);
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(1);
await removeLastLocalPinEvent();
expect(screen.getAllByRole("listitem")).toHaveLength(1);
// Unpin the second message
nonLocalPins.pop();
await emitPinUpdates(room);
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
await removeLastNonLocalPinEvent();
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
it("hides unpinnable events found in local timeline", async () => {
// Redacted messages are unpinnable
const pin = mkEvent({
event: true,
type: EventType.RoomMessage,
content: {},
unsigned: { redacted_because: {} as unknown as IEvent },
room: "!room:example.org",
user: "@alice:example.org",
});
const pins = await mountPins(mkRoom([pin], []));
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
});
it("hides unpinnable events not found in local timeline", async () => {
// Redacted messages are unpinnable
const pin = mkEvent({
event: true,
type: EventType.RoomMessage,
content: {},
unsigned: { redacted_because: {} as unknown as IEvent },
room: "!room:example.org",
user: "@alice:example.org",
});
const pins = await mountPins(mkRoom([], [pin]));
expect(pins.container.querySelectorAll(".mx_PinnedEventTile")).toHaveLength(0);
});
it("accounts for edits", async () => {
it("should display an edited pinned event", async () => {
const messageEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
@ -221,13 +257,78 @@ describe("<PinnedMessagesCard />", () => {
events: [messageEvent],
});
const pins = await mountPins(mkRoom([], [pin1]));
const pinTile = pins.container.querySelectorAll(".mx_PinnedEventTile");
expect(pinTile.length).toBe(1);
expect(pinTile[0].querySelector(".mx_EventTile_body")!).toHaveTextContent("First pinned message, edited");
await initPinnedMessagesCard([], [pin1]);
expect(screen.getByText("First pinned message, edited")).toBeInTheDocument();
});
it("displays votes on polls not found in local timeline", async () => {
describe("unpinnable event", () => {
it("should hide unpinnable events found in local timeline", async () => {
// Redacted messages are unpinnable
const pin = mkEvent({
event: true,
type: EventType.RoomMessage,
content: {},
unsigned: { redacted_because: {} as unknown as IEvent },
room: "!room:example.org",
user: "@alice:example.org",
});
await initPinnedMessagesCard([pin], []);
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
it("hides unpinnable events not found in local timeline", async () => {
// Redacted messages are unpinnable
const pin = mkEvent({
event: true,
type: EventType.RoomMessage,
content: {},
unsigned: { redacted_because: {} as unknown as IEvent },
room: "!room:example.org",
user: "@alice:example.org",
});
await initPinnedMessagesCard([], [pin]);
expect(screen.queryAllByRole("listitem")).toHaveLength(0);
});
});
describe("unpin all", () => {
it("should not allow to unpinall", async () => {
const room = mkRoom([pin1], [pin2]);
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"mayClientSendStateEvent",
).mockReturnValue(false);
const { asFragment } = render(
<MatrixClientContext.Provider value={cli}>
<PinnedMessagesCard
room={room}
onClose={jest.fn()}
permalinkCreator={new RoomPermalinkCreator(room, room.roomId)}
/>
</MatrixClientContext.Provider>,
);
// Wait a tick for state updates
await act(() => sleep(0));
expect(screen.queryByText("Unpin all messages")).toBeNull();
expect(asFragment()).toMatchSnapshot();
});
it("should allow unpinning all messages", async () => {
jest.spyOn(Modal, "createDialog");
const { room } = await initPinnedMessagesCard([pin1], [pin2]);
expect(screen.getByText("Unpin all messages")).toBeInTheDocument();
await userEvent.click(screen.getByText("Unpin all messages"));
// Should open the UnpinAllDialog dialog
expect(Modal.createDialog).toHaveBeenCalledWith(UnpinAllDialog, { roomId: room.roomId, matrixClient: cli });
});
});
it("should displays votes on polls not found in local timeline", async () => {
const poll = mkEvent({
...PollStartEvent.from("A poll", ["Option 1", "Option 2"], M_POLL_KIND_DISCLOSED).serialize(),
event: true,
@ -270,11 +371,8 @@ describe("<PinnedMessagesCard />", () => {
return { originalEvent: undefined as unknown as MatrixEvent, events: [] };
});
const room = mkRoom([], [poll]);
// poll end event validates against this
jest.spyOn(room.currentState, "maySendRedactionForEvent").mockReturnValue(true);
const { room } = await initPinnedMessagesCard([], [poll]);
const pins = await mountPins(room);
// two pages of results
await flushPromises();
await flushPromises();
@ -282,34 +380,12 @@ describe("<PinnedMessagesCard />", () => {
const pollInstance = room.polls.get(poll.getId()!);
expect(pollInstance).toBeTruthy();
const pinTile = pins.container.querySelectorAll(".mx_MPollBody");
expect(screen.getByText("A poll")).toBeInTheDocument();
expect(pinTile).toHaveLength(1);
expect(pinTile[0].querySelectorAll(".mx_PollOption_ended")).toHaveLength(2);
expect(pinTile[0].querySelectorAll(".mx_PollOption_optionVoteCount")[0]).toHaveTextContent("2 votes");
expect([...pinTile[0].querySelectorAll(".mx_PollOption_optionVoteCount")].at(-1)).toHaveTextContent("1 vote");
});
expect(screen.getByText("Option 1")).toBeInTheDocument();
expect(screen.getByText("2 votes")).toBeInTheDocument();
it("should allow admins to unpin messages", async () => {
const nonLocalPins = [pin1];
const room = mkRoom([], nonLocalPins);
jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true);
const sendStateEvent = jest.spyOn(cli, "sendStateEvent");
const pins = await mountPins(room);
const pinTile = pins.container.querySelectorAll(".mx_PinnedEventTile");
expect(pinTile).toHaveLength(1);
fireEvent.click(pinTile[0].querySelector(".mx_PinnedEventTile_unpinButton")!);
expect(sendStateEvent).toHaveBeenCalledWith(room.roomId, "m.room.pinned_events", { pinned: [] }, "");
nonLocalPins.pop();
await Promise.all([waitForElementToBeRemoved(pinTile[0]), emitPinUpdates(room)]);
});
it("should show spinner whilst loading", async () => {
const room = mkRoom([], [pin1]);
mountPins(room);
await waitForElementToBeRemoved(() => screen.queryAllByRole("progressbar"));
expect(screen.getByText("Option 2")).toBeInTheDocument();
expect(screen.getByText("1 vote")).toBeInTheDocument();
});
});

View file

@ -0,0 +1,457 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<PinnedMessagesCard /> should show the empty state when there are no pins 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_PinnedMessagesCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<h4
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
>
Pinned message
</h4>
</div>
<button
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_Flex mx_EmptyState"
style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: center; --mx-flex-justify: center; --mx-flex-gap: var(--cpd-space-4x);"
>
<svg
fill="currentColor"
height="32px"
viewBox="0 0 24 24"
width="32px"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M6.119 2a.5.5 0 0 0-.35.857L7.85 4.9a.5.5 0 0 1 .15.357v4.487a.5.5 0 0 1-.15.356l-3.7 3.644A.5.5 0 0 0 4 14.1v1.4a.5.5 0 0 0 .5.5H11v6a1 1 0 1 0 2 0v-6h6.5a.5.5 0 0 0 .5-.5v-1.4a.5.5 0 0 0-.15-.356l-3.7-3.644a.5.5 0 0 1-.15-.356V5.257a.5.5 0 0 1 .15-.357l2.081-2.043a.5.5 0 0 0-.35-.857H6.119ZM10 4h4v5.744a2.5 2.5 0 0 0 .746 1.781L17.26 14H6.74l2.514-2.475A2.5 2.5 0 0 0 10 9.744V4Z"
fill-rule="evenodd"
/>
</svg>
<p
class="_typography_yh5dq_162 _font-body-lg-semibold_yh5dq_83"
>
Pin important messages so that they can be easily discovered
</p>
<p
class="_typography_yh5dq_162 _font-body-md-regular_yh5dq_59"
>
Select a message and choose “Pin” to it include here.
</p>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<PinnedMessagesCard /> should show two pinned messages 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_PinnedMessagesCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<h4
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
>
2 Pinned messages
</h4>
</div>
<button
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_PinnedMessagesCard_wrapper mx_PinnedMessagesCard_wrapper_unpin_all"
role="list"
>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_bh2qc_17"
data-state="closed"
id="radix-2"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
The second one
</div>
</div>
</div>
</div>
<div
class="_separator_144s5_17 mx_PinnedMessagesCard_Separator"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_bh2qc_17"
data-state="closed"
id="radix-3"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
First pinned message
</div>
</div>
</div>
</div>
</div>
<div
class="mx_PinnedMessagesCard_unpin"
>
<button
class="_button_zt6rp_17"
data-kind="tertiary"
data-size="lg"
role="button"
tabindex="0"
>
Unpin all messages
</button>
</div>
</div>
</div>
</DocumentFragment>
`;
exports[`<PinnedMessagesCard /> unpin all should not allow to unpinall 1`] = `
<DocumentFragment>
<div
class="mx_BaseCard mx_PinnedMessagesCard"
>
<div
class="mx_BaseCard_header"
>
<div
class="mx_BaseCard_header_title"
>
<h4
class="mx_Heading_h4 mx_BaseCard_header_title_heading"
>
2 Pinned messages
</h4>
</div>
<button
class="_icon-button_bh2qc_17 _subtle-bg_bh2qc_38"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414Z"
/>
</svg>
</div>
</button>
</div>
<div
class="mx_AutoHideScrollbar"
tabindex="-1"
>
<div
class="mx_PinnedMessagesCard_wrapper"
role="list"
>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_bh2qc_17"
data-state="closed"
id="radix-18"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
The second one
</div>
</div>
</div>
</div>
<div
class="_separator_144s5_17 mx_PinnedMessagesCard_Separator"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<div>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
data-color="3"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color3"
>
@alice:example.org
</span>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_bh2qc_17"
data-state="closed"
id="radix-19"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
>
<div
class="mx_EventTile_body translate"
dir="auto"
>
First pinned message
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</DocumentFragment>
`;

View file

@ -15,32 +15,44 @@ limitations under the License.
*/
import React from "react";
import { render } from "@testing-library/react";
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { render, screen, waitFor } from "@testing-library/react";
import { EventTimeline, EventType, IEvent, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import userEvent from "@testing-library/user-event";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
import PinnedEventTile from "../../../../src/components/views/rooms/PinnedEventTile";
import { getMockClientWithEventEmitter } from "../../../test-utils";
import { PinnedEventTile } from "../../../../src/components/views/rooms/PinnedEventTile";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import { stubClient } from "../../../test-utils";
import dis from "../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../src/dispatcher/actions";
import { getForwardableEvent } from "../../../../src/events";
import { createRedactEventDialog } from "../../../../src/components/views/dialogs/ConfirmRedactDialog";
jest.mock("../../../../src/components/views/dialogs/ConfirmRedactDialog", () => ({
createRedactEventDialog: jest.fn(),
}));
describe("<PinnedEventTile />", () => {
const userId = "@alice:server.org";
const roomId = "!room:server.org";
const mockClient = getMockClientWithEventEmitter({
getRoom: jest.fn(),
});
const room = new Room(roomId, mockClient, userId);
const permalinkCreator = new RoomPermalinkCreator(room);
const getComponent = (event: MatrixEvent) =>
render(<PinnedEventTile permalinkCreator={permalinkCreator} event={event} />);
let mockClient: MatrixClient;
let room: Room;
let permalinkCreator: RoomPermalinkCreator;
beforeEach(() => {
mockClient.getRoom.mockReturnValue(room);
mockClient = stubClient();
room = new Room(roomId, mockClient, userId);
permalinkCreator = new RoomPermalinkCreator(room);
jest.spyOn(dis, "dispatch").mockReturnValue(undefined);
});
it("should render pinned event", () => {
const pin1 = new MatrixEvent({
type: "m.room.message",
/**
* Create a pinned event with the given content.
* @param content
*/
function makePinEvent(content?: Partial<IEvent>) {
return new MatrixEvent({
type: EventType.RoomMessage,
sender: userId,
content: {
body: "First pinned message",
@ -48,25 +60,150 @@ describe("<PinnedEventTile />", () => {
},
room_id: roomId,
origin_server_ts: 0,
event_id: "$eventId",
...content,
});
}
const { container } = getComponent(pin1);
/**
* Render the component with the given event.
* @param event - pinned event
*/
function renderComponent(event: MatrixEvent) {
return render(
<MatrixClientContext.Provider value={mockClient}>
<PinnedEventTile permalinkCreator={permalinkCreator} event={event} room={room} />
</MatrixClientContext.Provider>,
);
}
/**
* Render the component and open the menu.
*/
async function renderAndOpenMenu() {
const pinEvent = makePinEvent();
const renderResult = renderComponent(pinEvent);
await userEvent.click(screen.getByRole("button", { name: "Open menu" }));
return { pinEvent, renderResult };
}
it("should throw when pinned event has no sender", () => {
const pinEventWithoutSender = makePinEvent({ sender: undefined });
expect(() => renderComponent(pinEventWithoutSender)).toThrow("Pinned event unexpectedly has no sender");
});
it("should render pinned event", () => {
const { container } = renderComponent(makePinEvent());
expect(container).toMatchSnapshot();
});
it("should throw when pinned event has no sender", () => {
const pin1 = new MatrixEvent({
type: "m.room.message",
sender: undefined,
content: {
body: "First pinned message",
msgtype: "m.text",
},
room_id: roomId,
origin_server_ts: 0,
});
it("should render the menu without unpin and delete", async () => {
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
false,
);
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"maySendRedactionForEvent",
).mockReturnValue(false);
expect(() => getComponent(pin1)).toThrow("Pinned event unexpectedly has no sender");
await renderAndOpenMenu();
// Unpin and delete should not be present
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
expect(screen.getByRole("menuitem", { name: "View in timeline" })).toBeInTheDocument();
expect(screen.getByRole("menuitem", { name: "Forward" })).toBeInTheDocument();
expect(screen.queryByRole("menuitem", { name: "Unpin" })).toBeNull();
expect(screen.queryByRole("menuitem", { name: "Delete" })).toBeNull();
expect(screen.getByRole("menu")).toMatchSnapshot();
});
it("should render the menu with all the options", async () => {
// Enable unpin
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
true,
);
// Enable redaction
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"maySendRedactionForEvent",
).mockReturnValue(true);
await renderAndOpenMenu();
await waitFor(() => expect(screen.getByRole("menu")).toBeInTheDocument());
["View in timeline", "Forward", "Unpin", "Delete"].forEach((name) =>
expect(screen.getByRole("menuitem", { name })).toBeInTheDocument(),
);
expect(screen.getByRole("menu")).toMatchSnapshot();
});
it("should view in the timeline", async () => {
const { pinEvent } = await renderAndOpenMenu();
// Test view in timeline button
await userEvent.click(screen.getByRole("menuitem", { name: "View in timeline" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.ViewRoom,
event_id: pinEvent.getId(),
highlighted: true,
room_id: pinEvent.getRoomId(),
metricsTrigger: undefined, // room doesn't change
});
});
it("should open forward dialog", async () => {
const { pinEvent } = await renderAndOpenMenu();
// Test forward button
await userEvent.click(screen.getByRole("menuitem", { name: "Forward" }));
expect(dis.dispatch).toHaveBeenCalledWith({
action: Action.OpenForwardDialog,
event: getForwardableEvent(pinEvent, mockClient),
permalinkCreator: permalinkCreator,
});
});
it("should unpin the event", async () => {
const { pinEvent } = await renderAndOpenMenu();
const pinEvent2 = makePinEvent({ event_id: "$eventId2" });
const stateEvent = {
getContent: jest.fn().mockReturnValue({ pinned: [pinEvent.getId(), pinEvent2.getId()] }),
} as unknown as MatrixEvent;
// Enable unpin
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "mayClientSendStateEvent").mockReturnValue(
true,
);
// Mock the state event
jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockReturnValue(
stateEvent,
);
// Test unpin button
await userEvent.click(screen.getByRole("menuitem", { name: "Unpin" }));
expect(mockClient.sendStateEvent).toHaveBeenCalledWith(
room.roomId,
EventType.RoomPinnedEvents,
{
pinned: [pinEvent2.getId()],
},
"",
);
});
it("should delete the event", async () => {
// Enable redaction
jest.spyOn(
room.getLiveTimeline().getState(EventTimeline.FORWARDS)!,
"maySendRedactionForEvent",
).mockReturnValue(true);
const { pinEvent } = await renderAndOpenMenu();
await userEvent.click(screen.getByRole("menuitem", { name: "Delete" }));
expect(createRedactEventDialog).toHaveBeenCalledWith({
mxEvent: pinEvent,
});
});
});

View file

@ -4,25 +4,52 @@ exports[`<PinnedEventTile /> should render pinned event 1`] = `
<div>
<div
class="mx_PinnedEventTile"
role="listitem"
>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 24px;"
>
a
</span>
<span
class="mx_PinnedEventTile_sender mx_Username_color2"
>
@alice:server.org
</span>
<div>
<span
class="_avatar_mcap2_17 mx_BaseAvatar mx_PinnedEventTile_senderAvatar _avatar-imageless_mcap2_61"
data-color="2"
data-testid="avatar-img"
data-type="round"
role="presentation"
style="--cpd-avatar-size: 32px;"
>
a
</span>
</div>
<div
class="mx_PinnedEventTile_message"
class="mx_PinnedEventTile_wrapper"
>
<div
class="mx_PinnedEventTile_top"
>
<span
class="_typography_yh5dq_162 _font-body-md-semibold_yh5dq_64 mx_PinnedEventTile_sender mx_Username_color2"
>
@alice:server.org
</span>
<button
aria-disabled="false"
aria-expanded="false"
aria-haspopup="menu"
aria-label="Open menu"
class="_icon-button_bh2qc_17"
data-state="closed"
id="radix-0"
role="button"
style="--cpd-icon-button-size: 24px;"
tabindex="0"
type="button"
>
<div
class="_indicator-icon_133tf_26"
style="--cpd-icon-button-size: 100%;"
>
<div />
</div>
</button>
</div>
<div
class="mx_MTextBody mx_EventTile_content"
>
@ -34,22 +61,250 @@ exports[`<PinnedEventTile /> should render pinned event 1`] = `
</div>
</div>
</div>
<div
class="mx_PinnedEventTile_footer"
>
<span
class="mx_MessageTimestamp mx_PinnedEventTile_timestamp"
>
Thu, Jan 1, 1970, 00:00
</span>
<div
class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_link"
role="button"
tabindex="0"
>
View message
</div>
</div>
</div>
</div>
`;
exports[`<PinnedEventTile /> should render the menu with all the options 1`] = `
<div
aria-label="Open menu"
aria-labelledby="radix-6"
aria-orientation="vertical"
class="_menu_1x5h1_17"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="right"
data-state="open"
dir="ltr"
id="radix-7"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
tabindex="-1"
>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
View in timeline
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Unpin
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Forward
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
<div
class="_separator_144s5_17"
data-kind="primary"
data-orientation="horizontal"
role="separator"
/>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="critical"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Delete
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
</div>
`;
exports[`<PinnedEventTile /> should render the menu without unpin and delete 1`] = `
<div
aria-label="Open menu"
aria-labelledby="radix-2"
aria-orientation="vertical"
class="_menu_1x5h1_17"
data-align="start"
data-orientation="vertical"
data-radix-menu-content=""
data-side="right"
data-state="open"
dir="ltr"
id="radix-3"
role="menu"
style="outline: none; --radix-dropdown-menu-content-transform-origin: var(--radix-popper-transform-origin); --radix-dropdown-menu-content-available-width: var(--radix-popper-available-width); --radix-dropdown-menu-content-available-height: var(--radix-popper-available-height); --radix-dropdown-menu-trigger-width: var(--radix-popper-anchor-width); --radix-dropdown-menu-trigger-height: var(--radix-popper-anchor-height); pointer-events: auto;"
tabindex="-1"
>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
View in timeline
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
<button
class="_item_1gwvj_17 _interactive_1gwvj_36"
data-kind="primary"
data-orientation="vertical"
data-radix-collection-item=""
role="menuitem"
tabindex="-1"
>
<div
aria-hidden="true"
class="_icon_1gwvj_44"
height="24"
width="24"
/>
<span
class="_typography_yh5dq_162 _font-body-md-medium_yh5dq_69 _label_1gwvj_53"
>
Forward
</span>
<svg
aria-hidden="true"
class="_nav-hint_1gwvj_60"
fill="currentColor"
height="24"
viewBox="8 0 8 24"
width="8"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M8.7 17.3a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7l3.9-3.9-3.9-3.9a.948.948 0 0 1-.275-.7.95.95 0 0 1 .275-.7.948.948 0 0 1 .7-.275.95.95 0 0 1 .7.275l4.6 4.6c.1.1.17.208.213.325.041.117.062.242.062.375s-.02.258-.063.375a.877.877 0 0 1-.212.325l-4.6 4.6a.948.948 0 0 1-.7.275.948.948 0 0 1-.7-.275Z"
/>
</svg>
</button>
</div>
`;