Merge branch 'develop' into johannes/latest-room-in-space

This commit is contained in:
Johannes Marbach 2023-02-01 19:54:40 +01:00
commit 3766b39361
119 changed files with 4636 additions and 1409 deletions

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -15,33 +15,106 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { Room, RoomMember, RoomType } from "matrix-js-sdk/src/matrix";
import { Room, RoomMember, RoomType, User } from "matrix-js-sdk/src/matrix";
import { avatarUrlForRoom } from "../src/Avatar";
import { Media, mediaFromMxc } from "../src/customisations/Media";
import {
avatarUrlForMember,
avatarUrlForRoom,
avatarUrlForUser,
defaultAvatarUrlForString,
getColorForString,
getInitialLetter,
} from "../src/Avatar";
import { mediaFromMxc } from "../src/customisations/Media";
import DMRoomMap from "../src/utils/DMRoomMap";
jest.mock("../src/customisations/Media", () => ({
mediaFromMxc: jest.fn(),
}));
import { filterConsole, stubClient } from "./test-utils";
const roomId = "!room:example.com";
const avatarUrl1 = "https://example.com/avatar1";
const avatarUrl2 = "https://example.com/avatar2";
describe("avatarUrlForMember", () => {
let member: RoomMember;
beforeEach(() => {
stubClient();
member = new RoomMember(roomId, "@user:example.com");
});
it("returns the member's url", () => {
const mxc = "mxc://example.com/a/b/c/d/avatar.gif";
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(mxc);
expect(avatarUrlForMember(member, 32, 32, "crop")).toBe(
mediaFromMxc(mxc).getThumbnailOfSourceHttp(32, 32, "crop"),
);
});
it("returns a default if the member has no avatar", () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(undefined);
expect(avatarUrlForMember(member, 32, 32, "crop")).toMatch(/^data:/);
});
});
describe("avatarUrlForUser", () => {
let user: User;
beforeEach(() => {
stubClient();
user = new User("@user:example.com");
});
it("should return the user's avatar", () => {
const mxc = "mxc://example.com/a/b/c/d/avatar.gif";
user.avatarUrl = mxc;
expect(avatarUrlForUser(user, 64, 64, "scale")).toBe(
mediaFromMxc(mxc).getThumbnailOfSourceHttp(64, 64, "scale"),
);
});
it("should not provide a fallback", () => {
expect(avatarUrlForUser(user, 64, 64, "scale")).toBeNull();
});
});
describe("defaultAvatarUrlForString", () => {
it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => {
expect(defaultAvatarUrlForString(s)).not.toBe("");
});
});
describe("getColorForString", () => {
it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => {
expect(getColorForString(s)).toMatch(/^#\w+$/);
});
it("should return different values for different strings", () => {
expect(getColorForString("a")).not.toBe(getColorForString("b"));
});
});
describe("getInitialLetter", () => {
filterConsole("argument to `getInitialLetter` not supplied");
it.each(["a", "abc", "abcde", "@".repeat(150)])("should return a value for %s", (s) => {
expect(getInitialLetter(s)).not.toBe("");
});
it("should return undefined for empty strings", () => {
expect(getInitialLetter("")).toBeUndefined();
});
});
describe("avatarUrlForRoom", () => {
let getThumbnailOfSourceHttp: jest.Mock;
let room: Room;
let roomMember: RoomMember;
let dmRoomMap: DMRoomMap;
beforeEach(() => {
getThumbnailOfSourceHttp = jest.fn();
mocked(mediaFromMxc).mockImplementation((): Media => {
return {
getThumbnailOfSourceHttp,
} as unknown as Media;
});
stubClient();
room = {
roomId,
getMxcAvatarUrl: jest.fn(),
@ -59,14 +132,14 @@ describe("avatarUrlForRoom", () => {
});
it("should return null for a null room", () => {
expect(avatarUrlForRoom(null, 128, 128)).toBeNull();
expect(avatarUrlForRoom(undefined, 128, 128)).toBeNull();
});
it("should return the HTTP source if the room provides a MXC url", () => {
mocked(room.getMxcAvatarUrl).mockReturnValue(avatarUrl1);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
expect(avatarUrlForRoom(room, 128, 256, "crop")).toBe(
mediaFromMxc(avatarUrl1).getThumbnailOfSourceHttp(128, 256, "crop"),
);
});
it("should return null for a space room", () => {
@ -83,7 +156,7 @@ describe("avatarUrlForRoom", () => {
it("should return null if there is no other member in the room", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(null);
mocked(room.getAvatarFallbackMember).mockReturnValue(undefined);
expect(avatarUrlForRoom(room, 128, 128)).toBeNull();
});
@ -97,8 +170,8 @@ describe("avatarUrlForRoom", () => {
mocked(dmRoomMap).getUserIdForRoomId.mockReturnValue("@user:example.com");
mocked(room.getAvatarFallbackMember).mockReturnValue(roomMember);
mocked(roomMember.getMxcAvatarUrl).mockReturnValue(avatarUrl2);
getThumbnailOfSourceHttp.mockReturnValue(avatarUrl2);
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(avatarUrl2);
expect(getThumbnailOfSourceHttp).toHaveBeenCalledWith(128, 256, "crop");
expect(avatarUrlForRoom(room, 128, 256, "crop")).toEqual(
mediaFromMxc(avatarUrl2).getThumbnailOfSourceHttp(128, 256, "crop"),
);
});
});

View file

@ -109,7 +109,7 @@ describe("Notifier", () => {
decryptEventIfNeeded: jest.fn(),
getRoom: jest.fn(),
getPushActionsForEvent: jest.fn(),
supportsExperimentalThreads: jest.fn().mockReturnValue(false),
supportsThreads: jest.fn().mockReturnValue(false),
});
mockClient.pushRules = {

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -15,21 +15,29 @@ limitations under the License.
*/
import { mocked } from "jest-mock";
import { ConditionKind, PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules";
import { PushRuleActionName, TweakName } from "matrix-js-sdk/src/@types/PushRules";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { EventStatus, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { mkEvent, stubClient } from "./test-utils";
import { MatrixClientPeg } from "../src/MatrixClientPeg";
import { getRoomNotifsState, RoomNotifState, getUnreadNotificationCount } from "../src/RoomNotifs";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { mkEvent, mkRoom, muteRoom, stubClient } from "./test-utils";
import {
getRoomNotifsState,
RoomNotifState,
getUnreadNotificationCount,
determineUnreadState,
} from "../src/RoomNotifs";
import { NotificationColor } from "../src/stores/notifications/NotificationColor";
describe("RoomNotifs test", () => {
let client: jest.Mocked<MatrixClient>;
beforeEach(() => {
stubClient();
client = stubClient() as jest.Mocked<MatrixClient>;
});
it("getRoomNotifsState handles rules with no conditions", () => {
const cli = MatrixClientPeg.get();
mocked(cli).pushRules = {
mocked(client).pushRules = {
global: {
override: [
{
@ -41,70 +49,47 @@ describe("RoomNotifs test", () => {
],
},
};
expect(getRoomNotifsState(cli, "!roomId:server")).toBe(null);
expect(getRoomNotifsState(client, "!roomId:server")).toBe(null);
});
it("getRoomNotifsState handles guest users", () => {
const cli = MatrixClientPeg.get();
mocked(cli).isGuest.mockReturnValue(true);
expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.AllMessages);
mocked(client).isGuest.mockReturnValue(true);
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessages);
});
it("getRoomNotifsState handles mute state", () => {
const cli = MatrixClientPeg.get();
cli.pushRules = {
global: {
override: [
{
rule_id: "!roomId:server",
enabled: true,
default: false,
conditions: [
{
kind: ConditionKind.EventMatch,
key: "room_id",
pattern: "!roomId:server",
},
],
actions: [PushRuleActionName.DontNotify],
},
],
},
};
expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.Mute);
const room = mkRoom(client, "!roomId:server");
muteRoom(room);
expect(getRoomNotifsState(client, room.roomId)).toBe(RoomNotifState.Mute);
});
it("getRoomNotifsState handles mentions only", () => {
const cli = MatrixClientPeg.get();
cli.getRoomPushRule = () => ({
(client as any).getRoomPushRule = () => ({
rule_id: "!roomId:server",
enabled: true,
default: false,
actions: [PushRuleActionName.DontNotify],
});
expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.MentionsOnly);
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.MentionsOnly);
});
it("getRoomNotifsState handles noisy", () => {
const cli = MatrixClientPeg.get();
cli.getRoomPushRule = () => ({
(client as any).getRoomPushRule = () => ({
rule_id: "!roomId:server",
enabled: true,
default: false,
actions: [{ set_tweak: TweakName.Sound, value: "default" }],
});
expect(getRoomNotifsState(cli, "!roomId:server")).toBe(RoomNotifState.AllMessagesLoud);
expect(getRoomNotifsState(client, "!roomId:server")).toBe(RoomNotifState.AllMessagesLoud);
});
describe("getUnreadNotificationCount", () => {
const ROOM_ID = "!roomId:example.org";
const THREAD_ID = "$threadId";
let cli;
let room: Room;
beforeEach(() => {
cli = MatrixClientPeg.get();
room = new Room(ROOM_ID, cli, cli.getUserId());
room = new Room(ROOM_ID, client, client.getUserId()!);
});
it("counts room notification type", () => {
@ -125,19 +110,19 @@ describe("RoomNotifs test", () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 1);
const OLD_ROOM_ID = "!oldRoomId:example.org";
const oldRoom = new Room(OLD_ROOM_ID, cli, cli.getUserId());
const oldRoom = new Room(OLD_ROOM_ID, client, client.getUserId()!);
oldRoom.setUnreadNotificationCount(NotificationCountType.Total, 10);
oldRoom.setUnreadNotificationCount(NotificationCountType.Highlight, 6);
cli.getRoom.mockReset().mockReturnValue(oldRoom);
client.getRoom.mockReset().mockReturnValue(oldRoom);
const predecessorEvent = mkEvent({
event: true,
type: "m.room.create",
room: ROOM_ID,
user: cli.getUserId(),
user: client.getUserId()!,
content: {
creator: cli.getUserId(),
creator: client.getUserId(),
room_version: "5",
predecessor: {
room_id: OLD_ROOM_ID,
@ -165,4 +150,78 @@ describe("RoomNotifs test", () => {
expect(getUnreadNotificationCount(room, NotificationCountType.Highlight, THREAD_ID)).toBe(1);
});
});
describe("determineUnreadState", () => {
let room: Room;
beforeEach(() => {
room = new Room("!room-id:example.com", client, "@user:example.com", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
});
it("shows nothing by default", async () => {
const { color, symbol, count } = determineUnreadState(room);
expect(symbol).toBe(null);
expect(color).toBe(NotificationColor.None);
expect(count).toBe(0);
});
it("indicates if there are unsent messages", async () => {
const event = mkEvent({
event: true,
type: "m.message",
user: "@user:example.org",
content: {},
});
event.status = EventStatus.NOT_SENT;
room.addPendingEvent(event, "txn");
const { color, symbol, count } = determineUnreadState(room);
expect(symbol).toBe("!");
expect(color).toBe(NotificationColor.Unsent);
expect(count).toBeGreaterThan(0);
});
it("indicates the user has been invited to a channel", async () => {
room.updateMyMembership("invite");
const { color, symbol, count } = determineUnreadState(room);
expect(symbol).toBe("!");
expect(color).toBe(NotificationColor.Red);
expect(count).toBeGreaterThan(0);
});
it("shows nothing for muted channels", async () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 99);
room.setUnreadNotificationCount(NotificationCountType.Total, 99);
muteRoom(room);
const { color, count } = determineUnreadState(room);
expect(color).toBe(NotificationColor.None);
expect(count).toBe(0);
});
it("uses the correct number of unreads", async () => {
room.setUnreadNotificationCount(NotificationCountType.Total, 999);
const { color, count } = determineUnreadState(room);
expect(color).toBe(NotificationColor.Grey);
expect(count).toBe(999);
});
it("uses the correct number of highlights", async () => {
room.setUnreadNotificationCount(NotificationCountType.Highlight, 888);
const { color, count } = determineUnreadState(room);
expect(color).toBe(NotificationColor.Red);
expect(count).toBe(888);
});
});
});

View file

@ -124,7 +124,7 @@ describe("Unread", () => {
const myId = client.getUserId()!;
beforeAll(() => {
client.supportsExperimentalThreads = () => true;
client.supportsThreads = () => true;
});
beforeEach(() => {

View file

@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import React, { MouseEventHandler } from "react";
import { screen, render, RenderResult } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
@ -82,28 +82,39 @@ describe("PictureInPictureDragger", () => {
});
});
it("doesn't leak drag events to children as clicks", async () => {
const clickSpy = jest.fn();
render(
<PictureInPictureDragger draggable={true}>
{[
({ onStartMoving }) => (
<div onMouseDown={onStartMoving} onClick={clickSpy}>
Hello
</div>
),
]}
</PictureInPictureDragger>,
);
const target = screen.getByText("Hello");
describe("when rendering the dragger", () => {
let clickSpy: jest.Mocked<MouseEventHandler>;
let target: HTMLElement;
// A click without a drag motion should go through
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { keys: "[/MouseLeft]" }]);
expect(clickSpy).toHaveBeenCalled();
beforeEach(() => {
clickSpy = jest.fn();
render(
<PictureInPictureDragger draggable={true}>
{[
({ onStartMoving }) => (
<div onMouseDown={onStartMoving} onClick={clickSpy}>
Hello
</div>
),
]}
</PictureInPictureDragger>,
);
target = screen.getByText("Hello");
});
// A drag motion should not trigger a click
clickSpy.mockClear();
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 60, y: 60 } }, "[/MouseLeft]"]);
expect(clickSpy).not.toHaveBeenCalled();
it("and clicking without a drag motion, it should pass the click to children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { keys: "[/MouseLeft]" }]);
expect(clickSpy).toHaveBeenCalled();
});
it("and clicking with a drag motion above the threshold of 5px, it should not pass the click to children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 60, y: 2 } }, "[/MouseLeft]"]);
expect(clickSpy).not.toHaveBeenCalled();
});
it("and clickign with a drag motion below the threshold of 5px, it should pass the click to the children", async () => {
await userEvent.pointer([{ keys: "[MouseLeft>]", target }, { coords: { x: 4, y: 4 } }, "[/MouseLeft]"]);
expect(clickSpy).toHaveBeenCalled();
});
});
});

View file

@ -48,7 +48,7 @@ describe("<RoomSearchView/>", () => {
beforeEach(async () => {
stubClient();
client = MatrixClientPeg.get();
client.supportsExperimentalThreads = jest.fn().mockReturnValue(true);
client.supportsThreads = jest.fn().mockReturnValue(true);
room = new Room("!room:server", client, client.getUserId());
mocked(client.getRoom).mockReturnValue(room);
permalinkCreator = new RoomPermalinkCreator(room, room.roomId);

View file

@ -26,53 +26,14 @@ import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../src/contexts/RoomContext";
import { _t } from "../../../src/languageHandler";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { shouldShowFeedback } from "../../../src/utils/Feedback";
import { RoomPermalinkCreator } from "../../../src/utils/permalinks/Permalinks";
import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { createTestClient, getRoomContext, mkStubRoom, mockPlatformPeg, stubClient } from "../../test-utils";
import { getRoomContext, mockPlatformPeg, stubClient } from "../../test-utils";
import { mkThread } from "../../test-utils/threads";
jest.mock("../../../src/utils/Feedback");
describe("ThreadPanel", () => {
describe("Feedback prompt", () => {
const cli = createTestClient();
const room = mkStubRoom("!room:server", "room", cli);
mocked(cli.getRoom).mockReturnValue(room);
it("should show feedback prompt if feedback is enabled", () => {
mocked(shouldShowFeedback).mockReturnValue(true);
render(
<MatrixClientContext.Provider value={cli}>
<ThreadPanel
roomId="!room:server"
onClose={jest.fn()}
resizeNotifier={new ResizeNotifier()}
permalinkCreator={new RoomPermalinkCreator(room)}
/>
</MatrixClientContext.Provider>,
);
expect(screen.queryByText("Give feedback")).toBeTruthy();
});
it("should hide feedback prompt if feedback is disabled", () => {
mocked(shouldShowFeedback).mockReturnValue(false);
render(
<MatrixClientContext.Provider value={cli}>
<ThreadPanel
roomId="!room:server"
onClose={jest.fn()}
resizeNotifier={new ResizeNotifier()}
permalinkCreator={new RoomPermalinkCreator(room)}
/>
</MatrixClientContext.Provider>,
);
expect(screen.queryByText("Give feedback")).toBeFalsy();
});
});
describe("Header", () => {
it("expect that All filter for ThreadPanelHeader properly renders Show: All threads", () => {
const { asFragment } = render(
@ -161,7 +122,7 @@ describe("ThreadPanel", () => {
Thread.setServerSideSupport(FeatureSupport.Stable);
Thread.setServerSideListSupport(FeatureSupport.Stable);
Thread.setServerSideFwdPaginationSupport(FeatureSupport.Stable);
jest.spyOn(mockClient, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true);
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,

View file

@ -117,7 +117,7 @@ describe("ThreadView", () => {
stubClient();
mockPlatformPeg();
mockClient = mocked(MatrixClientPeg.get());
jest.spyOn(mockClient, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(mockClient, "supportsThreads").mockReturnValue(true);
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,

View file

@ -362,7 +362,7 @@ describe("TimelinePanel", () => {
client = MatrixClientPeg.get();
Thread.hasServerSideSupport = FeatureSupport.Stable;
client.supportsExperimentalThreads = () => true;
client.supportsThreads = () => true;
const getValueCopy = SettingsStore.getValue;
SettingsStore.getValue = jest.fn().mockImplementation((name: string) => {
if (name === "feature_threadenabled") return true;
@ -524,7 +524,7 @@ describe("TimelinePanel", () => {
const client = MatrixClientPeg.get();
client.isRoomEncrypted = () => true;
client.supportsExperimentalThreads = () => true;
client.supportsThreads = () => true;
client.decryptEventIfNeeded = () => Promise.resolve();
const authorId = client.getUserId()!;
const room = new Room("roomId", client, authorId, {

View file

@ -20,22 +20,16 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 24px; height: 24px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span>
</div>
</div>
@ -119,22 +113,16 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 24px; height: 24px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span>
</div>
</div>
@ -215,23 +203,17 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar"
role="button"
style="width: 52px; height: 52px;"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 52px; height: 52px; font-size: 33.800000000000004px; line-height: 52px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 52px; height: 52px;"
/>
</span>
<h2>
@user:example.com
@ -314,22 +296,16 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 24px; height: 24px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span>
</div>
</div>
@ -410,23 +386,17 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar"
role="button"
style="width: 52px; height: 52px;"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 52px; height: 52px; font-size: 33.800000000000004px; line-height: 52px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 52px; height: 52px;"
/>
</span>
<h2>
@user:example.com
@ -581,22 +551,16 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 24px; height: 24px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 15.600000000000001px; width: 24px; line-height: 24px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 24px; height: 24px; font-size: 15.600000000000001px; line-height: 24px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 24px; height: 24px;"
/>
</span>
</div>
</div>
@ -672,23 +636,17 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar"
role="button"
style="width: 52px; height: 52px;"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 33.800000000000004px; width: 52px; line-height: 52px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 52px; height: 52px; font-size: 33.800000000000004px; line-height: 52px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 52px; height: 52px;"
/>
</span>
<h2>
@user:example.com

View file

@ -20,22 +20,16 @@ exports[`<UserMenu> when rendered should render as expected 1`] = `
<span
class="mx_BaseAvatar mx_UserMenu_userAvatar_BaseAvatar"
role="presentation"
style="width: 32px; height: 32px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(54, 139, 214); width: 32px; height: 32px; font-size: 20.8px; line-height: 32px;"
>
U
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span>
</div>
</div>

View file

@ -0,0 +1,201 @@
/*
Copyright 2023 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 { fireEvent, render } from "@testing-library/react";
import { ClientEvent, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import React from "react";
import { act } from "react-dom/test-utils";
import { SyncState } from "matrix-js-sdk/src/sync";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import RoomContext from "../../../../src/contexts/RoomContext";
import { getRoomContext } from "../../../test-utils/room";
import { stubClient } from "../../../test-utils/test-utils";
import BaseAvatar from "../../../../src/components/views/avatars/BaseAvatar";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
type Props = React.ComponentPropsWithoutRef<typeof BaseAvatar>;
describe("<BaseAvatar />", () => {
let client: MatrixClient;
let room: Room;
let member: RoomMember;
function getComponent(props: Partial<Props>) {
return (
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={getRoomContext(room, {})}>
<BaseAvatar name="" {...props} />
</RoomContext.Provider>
</MatrixClientContext.Provider>
);
}
function failLoadingImg(container: HTMLElement): void {
const img = container.querySelector<HTMLImageElement>("img")!;
expect(img).not.toBeNull();
act(() => {
fireEvent.error(img);
});
}
function emitReconnect(): void {
act(() => {
client.emit(ClientEvent.Sync, SyncState.Prepared, SyncState.Reconnecting);
});
}
beforeEach(() => {
client = stubClient();
room = new Room("!room:example.com", client, client.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
member = new RoomMember(room.roomId, "@bob:example.org");
jest.spyOn(room, "getMember").mockReturnValue(member);
});
it("renders with minimal properties", () => {
const { container } = render(getComponent({}));
expect(container.querySelector(".mx_BaseAvatar")).not.toBeNull();
});
it("matches snapshot (avatar)", () => {
const { container } = render(
getComponent({
name: "CoolUser22",
title: "Hover title",
url: "https://example.com/images/avatar.gif",
className: "mx_SomethingArbitrary",
}),
);
expect(container).toMatchSnapshot();
});
it("matches snapshot (avatar + click)", () => {
const { container } = render(
getComponent({
name: "CoolUser22",
title: "Hover title",
url: "https://example.com/images/avatar.gif",
className: "mx_SomethingArbitrary",
onClick: () => {},
}),
);
expect(container).toMatchSnapshot();
});
it("matches snapshot (no avatar)", () => {
const { container } = render(
getComponent({
name: "xX_Element_User_Xx",
title: ":kiss:",
defaultToInitialLetter: true,
className: "big-and-bold",
}),
);
expect(container).toMatchSnapshot();
});
it("matches snapshot (no avatar + click)", () => {
const { container } = render(
getComponent({
name: "xX_Element_User_Xx",
title: ":kiss:",
defaultToInitialLetter: true,
className: "big-and-bold",
onClick: () => {},
}),
);
expect(container).toMatchSnapshot();
});
it("uses fallback images", () => {
const images = [...Array(10)].map((_, i) => `https://example.com/images/${i}.webp`);
const { container } = render(
getComponent({
url: images[0],
urls: images.slice(1),
}),
);
for (const image of images) {
expect(container.querySelector("img")!.src).toBe(image);
failLoadingImg(container);
}
});
it("re-renders on reconnect", () => {
const primary = "https://example.com/image.jpeg";
const fallback = "https://example.com/fallback.png";
const { container } = render(
getComponent({
url: primary,
urls: [fallback],
}),
);
failLoadingImg(container);
expect(container.querySelector("img")!.src).toBe(fallback);
emitReconnect();
expect(container.querySelector("img")!.src).toBe(primary);
});
it("renders with an image", () => {
const url = "https://example.com/images/small/avatar.gif?size=realBig";
const { container } = render(getComponent({ url }));
const img = container.querySelector("img");
expect(img!.src).toBe(url);
});
it("renders the initial letter", () => {
const { container } = render(getComponent({ name: "Yellow", defaultToInitialLetter: true }));
const avatar = container.querySelector<HTMLSpanElement>(".mx_BaseAvatar_initial")!;
expect(avatar.innerHTML).toBe("Y");
});
it.each([{}, { name: "CoolUser22" }, { name: "XxElement_FanxX", defaultToInitialLetter: true }])(
"includes a click handler",
(props: Partial<Props>) => {
const onClick = jest.fn();
const { container } = render(
getComponent({
...props,
onClick,
}),
);
act(() => {
fireEvent.click(container.querySelector(".mx_BaseAvatar")!);
});
expect(onClick).toHaveBeenCalled();
},
);
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -14,19 +14,25 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { getByTestId, render, waitFor } from "@testing-library/react";
import { mocked } from "jest-mock";
import { fireEvent, getByTestId, render } from "@testing-library/react";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import React from "react";
import { act } from "react-dom/test-utils";
import MemberAvatar from "../../../../src/components/views/avatars/MemberAvatar";
import RoomContext from "../../../../src/contexts/RoomContext";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import { mediaFromMxc } from "../../../../src/customisations/Media";
import { ViewUserPayload } from "../../../../src/dispatcher/payloads/ViewUserPayload";
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
import { SettingLevel } from "../../../../src/settings/SettingLevel";
import SettingsStore from "../../../../src/settings/SettingsStore";
import { getRoomContext } from "../../../test-utils/room";
import { stubClient } from "../../../test-utils/test-utils";
import { Action } from "../../../../src/dispatcher/actions";
type Props = React.ComponentPropsWithoutRef<typeof MemberAvatar>;
describe("MemberAvatar", () => {
const ROOM_ID = "roomId";
@ -35,7 +41,7 @@ describe("MemberAvatar", () => {
let room: Room;
let member: RoomMember;
function getComponent(props) {
function getComponent(props: Partial<Props>) {
return (
<RoomContext.Provider value={getRoomContext(room, {})}>
<MemberAvatar member={null} width={35} height={35} {...props} />
@ -44,10 +50,7 @@ describe("MemberAvatar", () => {
}
beforeEach(() => {
jest.clearAllMocks();
stubClient();
mockClient = mocked(MatrixClientPeg.get());
mockClient = stubClient();
room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
@ -55,22 +58,77 @@ describe("MemberAvatar", () => {
member = new RoomMember(ROOM_ID, "@bob:example.org");
jest.spyOn(room, "getMember").mockReturnValue(member);
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400");
});
it("shows an avatar for useOnlyCurrentProfiles", async () => {
jest.spyOn(SettingsStore, "getValue").mockImplementation((settingName: string) => {
return settingName === "useOnlyCurrentProfiles";
});
it("supports 'null' members", () => {
const { container } = render(getComponent({ member: null }));
expect(container.querySelector("img")).not.toBeNull();
});
it("matches the snapshot", () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400");
const { container } = render(
getComponent({
member,
fallbackUserId: "Fallback User ID",
title: "Hover title",
style: {
color: "pink",
},
}),
);
expect(container).toMatchSnapshot();
});
it("shows an avatar for useOnlyCurrentProfiles", () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("http://placekitten.com/400/400");
SettingsStore.setValue("useOnlyCurrentProfiles", null, SettingLevel.DEVICE, true);
const { container } = render(getComponent({}));
let avatar: HTMLElement;
await waitFor(() => {
avatar = getByTestId(container, "avatar-img");
expect(avatar).toBeInTheDocument();
const avatar = getByTestId<HTMLImageElement>(container, "avatar-img");
expect(avatar).toBeInTheDocument();
expect(avatar.getAttribute("src")).not.toBe("");
});
it("uses the member's configured avatar", () => {
const mxcUrl = "mxc://example.com/avatars/user.tiff";
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(mxcUrl);
const { container } = render(getComponent({ member }));
const img = container.querySelector("img");
expect(img).not.toBeNull();
expect(img!.src).toBe(mediaFromMxc(mxcUrl).srcHttp);
});
it("uses a fallback when the member has no avatar", () => {
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue(undefined);
const { container } = render(getComponent({ member }));
const img = container.querySelector(".mx_BaseAvatar_image");
expect(img).not.toBeNull();
});
it("dispatches on click", () => {
const { container } = render(getComponent({ member, viewUserOnClick: true }));
const spy = jest.spyOn(defaultDispatcher, "dispatch");
act(() => {
fireEvent.click(container.querySelector(".mx_BaseAvatar")!);
});
expect(avatar!.getAttribute("src")).not.toBe("");
expect(spy).toHaveBeenCalled();
const [payload] = spy.mock.lastCall!;
expect(payload).toStrictEqual<ViewUserPayload>({
action: Action.ViewUser,
member,
push: false,
});
});
});

View file

@ -39,7 +39,7 @@ describe("RoomAvatar", () => {
const dmRoomMap = new DMRoomMap(client);
jest.spyOn(dmRoomMap, "getUserIdForRoomId");
jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap);
jest.spyOn(AvatarModule, "defaultAvatarUrlForString");
jest.spyOn(AvatarModule, "getColorForString");
});
afterAll(() => {
@ -48,14 +48,14 @@ describe("RoomAvatar", () => {
afterEach(() => {
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReset();
mocked(AvatarModule.defaultAvatarUrlForString).mockClear();
mocked(AvatarModule.getColorForString).mockClear();
});
it("should render as expected for a Room", () => {
const room = new Room("!room:example.com", client, client.getSafeUserId());
room.name = "test room";
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(room.roomId);
expect(AvatarModule.getColorForString).toHaveBeenCalledWith(room.roomId);
});
it("should render as expected for a DM room", () => {
@ -64,7 +64,7 @@ describe("RoomAvatar", () => {
room.name = "DM room";
mocked(DMRoomMap.shared().getUserIdForRoomId).mockReturnValue(userId);
expect(render(<RoomAvatar room={room} />).container).toMatchSnapshot();
expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(userId);
expect(AvatarModule.getColorForString).toHaveBeenCalledWith(userId);
});
it("should render as expected for a LocalRoom", () => {
@ -73,6 +73,6 @@ describe("RoomAvatar", () => {
localRoom.name = "local test room";
localRoom.targets.push(new DirectoryMember({ user_id: userId }));
expect(render(<RoomAvatar room={localRoom} />).container).toMatchSnapshot();
expect(AvatarModule.defaultAvatarUrlForString).toHaveBeenCalledWith(userId);
expect(AvatarModule.getColorForString).toHaveBeenCalledWith(userId);
});
});

View file

@ -0,0 +1,72 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<BaseAvatar /> matches snapshot (avatar + click) 1`] = `
<div>
<img
alt="Avatar"
class="mx_AccessibleButton mx_BaseAvatar mx_BaseAvatar_image mx_SomethingArbitrary"
data-testid="avatar-img"
role="button"
src="https://example.com/images/avatar.gif"
style="width: 40px; height: 40px;"
tabindex="0"
title="Hover title"
/>
</div>
`;
exports[`<BaseAvatar /> matches snapshot (avatar) 1`] = `
<div>
<img
alt=""
class="mx_BaseAvatar mx_BaseAvatar_image mx_SomethingArbitrary"
data-testid="avatar-img"
src="https://example.com/images/avatar.gif"
style="width: 40px; height: 40px;"
title="Hover title"
/>
</div>
`;
exports[`<BaseAvatar /> matches snapshot (no avatar + click) 1`] = `
<div>
<span
aria-label="Avatar"
aria-live="off"
class="mx_AccessibleButton mx_BaseAvatar big-and-bold"
role="button"
style="width: 40px; height: 40px;"
tabindex="0"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(13, 189, 139); width: 40px; height: 40px; font-size: 26px; line-height: 40px;"
title=":kiss:"
>
X
</span>
</span>
</div>
`;
exports[`<BaseAvatar /> matches snapshot (no avatar) 1`] = `
<div>
<span
class="mx_BaseAvatar big-and-bold"
role="presentation"
style="width: 40px; height: 40px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(13, 189, 139); width: 40px; height: 40px; font-size: 26px; line-height: 40px;"
title=":kiss:"
>
X
</span>
</span>
</div>
`;

View file

@ -0,0 +1,14 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`MemberAvatar matches the snapshot 1`] = `
<div>
<img
alt=""
class="mx_BaseAvatar mx_BaseAvatar_image"
data-testid="avatar-img"
src="http://this.is.a.url//placekitten.com/400/400"
style="color: pink; width: 35px; height: 35px;"
title="Hover title"
/>
</div>
`;

View file

@ -5,22 +5,16 @@ exports[`RoomAvatar should render as expected for a DM room 1`] = `
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 36px; height: 36px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(13, 189, 139); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
>
D
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span>
</div>
`;
@ -30,22 +24,16 @@ exports[`RoomAvatar should render as expected for a LocalRoom 1`] = `
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 36px; height: 36px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
>
L
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span>
</div>
`;
@ -55,22 +43,16 @@ exports[`RoomAvatar should render as expected for a Room 1`] = `
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 36px; height: 36px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
>
T
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span>
</div>
`;

View file

@ -13,23 +13,17 @@ exports[`<BeaconMarker /> renders marker when beacon has location 1`] = `
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 36px; height: 36px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
title="@alice:server"
>
A
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
title="@alice:server"
/>
</span>
</div>
</div>

View file

@ -0,0 +1,71 @@
/*
Copyright 2023 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 { getByLabelText, render } from "@testing-library/react";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClient } from "matrix-js-sdk/src/client";
import userEvent from "@testing-library/user-event";
import { stubClient } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
import MatrixClientContext from "../../../../src/contexts/MatrixClientContext";
import DevtoolsDialog from "../../../../src/components/views/dialogs/DevtoolsDialog";
describe("DevtoolsDialog", () => {
let cli: MatrixClient;
let room: Room;
function getComponent(roomId: string, onFinished = () => true) {
return render(
<MatrixClientContext.Provider value={cli}>
<DevtoolsDialog roomId={roomId} onFinished={onFinished} />
</MatrixClientContext.Provider>,
);
}
beforeEach(() => {
stubClient();
cli = MatrixClientPeg.get();
room = new Room("!id", cli, "@alice:matrix.org");
jest.spyOn(cli, "getRoom").mockReturnValue(room);
});
afterAll(() => {
jest.restoreAllMocks();
});
it("renders the devtools dialog", () => {
const { asFragment } = getComponent(room.roomId);
expect(asFragment()).toMatchSnapshot();
});
it("copies the roomid", async () => {
const user = userEvent.setup();
jest.spyOn(navigator.clipboard, "writeText");
const { container } = getComponent(room.roomId);
const copyBtn = getByLabelText(container, "Copy");
await user.click(copyBtn);
const copiedBtn = getByLabelText(container, "Copied!");
expect(copiedBtn).toBeInTheDocument();
expect(navigator.clipboard.writeText).toHaveBeenCalled();
expect(navigator.clipboard.readText()).resolves.toBe(room.roomId);
});
});

View file

@ -0,0 +1,83 @@
/*
Copyright 2023 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, RenderResult } from "@testing-library/react";
import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { flushPromises, mkMessage, stubClient } from "../../../test-utils";
import MessageEditHistoryDialog from "../../../../src/components/views/dialogs/MessageEditHistoryDialog";
describe("<MessageEditHistory />", () => {
const roomId = "!aroom:example.com";
let client: jest.Mocked<MatrixClient>;
let event: MatrixEvent;
beforeEach(() => {
client = stubClient() as jest.Mocked<MatrixClient>;
event = mkMessage({
event: true,
user: "@user:example.com",
room: "!room:example.com",
msg: "My Great Message",
});
});
async function renderComponent(): Promise<RenderResult> {
const result = render(<MessageEditHistoryDialog mxEvent={event} onFinished={jest.fn()} />);
await flushPromises();
return result;
}
function mockEdits(...edits: { msg: string; ts: number | undefined }[]) {
client.relations.mockImplementation(() =>
Promise.resolve({
events: edits.map(
(e) =>
new MatrixEvent({
type: EventType.RoomMessage,
room_id: roomId,
origin_server_ts: e.ts,
content: {
body: e.msg,
},
}),
),
}),
);
}
it("should match the snapshot", async () => {
mockEdits({ msg: "My Great Massage", ts: 1234 });
const { container } = await renderComponent();
expect(container).toMatchSnapshot();
});
it("should support events with ", async () => {
mockEdits(
{ msg: "My Great Massage", ts: undefined },
{ msg: "My Great Massage?", ts: undefined },
{ msg: "My Great Missage", ts: undefined },
);
const { container } = await renderComponent();
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,229 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`DevtoolsDialog renders the devtools dialog 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_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h2 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Developer Tools
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_DevTools_label_left"
>
Toolbox
</div>
<div
class="mx_CopyableText mx_DevTools_label_right"
>
Room ID: !id
<div
aria-label="Copy"
class="mx_AccessibleButton mx_CopyableText_copyButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_DevTools_label_bottom"
/>
<div
class="mx_DevTools_content"
>
<div>
<h3>
Room
</h3>
<button
class="mx_DevTools_button"
>
Send custom timeline event
</button>
<button
class="mx_DevTools_button"
>
Explore room state
</button>
<button
class="mx_DevTools_button"
>
Explore room account data
</button>
<button
class="mx_DevTools_button"
>
View servers in room
</button>
<button
class="mx_DevTools_button"
>
Verification explorer
</button>
<button
class="mx_DevTools_button"
>
Active Widgets
</button>
</div>
<div>
<h3>
Other
</h3>
<button
class="mx_DevTools_button"
>
Explore account data
</button>
<button
class="mx_DevTools_button"
>
Settings explorer
</button>
<button
class="mx_DevTools_button"
>
Server info
</button>
</div>
<div>
<h3>
Options
</h3>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
>
<span
class="mx_SettingsFlag_labelText"
>
Developer mode
</span>
</label>
<div
aria-checked="false"
aria-disabled="false"
aria-label="Developer mode"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
>
<span
class="mx_SettingsFlag_labelText"
>
Show hidden events in timeline
</span>
</label>
<div
aria-checked="false"
aria-disabled="false"
aria-label="Show hidden events in timeline"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
>
<span
class="mx_SettingsFlag_labelText"
>
Enable widget screenshots on supported widgets
</span>
</label>
<div
aria-checked="false"
aria-disabled="false"
aria-label="Enable widget screenshots on supported widgets"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
<div
class="mx_SettingsFlag"
>
<label
class="mx_SettingsFlag_label"
>
<span
class="mx_SettingsFlag_labelText"
>
Force 15s voice broadcast chunk length
</span>
</label>
<div
aria-checked="false"
aria-disabled="false"
aria-label="Force 15s voice broadcast chunk length"
class="mx_AccessibleButton mx_ToggleSwitch mx_ToggleSwitch_enabled"
role="switch"
tabindex="0"
>
<div
class="mx_ToggleSwitch_ball"
/>
</div>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<button>
Back
</button>
</div>
</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

@ -0,0 +1,322 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<MessageEditHistory /> should match the snapshot 1`] = `
<div>
<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_MessageEditHistoryDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h2 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Message edits
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_MessageEditHistoryDialog_scrollPanel"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
>
<ul
class="mx_MessageEditHistoryDialog_edits"
>
<li>
<div
aria-label="Thu, Jan 1 1970"
class="mx_DateSeparator"
role="separator"
tabindex="-1"
>
<hr
role="none"
/>
<h2
aria-hidden="true"
>
Thu, Jan 1 1970
</h2>
<hr
role="none"
/>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
00:00
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
My Great Massage
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
</ul>
</ol>
</div>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;
exports[`<MessageEditHistory /> should support events with 1`] = `
<div>
<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_MessageEditHistoryDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h2 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Message edits
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_AutoHideScrollbar mx_ScrollPanel mx_MessageEditHistoryDialog_scrollPanel"
tabindex="-1"
>
<div
class="mx_RoomView_messageListWrapper"
>
<ol
aria-live="polite"
class="mx_RoomView_MessageList"
>
<ul
class="mx_MessageEditHistoryDialog_edits"
>
<li>
<div
aria-label=", NaN NaN"
class="mx_DateSeparator"
role="separator"
tabindex="-1"
>
<hr
role="none"
/>
<h2
aria-hidden="true"
>
, NaN NaN
</h2>
<hr
role="none"
/>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
NaN:NaN
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
My Great Massage
<span
class="mx_EditHistoryMessage_deletion"
>
?
</span>
</span>
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
NaN:NaN
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
My Great M
<span
class="mx_EditHistoryMessage_deletion"
>
i
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
a
</span>
ssage
<span
class="mx_EditHistoryMessage_insertion"
>
?
</span>
</span>
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
<li>
<div
class="mx_EventTile"
>
<div
class="mx_EventTile_line"
>
<span
class="mx_MessageTimestamp"
>
NaN:NaN
</span>
<div
class="mx_EventTile_content"
>
<span
class="mx_EventTile_body"
dir="auto"
>
My Great Missage
</span>
</div>
<div
class="mx_MessageActionBar"
>
<div
class="mx_AccessibleButton"
role="button"
tabindex="0"
>
Remove
</div>
</div>
</div>
</div>
</li>
</ul>
</ol>
</div>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</div>
`;

View file

@ -18,13 +18,16 @@ import React from "react";
import { act, render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { mocked } from "jest-mock";
import { EventType, MatrixEvent } from "matrix-js-sdk/src/matrix";
import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import dis from "../../../../src/dispatcher/dispatcher";
import SettingsStore from "../../../../src/settings/SettingsStore";
import RoomCreate from "../../../../src/components/views/messages/RoomCreate";
import { stubClient } from "../../../test-utils/test-utils";
import { RoomCreate } from "../../../../src/components/views/messages/RoomCreate";
import { stubClient, upsertRoomStateEvents } from "../../../test-utils/test-utils";
import { Action } from "../../../../src/dispatcher/actions";
import RoomContext from "../../../../src/contexts/RoomContext";
import { getRoomContext } from "../../../test-utils";
import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
jest.mock("../../../../src/dispatcher/dispatcher");
@ -33,6 +36,7 @@ describe("<RoomCreate />", () => {
const roomId = "!room:server.org";
const createEvent = new MatrixEvent({
type: EventType.RoomCreate,
state_key: "",
sender: userId,
room_id: roomId,
content: {
@ -40,6 +44,20 @@ describe("<RoomCreate />", () => {
},
event_id: "$create",
});
const createEventWithoutPredecessor = new MatrixEvent({
type: EventType.RoomCreate,
state_key: "",
sender: userId,
room_id: roomId,
content: {},
event_id: "$create",
});
stubClient();
const client = mocked(MatrixClientPeg.get());
const room = new Room(roomId, client, userId);
upsertRoomStateEvents(room, [createEvent]);
const roomNoPredecessors = new Room(roomId, client, userId);
upsertRoomStateEvents(roomNoPredecessors, [createEventWithoutPredecessor]);
beforeEach(() => {
jest.clearAllMocks();
@ -54,21 +72,34 @@ describe("<RoomCreate />", () => {
jest.spyOn(SettingsStore, "setValue").mockRestore();
});
function renderRoomCreate(room: Room) {
return render(
<RoomContext.Provider value={getRoomContext(room, {})}>
<RoomCreate mxEvent={createEvent} />
</RoomContext.Provider>,
);
}
it("Renders as expected", () => {
const roomCreate = render(<RoomCreate mxEvent={createEvent} />);
const roomCreate = renderRoomCreate(room);
expect(roomCreate.asFragment()).toMatchSnapshot();
});
it("Links to the old version of the room", () => {
render(<RoomCreate mxEvent={createEvent} />);
renderRoomCreate(room);
expect(screen.getByText("Click here to see older messages.")).toHaveAttribute(
"href",
"https://matrix.to/#/old_room_id/tombstone_event_id",
);
});
it("Shows an empty div if there is no predecessor", () => {
renderRoomCreate(roomNoPredecessors);
expect(screen.queryByText("Click here to see older messages.", { exact: false })).toBeNull();
});
it("Opens the old room on click", async () => {
render(<RoomCreate mxEvent={createEvent} />);
renderRoomCreate(room);
const link = screen.getByText("Click here to see older messages.");
await act(() => userEvent.click(link));

View file

@ -17,7 +17,6 @@ limitations under the License.
import { render } from "@testing-library/react";
import { MatrixEvent, MsgType, RelationType } from "matrix-js-sdk/src/matrix";
import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
import React from "react";
@ -38,7 +37,7 @@ describe("RoomHeaderButtons-test.tsx", function () {
stubClient();
client = MatrixClientPeg.get();
client.supportsExperimentalThreads = () => true;
client.supportsThreads = () => true;
room = new Room(ROOM_ID, client, client.getUserId() ?? "", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
@ -173,9 +172,4 @@ describe("RoomHeaderButtons-test.tsx", function () {
room.addReceipt(receipt);
expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull();
});
it("does not explode without a room", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported);
expect(() => getComponent()).not.toThrow();
});
});

View file

@ -72,6 +72,7 @@ const mockRoom = mocked({
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
@ -83,9 +84,12 @@ const mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
isCryptoEnabled: jest.fn(),
getUserId: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
isRoomEncrypted: jest.fn().mockReturnValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
@ -386,8 +390,11 @@ describe("<UserOptionsSection />", () => {
beforeEach(() => {
inviteSpy.mockReset();
mockClient.setIgnoredUsers.mockClear();
});
afterEach(() => Modal.closeCurrentModal("End of test"));
afterAll(() => {
inviteSpy.mockRestore();
});
@ -543,6 +550,52 @@ describe("<UserOptionsSection />", () => {
expect(screen.getByText(/operation failed/i)).toBeInTheDocument();
});
});
it("shows a modal before ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([true]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent({ isIgnored: false });
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).toHaveBeenLastCalledWith([member.userId]);
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("cancels ignoring the user", async () => {
const originalCreateDialog = Modal.createDialog;
const modalSpy = (Modal.createDialog = jest.fn().mockReturnValue({
finished: Promise.resolve([false]),
close: () => {},
}));
try {
mockClient.getIgnoredUsers.mockReturnValue([]);
renderComponent({ isIgnored: false });
await userEvent.click(screen.getByRole("button", { name: "Ignore" }));
expect(modalSpy).toHaveBeenCalled();
expect(mockClient.setIgnoredUsers).not.toHaveBeenCalled();
} finally {
Modal.createDialog = originalCreateDialog;
}
});
it("unignores the user", async () => {
mockClient.getIgnoredUsers.mockReturnValue([member.userId]);
renderComponent({ isIgnored: true });
await userEvent.click(screen.getByRole("button", { name: "Unignore" }));
expect(mockClient.setIgnoredUsers).toHaveBeenCalledWith([]);
});
});
describe("<PowerLevelEditor />", () => {

View file

@ -92,7 +92,7 @@ describe("EventTile", () => {
describe("EventTile thread summary", () => {
beforeEach(() => {
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
});
it("removes the thread summary when thread is deleted", async () => {

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -23,36 +23,26 @@ import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room";
import { EventStatus } from "matrix-js-sdk/src/models/event-status";
import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts";
import type { MatrixClient } from "matrix-js-sdk/src/client";
import { mkThread } from "../../../../test-utils/threads";
import { UnreadNotificationBadge } from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge";
import { mkEvent, mkMessage, stubClient } from "../../../../test-utils/test-utils";
import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg";
import { mkEvent, mkMessage, muteRoom, stubClient } from "../../../../test-utils/test-utils";
import * as RoomNotifs from "../../../../../src/RoomNotifs";
jest.mock("../../../../../src/RoomNotifs");
jest.mock("../../../../../src/RoomNotifs", () => ({
...(jest.requireActual("../../../../../src/RoomNotifs") as Object),
getRoomNotifsState: jest.fn(),
}));
const ROOM_ID = "!roomId:example.org";
let THREAD_ID: string;
describe("UnreadNotificationBadge", () => {
stubClient();
const client = MatrixClientPeg.get();
let client: MatrixClient;
let room: Room;
function getComponent(threadId?: string) {
return <UnreadNotificationBadge room={room} threadId={threadId} />;
}
beforeAll(() => {
client.supportsExperimentalThreads = () => true;
});
beforeEach(() => {
jest.clearAllMocks();
client = stubClient();
client.supportsThreads = () => true;
room = new Room(ROOM_ID, client, client.getUserId()!, {
pendingEventOrdering: PendingEventOrdering.Detached,
@ -145,41 +135,39 @@ describe("UnreadNotificationBadge", () => {
});
it("adds a warning for invites", () => {
jest.spyOn(room, "getMyMembership").mockReturnValue("invite");
room.updateMyMembership("invite");
render(getComponent());
expect(screen.queryByText("!")).not.toBeNull();
});
it("hides counter for muted rooms", () => {
jest.spyOn(RoomNotifs, "getRoomNotifsState").mockReset().mockReturnValue(RoomNotifs.RoomNotifState.Mute);
muteRoom(room);
const { container } = render(getComponent());
expect(container.querySelector(".mx_NotificationBadge")).toBeNull();
});
it("activity renders unread notification badge", () => {
act(() => {
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0);
room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0);
// Add another event on the thread which is not sent by us.
const event = mkEvent({
event: true,
type: "m.room.message",
user: "@alice:server.org",
room: room.roomId,
content: {
"msgtype": MsgType.Text,
"body": "Hello from Bob",
"m.relates_to": {
event_id: THREAD_ID,
rel_type: RelationType.Thread,
},
// Add another event on the thread which is not sent by us.
const event = mkEvent({
event: true,
type: "m.room.message",
user: "@alice:server.org",
room: room.roomId,
content: {
"msgtype": MsgType.Text,
"body": "Hello from Bob",
"m.relates_to": {
event_id: THREAD_ID,
rel_type: RelationType.Thread,
},
ts: 5,
});
room.addLiveEvents([event]);
},
ts: 5,
});
room.addLiveEvents([event]);
const { container } = render(getComponent(THREAD_ID));
expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy();

View file

@ -72,7 +72,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image.prop("src")).toEqual("data:image/png;base64,00");
expect(image).toBeTruthy();
});
it("shows the room avatar in a room with 2 people", () => {
@ -86,7 +86,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image.prop("src")).toEqual("data:image/png;base64,00");
expect(image).toBeTruthy();
});
it("shows the room avatar in a room with >2 people", () => {
@ -100,7 +100,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image.prop("src")).toEqual("data:image/png;base64,00");
expect(image).toBeTruthy();
});
it("shows the room avatar in a DM with only ourselves", () => {
@ -114,7 +114,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image.prop("src")).toEqual("data:image/png;base64,00");
expect(image).toBeTruthy();
});
it("shows the user avatar in a DM with 2 people", () => {
@ -148,7 +148,7 @@ describe("RoomHeader (Enzyme)", () => {
// And there is no image avatar (because it's not set on this room)
const image = findImg(rendered, ".mx_BaseAvatar_image");
expect(image.prop("src")).toEqual("data:image/png;base64,00");
expect(image).toBeTruthy();
});
it("renders call buttons normally", () => {

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -17,7 +17,7 @@ limitations under the License.
import * as React from "react";
import { MatrixEvent } from "matrix-js-sdk/src/models/event";
import { EventType } from "matrix-js-sdk/src/@types/event";
import { render } from "@testing-library/react";
import { render, type RenderResult } from "@testing-library/react";
import { Room } from "matrix-js-sdk/src/models/room";
import { stubClient } from "../../../test-utils";
@ -26,6 +26,8 @@ import { MatrixClientPeg } from "../../../../src/MatrixClientPeg";
const ROOM_ID = "!qPewotXpIctQySfjSy:localhost";
type Props = React.ComponentPropsWithoutRef<typeof SearchResultTile>;
describe("SearchResultTile", () => {
beforeAll(() => {
stubClient();
@ -35,50 +37,72 @@ describe("SearchResultTile", () => {
jest.spyOn(cli, "getRoom").mockReturnValue(room);
});
function renderComponent(props: Partial<Props>): RenderResult {
return render(<SearchResultTile timeline={[]} ourEventsIndexes={[1]} {...props} />);
}
it("Sets up appropriate callEventGrouper for m.call. events", () => {
const { container } = render(
<SearchResultTile
timeline={[
new MatrixEvent({
type: EventType.CallInvite,
sender: "@user1:server",
room_id: ROOM_ID,
origin_server_ts: 1432735824652,
content: { call_id: "call.1" },
event_id: "$1:server",
}),
new MatrixEvent({
content: {
body: "This is an example text message",
format: "org.matrix.custom.html",
formatted_body: "<b>This is an example text message</b>",
msgtype: "m.text",
},
event_id: "$144429830826TWwbB:localhost",
origin_server_ts: 1432735824653,
room_id: ROOM_ID,
sender: "@example:example.org",
type: "m.room.message",
unsigned: {
age: 1234,
},
}),
new MatrixEvent({
type: EventType.CallAnswer,
sender: "@user2:server",
room_id: ROOM_ID,
origin_server_ts: 1432735824654,
content: { call_id: "call.1" },
event_id: "$2:server",
}),
]}
ourEventsIndexes={[1]}
/>,
);
const { container } = renderComponent({
timeline: [
new MatrixEvent({
type: EventType.CallInvite,
sender: "@user1:server",
room_id: ROOM_ID,
origin_server_ts: 1432735824652,
content: { call_id: "call.1" },
event_id: "$1:server",
}),
new MatrixEvent({
content: {
body: "This is an example text message",
format: "org.matrix.custom.html",
formatted_body: "<b>This is an example text message</b>",
msgtype: "m.text",
},
event_id: "$144429830826TWwbB:localhost",
origin_server_ts: 1432735824653,
room_id: ROOM_ID,
sender: "@example:example.org",
type: "m.room.message",
unsigned: {
age: 1234,
},
}),
new MatrixEvent({
type: EventType.CallAnswer,
sender: "@user2:server",
room_id: ROOM_ID,
origin_server_ts: 1432735824654,
content: { call_id: "call.1" },
event_id: "$2:server",
}),
],
});
const tiles = container.querySelectorAll<HTMLElement>(".mx_EventTile");
expect(tiles.length).toEqual(2);
expect(tiles[0].dataset.eventId).toBe("$1:server");
expect(tiles[1].dataset.eventId).toBe("$144429830826TWwbB:localhost");
expect(tiles[0]!.dataset.eventId).toBe("$1:server");
expect(tiles[1]!.dataset.eventId).toBe("$144429830826TWwbB:localhost");
});
it("supports events with missing timestamps", () => {
const { container } = renderComponent({
timeline: [...Array(20)].map(
(_, i) =>
new MatrixEvent({
type: EventType.RoomMessage,
sender: "@user1:server",
room_id: ROOM_ID,
content: { body: `Message #${i}` },
event_id: `$${i}:server`,
origin_server_ts: undefined,
}),
),
});
const separators = container.querySelectorAll(".mx_DateSeparator");
// One separator is always rendered at the top, we don't want any
// between messages.
expect(separators.length).toBe(1);
});
});

View file

@ -161,22 +161,16 @@ exports[`<RoomPreviewBar /> with an invite without an invited email for a dm roo
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 36px; height: 36px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
>
R
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span>
</p>
<p>
@ -236,22 +230,16 @@ exports[`<RoomPreviewBar /> with an invite without an invited email for a non-dm
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 36px; height: 36px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 23.400000000000002px; width: 36px; line-height: 36px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 36px; height: 36px; font-size: 23.400000000000002px; line-height: 36px;"
>
R
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 36px; height: 36px;"
/>
</span>
</p>
<p>

View file

@ -15,22 +15,16 @@ exports[`RoomTile should render the room 1`] = `
<span
class="mx_BaseAvatar"
role="presentation"
style="width: 32px; height: 32px;"
>
<span
aria-hidden="true"
class="mx_BaseAvatar_initial"
style="font-size: 20.8px; width: 32px; line-height: 32px;"
class="mx_BaseAvatar_image mx_BaseAvatar_initial"
data-testid="avatar-img"
style="background-color: rgb(172, 59, 168); width: 32px; height: 32px; font-size: 20.8px; line-height: 32px;"
>
!
</span>
<img
alt=""
aria-hidden="true"
class="mx_BaseAvatar_image"
data-testid="avatar-img"
src="data:image/png;base64,00"
style="width: 32px; height: 32px;"
/>
</span>
</div>
<div

View file

@ -17,13 +17,21 @@ limitations under the License.
import "@testing-library/jest-dom";
import React from "react";
import { act, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { EventTimeline, MatrixEvent } from "matrix-js-sdk/src/matrix";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
import RoomContext from "../../../../../src/contexts/RoomContext";
import defaultDispatcher from "../../../../../src/dispatcher/dispatcher";
import { Action } from "../../../../../src/dispatcher/actions";
import { IRoomState } from "../../../../../src/components/structures/RoomView";
import { createTestClient, flushPromises, getRoomContext, mkEvent, mkStubRoom } from "../../../../test-utils";
import {
createTestClient,
flushPromises,
getRoomContext,
mkEvent,
mkStubRoom,
mockPlatformPeg,
} from "../../../../test-utils";
import { EditWysiwygComposer } from "../../../../../src/components/views/rooms/wysiwyg_composer";
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
import { Emoji } from "../../../../../src/components/views/rooms/wysiwyg_composer/components/Emoji";
@ -32,38 +40,54 @@ import dis from "../../../../../src/dispatcher/dispatcher";
import { ComposerInsertPayload, ComposerType } from "../../../../../src/dispatcher/payloads/ComposerInsertPayload";
import { ActionPayload } from "../../../../../src/dispatcher/payloads";
import * as EmojiButton from "../../../../../src/components/views/rooms/EmojiButton";
import { setSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/utils/selection";
import * as EventUtils from "../../../../../src/utils/EventUtils";
import { SubSelection } from "../../../../../src/components/views/rooms/wysiwyg_composer/types";
describe("EditWysiwygComposer", () => {
afterEach(() => {
jest.resetAllMocks();
});
const mockClient = createTestClient();
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: {
msgtype: "m.text",
body: "Replying to this",
format: "org.matrix.custom.html",
formatted_body: "Replying <b>to</b> this new content",
},
event: true,
});
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
mockRoom.findEventById = jest.fn((eventId) => {
return eventId === mockEvent.getId() ? mockEvent : null;
});
function createMocks(eventContent = "Replying <strong>to</strong> this new content") {
const mockClient = createTestClient();
const mockEvent = mkEvent({
type: "m.room.message",
room: "myfakeroom",
user: "myfakeuser",
content: {
msgtype: "m.text",
body: "Replying to this",
format: "org.matrix.custom.html",
formatted_body: eventContent,
},
event: true,
});
const mockRoom = mkStubRoom("myfakeroom", "myfakeroom", mockClient) as any;
mockRoom.findEventById = jest.fn((eventId) => {
return eventId === mockEvent.getId() ? mockEvent : null;
});
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {});
const defaultRoomContext: IRoomState = getRoomContext(mockRoom, {
liveTimeline: { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline,
});
const editorStateTransfer = new EditorStateTransfer(mockEvent);
const editorStateTransfer = new EditorStateTransfer(mockEvent);
const customRender = (disabled = false, _editorStateTransfer = editorStateTransfer) => {
return { defaultRoomContext, editorStateTransfer, mockClient, mockEvent };
}
const { editorStateTransfer, defaultRoomContext, mockClient, mockEvent } = createMocks();
const customRender = (
disabled = false,
_editorStateTransfer = editorStateTransfer,
client = mockClient,
roomContext = defaultRoomContext,
) => {
return render(
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={defaultRoomContext}>
<MatrixClientContext.Provider value={client}>
<RoomContext.Provider value={roomContext}>
<EditWysiwygComposer disabled={disabled} editorStateTransfer={_editorStateTransfer} />
</RoomContext.Provider>
</MatrixClientContext.Provider>,
@ -176,12 +200,13 @@ describe("EditWysiwygComposer", () => {
});
describe("Edit and save actions", () => {
let spyDispatcher: jest.SpyInstance<void, [payload: ActionPayload, sync?: boolean]>;
beforeEach(async () => {
spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
customRender();
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
});
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
afterEach(() => {
spyDispatcher.mockRestore();
});
@ -204,7 +229,6 @@ describe("EditWysiwygComposer", () => {
it("Should send message on save button click", async () => {
// When
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
fireEvent.input(screen.getByRole("textbox"), {
data: "foo bar",
inputType: "insertText",
@ -318,4 +342,290 @@ describe("EditWysiwygComposer", () => {
await waitFor(() => expect(screen.getByRole("textbox")).toHaveTextContent(/🦫/));
dis.unregister(dispatcherRef);
});
describe("Keyboard navigation", () => {
const setup = async (
editorState = editorStateTransfer,
client = createTestClient(),
roomContext = defaultRoomContext,
) => {
const spyDispatcher = jest.spyOn(defaultDispatcher, "dispatch");
customRender(false, editorState, client, roomContext);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
return { textbox: screen.getByRole("textbox"), spyDispatcher };
};
beforeEach(() => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
});
function select(selection: SubSelection) {
return act(async () => {
await setSelection(selection);
// the event is not automatically fired by jest
document.dispatchEvent(new CustomEvent("selectionchange"));
});
}
describe("Moving up", () => {
it("Should not moving when caret is not at beginning of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup();
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 1,
focusNode: textNode,
focusOffset: 2,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should moving up", async () => {
// When
const { textbox, spyDispatcher } = await setup();
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving up in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
const textNode = textbox.firstChild;
await select({
anchorNode: textNode,
anchorOffset: 0,
focusNode: textNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowUp",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
});
describe("Moving down", () => {
it("Should not moving when caret is not at the end of the text", async () => {
// When
const { textbox, spyDispatcher } = await setup();
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should not moving when the content has changed", async () => {
// When
const { textbox, spyDispatcher } = await setup();
fireEvent.input(textbox, {
data: "word",
inputType: "insertText",
});
const brNode = textbox.lastChild;
await select({
anchorNode: brNode,
anchorOffset: 0,
focusNode: brNode,
focusOffset: 0,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Then
expect(spyDispatcher).toBeCalledTimes(0);
});
it("Should moving down", async () => {
// When
const { textbox, spyDispatcher } = await setup();
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
it("Should moving down in list", async () => {
// When
const { mockEvent, defaultRoomContext, mockClient, editorStateTransfer } = createMocks(
"<ul><li><strong>Content</strong></li><li>Other Content</li></ul>",
);
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(mockEvent);
const { textbox, spyDispatcher } = await setup(editorStateTransfer, mockClient, defaultRoomContext);
// Skipping the BR tag and get the text node inside the last LI tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2].lastChild?.lastChild || textbox;
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: mockEvent,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
});
});
it("Should close editing", async () => {
// When
jest.spyOn(EventUtils, "findEditableEvent").mockReturnValue(undefined);
const { textbox, spyDispatcher } = await setup();
// Skipping the BR tag
const textNode = textbox.childNodes[textbox.childNodes.length - 2];
const { length } = textNode.textContent || "";
await select({
anchorNode: textNode,
anchorOffset: length,
focusNode: textNode,
focusOffset: length,
isForward: true,
});
fireEvent.keyDown(textbox, {
key: "ArrowDown",
});
// Wait for event dispatch to happen
await act(async () => {
await flushPromises();
});
// Then
await waitFor(() =>
expect(spyDispatcher).toBeCalledWith({
action: Action.EditEvent,
event: null,
timelineRenderingType: defaultRoomContext.timelineRenderingType,
}),
);
});
});
});
});

View file

@ -33,6 +33,8 @@ const mockWysiwyg = {
orderedList: jest.fn(),
unorderedList: jest.fn(),
quote: jest.fn(),
indent: jest.fn(),
unIndent: jest.fn(),
} as unknown as FormattingFunctions;
const openLinkModalSpy = jest.spyOn(LinkModal, "openLinkModal");
@ -51,6 +53,8 @@ const testCases: Record<
orderedList: { label: "Numbered list", mockFormatFn: mockWysiwyg.orderedList },
unorderedList: { label: "Bulleted list", mockFormatFn: mockWysiwyg.unorderedList },
quote: { label: "Quote", mockFormatFn: mockWysiwyg.quote },
indent: { label: "Indent increase", mockFormatFn: mockWysiwyg.indent },
unIndent: { label: "Indent decrease", mockFormatFn: mockWysiwyg.unIndent },
};
const createActionStates = (state: ActionState): AllActionStates => {

View file

@ -21,6 +21,7 @@ import userEvent from "@testing-library/user-event";
import { WysiwygComposer } from "../../../../../../src/components/views/rooms/wysiwyg_composer/components/WysiwygComposer";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
import { mockPlatformPeg } from "../../../../../test-utils";
describe("WysiwygComposer", () => {
const customRender = (
@ -46,6 +47,7 @@ describe("WysiwygComposer", () => {
const onChange = jest.fn();
const onSend = jest.fn();
beforeEach(async () => {
mockPlatformPeg({ overrideBrowserShortcuts: jest.fn().mockReturnValue(false) });
customRender(onChange, onSend);
await waitFor(() => expect(screen.getByRole("textbox")).toHaveAttribute("contentEditable", "true"));
});

View file

@ -225,7 +225,7 @@ describe("<Notifications />", () => {
}),
setAccountData: jest.fn(),
sendReadReceipt: jest.fn(),
supportsExperimentalThreads: jest.fn().mockReturnValue(true),
supportsThreads: jest.fn().mockReturnValue(true),
});
mockClient.getPushRules.mockResolvedValue(pushRules);

View file

@ -0,0 +1,45 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`RoomPillPart matches snapshot (avatar) 1`] = `
<span
class="mx_Pill mx_RoomPill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: url('http://this.is.a.url/www.example.com/avatars/room1.jpeg'); --avatar-letter: '';"
>
!room:example.com
</span>
`;
exports[`RoomPillPart matches snapshot (no avatar) 1`] = `
<span
class="mx_Pill mx_RoomPill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: #ac3ba8; --avatar-letter: '!';"
>
!room:example.com
</span>
`;
exports[`UserPillPart matches snapshot (avatar) 1`] = `
<span
class="mx_UserPill mx_Pill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: url('http://this.is.a.url/www.example.com/avatar.png'); --avatar-letter: '';"
>
DisplayName
</span>
`;
exports[`UserPillPart matches snapshot (no avatar) 1`] = `
<span
class="mx_UserPill mx_Pill"
contenteditable="false"
spellcheck="false"
style="--avatar-background: #ac3ba8; --avatar-letter: 'U';"
>
DisplayName
</span>
`;

View file

@ -348,4 +348,32 @@ describe("editor/model", function () {
expect(model.parts[0].text).toBe("foo@a");
});
});
describe("emojis", function () {
it("regional emojis should be separated to prevent them to be converted to flag", () => {
const renderer = createRenderer();
const pc = createPartCreator();
const model = new EditorModel([], pc, renderer);
const regionalEmojiA = String.fromCodePoint(127462);
const regionalEmojiZ = String.fromCodePoint(127487);
const caret = new DocumentOffset(0, true);
const regionalEmojis: string[] = [];
regionalEmojis.push(regionalEmojiA);
regionalEmojis.push(regionalEmojiZ);
for (let i = 0; i < 2; i++) {
const position = model.positionForOffset(caret.offset, caret.atNodeEnd);
model.transform(() => {
const addedLen = model.insert(pc.plainWithEmoji(regionalEmojis[i]), position);
caret.offset += addedLen;
return model.positionForOffset(caret.offset, true);
});
}
expect(model.parts.length).toBeGreaterThanOrEqual(4);
expect(model.parts[0].type).toBe("emoji");
expect(model.parts[1].type).not.toBe("emoji");
expect(model.parts[2].type).toBe("emoji");
expect(model.parts[3].type).not.toBe("emoji");
});
});
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -14,7 +14,11 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EmojiPart, PlainPart } from "../../src/editor/parts";
import { MatrixClient, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import { EmojiPart, PartCreator, PlainPart } from "../../src/editor/parts";
import DMRoomMap from "../../src/utils/DMRoomMap";
import { stubClient } from "../test-utils";
import { createPartCreator } from "./mock";
describe("editor/parts", () => {
@ -40,3 +44,67 @@ describe("editor/parts", () => {
expect(() => part.toDOMNode()).not.toThrow();
});
});
describe("UserPillPart", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
let creator: PartCreator;
beforeEach(() => {
client = stubClient();
room = new Room(roomId, client, "@me:example.com");
creator = new PartCreator(room, client);
});
it("matches snapshot (no avatar)", () => {
jest.spyOn(room, "getMember").mockReturnValue(new RoomMember(room.roomId, "@user:example.com"));
const pill = creator.userPill("DisplayName", "@user:example.com");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
it("matches snapshot (avatar)", () => {
const member = new RoomMember(room.roomId, "@user:example.com");
jest.spyOn(room, "getMember").mockReturnValue(member);
jest.spyOn(member, "getMxcAvatarUrl").mockReturnValue("mxc://www.example.com/avatar.png");
const pill = creator.userPill("DisplayName", "@user:example.com");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
});
describe("RoomPillPart", () => {
const roomId = "!room:example.com";
let client: jest.Mocked<MatrixClient>;
let room: Room;
let creator: PartCreator;
beforeEach(() => {
client = stubClient() as jest.Mocked<MatrixClient>;
DMRoomMap.makeShared();
room = new Room(roomId, client, "@me:example.com");
client.getRoom.mockReturnValue(room);
creator = new PartCreator(room, client);
});
it("matches snapshot (no avatar)", () => {
jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue(null);
const pill = creator.roomPill("super-secret clubhouse");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
it("matches snapshot (avatar)", () => {
jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue("mxc://www.example.com/avatars/room1.jpeg");
const pill = creator.roomPill("cool chat club");
const el = pill.toDOMNode();
expect(el).toMatchSnapshot();
});
});

View file

@ -11,9 +11,15 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { EventType, MatrixClient, MatrixEvent, MsgType } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { EventType, MatrixClient, MatrixEvent, MsgType, RelationType, Room } from "matrix-js-sdk/src/matrix";
import { JSONEventFactory, pickFactory } from "../../src/events/EventTileFactory";
import {
JSONEventFactory,
MessageEventFactory,
pickFactory,
TextualEventFactory,
} from "../../src/events/EventTileFactory";
import { VoiceBroadcastChunkEventType, VoiceBroadcastInfoState } from "../../src/voice-broadcast";
import { createTestClient, mkEvent } from "../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-utils";
@ -21,15 +27,32 @@ import { mkVoiceBroadcastInfoStateEvent } from "../voice-broadcast/utils/test-ut
const roomId = "!room:example.com";
describe("pickFactory", () => {
let voiceBroadcastStartedEvent: MatrixEvent;
let voiceBroadcastStoppedEvent: MatrixEvent;
let voiceBroadcastChunkEvent: MatrixEvent;
let utdEvent: MatrixEvent;
let utdBroadcastChunkEvent: MatrixEvent;
let audioMessageEvent: MatrixEvent;
let client: MatrixClient;
beforeAll(() => {
client = createTestClient();
const room = new Room(roomId, client, client.getSafeUserId());
mocked(client.getRoom).mockImplementation((getRoomId: string): Room | null => {
if (getRoomId === room.roomId) return room;
return null;
});
voiceBroadcastStartedEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getUserId()!,
client.deviceId!,
);
room.addLiveEvents([voiceBroadcastStartedEvent]);
voiceBroadcastStoppedEvent = mkVoiceBroadcastInfoStateEvent(
"!room:example.com",
roomId,
VoiceBroadcastInfoState.Stopped,
client.getUserId()!,
client.deviceId!,
@ -53,6 +76,29 @@ describe("pickFactory", () => {
msgtype: MsgType.Audio,
},
});
utdEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
user: client.getUserId()!,
room: roomId,
content: {
msgtype: "m.bad.encrypted",
},
});
utdBroadcastChunkEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
user: client.getUserId()!,
room: roomId,
content: {
"msgtype": "m.bad.encrypted",
"m.relates_to": {
rel_type: RelationType.Reference,
event_id: voiceBroadcastStartedEvent.getId(),
},
},
});
jest.spyOn(utdBroadcastChunkEvent, "isDecryptionFailure").mockReturnValue(true);
});
it("should return JSONEventFactory for a no-op m.room.power_levels event", () => {
@ -67,16 +113,20 @@ describe("pickFactory", () => {
});
describe("when showing hidden events", () => {
it("should return a function for a voice broadcast event", () => {
expect(pickFactory(voiceBroadcastChunkEvent, client, true)).toBeInstanceOf(Function);
it("should return a JSONEventFactory for a voice broadcast event", () => {
expect(pickFactory(voiceBroadcastChunkEvent, client, true)).toBe(JSONEventFactory);
});
it("should return a Function for a voice broadcast stopped event", () => {
expect(pickFactory(voiceBroadcastStoppedEvent, client, true)).toBeInstanceOf(Function);
it("should return a TextualEventFactory for a voice broadcast stopped event", () => {
expect(pickFactory(voiceBroadcastStoppedEvent, client, true)).toBe(TextualEventFactory);
});
it("should return a function for an audio message event", () => {
expect(pickFactory(audioMessageEvent, client, true)).toBeInstanceOf(Function);
it("should return a MessageEventFactory for an audio message event", () => {
expect(pickFactory(audioMessageEvent, client, true)).toBe(MessageEventFactory);
});
it("should return a MessageEventFactory for a UTD broadcast chunk event", () => {
expect(pickFactory(utdBroadcastChunkEvent, client, true)).toBe(MessageEventFactory);
});
});
@ -85,12 +135,20 @@ describe("pickFactory", () => {
expect(pickFactory(voiceBroadcastChunkEvent, client, false)).toBeUndefined();
});
it("should return a Function for a voice broadcast stopped event", () => {
expect(pickFactory(voiceBroadcastStoppedEvent, client, true)).toBeInstanceOf(Function);
it("should return a TextualEventFactory for a voice broadcast stopped event", () => {
expect(pickFactory(voiceBroadcastStoppedEvent, client, false)).toBe(TextualEventFactory);
});
it("should return a function for an audio message event", () => {
expect(pickFactory(audioMessageEvent, client, false)).toBeInstanceOf(Function);
it("should return a MessageEventFactory for an audio message event", () => {
expect(pickFactory(audioMessageEvent, client, false)).toBe(MessageEventFactory);
});
it("should return a MessageEventFactory for a UTD event", () => {
expect(pickFactory(utdEvent, client, false)).toBe(MessageEventFactory);
});
it("should return undefined for a UTD broadcast chunk event", () => {
expect(pickFactory(utdBroadcastChunkEvent, client, false)).toBeUndefined();
});
});
});

View file

@ -0,0 +1,110 @@
/*
Copyright 2023 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 { renderHook } from "@testing-library/react-hooks";
import { EventStatus, NotificationCountType, PendingEventOrdering } from "matrix-js-sdk/src/matrix";
import { Room } from "matrix-js-sdk/src/matrix";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { useUnreadNotifications } from "../../src/hooks/useUnreadNotifications";
import { NotificationColor } from "../../src/stores/notifications/NotificationColor";
import { mkEvent, muteRoom, stubClient } from "../test-utils";
describe("useUnreadNotifications", () => {
let client: MatrixClient;
let room: Room;
beforeEach(() => {
client = stubClient();
room = new Room("!room:example.org", client, "@user:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
});
function setUnreads(greys: number, reds: number): void {
room.setUnreadNotificationCount(NotificationCountType.Highlight, reds);
room.setUnreadNotificationCount(NotificationCountType.Total, greys);
}
it("shows nothing by default", async () => {
const { result } = renderHook(() => useUnreadNotifications(room));
const { color, symbol, count } = result.current;
expect(symbol).toBe(null);
expect(color).toBe(NotificationColor.None);
expect(count).toBe(0);
});
it("indicates if there are unsent messages", async () => {
const event = mkEvent({
event: true,
type: "m.message",
user: "@user:example.org",
content: {},
});
event.status = EventStatus.NOT_SENT;
room.addPendingEvent(event, "txn");
const { result } = renderHook(() => useUnreadNotifications(room));
const { color, symbol, count } = result.current;
expect(symbol).toBe("!");
expect(color).toBe(NotificationColor.Unsent);
expect(count).toBeGreaterThan(0);
});
it("indicates the user has been invited to a channel", async () => {
room.updateMyMembership("invite");
const { result } = renderHook(() => useUnreadNotifications(room));
const { color, symbol, count } = result.current;
expect(symbol).toBe("!");
expect(color).toBe(NotificationColor.Red);
expect(count).toBeGreaterThan(0);
});
it("shows nothing for muted channels", async () => {
setUnreads(999, 999);
muteRoom(room);
const { result } = renderHook(() => useUnreadNotifications(room));
const { color, count } = result.current;
expect(color).toBe(NotificationColor.None);
expect(count).toBe(0);
});
it("uses the correct number of unreads", async () => {
setUnreads(999, 0);
const { result } = renderHook(() => useUnreadNotifications(room));
const { color, count } = result.current;
expect(color).toBe(NotificationColor.Grey);
expect(count).toBe(999);
});
it("uses the correct number of highlights", async () => {
setUnreads(0, 888);
const { result } = renderHook(() => useUnreadNotifications(room));
const { color, count } = result.current;
expect(color).toBe(NotificationColor.Red);
expect(count).toBe(888);
});
});

View file

@ -93,7 +93,7 @@ describe("RoomViewStore", function () {
getSafeUserId: jest.fn().mockReturnValue(userId),
getDeviceId: jest.fn().mockReturnValue("ABC123"),
sendStateEvent: jest.fn().mockResolvedValue({}),
supportsExperimentalThreads: jest.fn(),
supportsThreads: jest.fn(),
});
const room = new Room(roomId, mockClient, userId);
const room2 = new Room(roomId2, mockClient, userId);

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -15,38 +15,165 @@ limitations under the License.
*/
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixEventEvent, MatrixEvent, MatrixClient } from "matrix-js-sdk/src/matrix";
import {
MatrixEventEvent,
PendingEventOrdering,
EventStatus,
NotificationCountType,
EventType,
} from "matrix-js-sdk/src/matrix";
import { MatrixEvent } from "matrix-js-sdk/src/matrix";
import { stubClient } from "../../test-utils";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
import { mkEvent, muteRoom, stubClient } from "../../test-utils";
import { RoomNotificationState } from "../../../src/stores/notifications/RoomNotificationState";
import * as testUtils from "../../test-utils";
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
import { NotificationColor } from "../../../src/stores/notifications/NotificationColor";
import { createMessageEventContent } from "../../test-utils/events";
describe("RoomNotificationState", () => {
let testRoom: Room;
let room: Room;
let client: MatrixClient;
beforeEach(() => {
stubClient();
client = MatrixClientPeg.get();
testRoom = testUtils.mkStubRoom("$aroomid", "Test room", client);
client = stubClient();
room = new Room("!room:example.com", client, "@user:example.org", {
pendingEventOrdering: PendingEventOrdering.Detached,
});
});
function addThread(room: Room): void {
const threadId = "thread_id";
jest.spyOn(room, "eventShouldLiveIn").mockReturnValue({
shouldLiveInRoom: true,
shouldLiveInThread: true,
threadId,
});
const thread = room.createThread(
threadId,
new MatrixEvent({
room_id: room.roomId,
event_id: "event_root_1",
type: EventType.RoomMessage,
sender: "userId",
content: createMessageEventContent("RootEvent"),
}),
[],
true,
);
for (let i = 0; i < 10; i++) {
thread.addEvent(
new MatrixEvent({
room_id: room.roomId,
event_id: "event_reply_1" + i,
type: EventType.RoomMessage,
sender: "userId",
content: createMessageEventContent("ReplyEvent" + 1),
}),
false,
);
}
}
function setUnreads(room: Room, greys: number, reds: number): void {
room.setUnreadNotificationCount(NotificationCountType.Highlight, reds);
room.setUnreadNotificationCount(NotificationCountType.Total, greys);
}
it("Updates on event decryption", () => {
const roomNotifState = new RoomNotificationState(testRoom as any as Room);
const roomNotifState = new RoomNotificationState(room);
const listener = jest.fn();
roomNotifState.addListener(NotificationStateEvents.Update, listener);
const testEvent = {
getRoomId: () => testRoom.roomId,
getRoomId: () => room.roomId,
} as unknown as MatrixEvent;
testRoom.getUnreadNotificationCount = jest.fn().mockReturnValue(1);
room.getUnreadNotificationCount = jest.fn().mockReturnValue(1);
client.emit(MatrixEventEvent.Decrypted, testEvent);
expect(listener).toHaveBeenCalled();
});
it("removes listeners", () => {
const roomNotifState = new RoomNotificationState(testRoom as any as Room);
const roomNotifState = new RoomNotificationState(room);
expect(() => roomNotifState.destroy()).not.toThrow();
});
it("suggests an 'unread' ! if there are unsent messages", () => {
const roomNotifState = new RoomNotificationState(room);
const event = mkEvent({
event: true,
type: "m.message",
user: "@user:example.org",
content: {},
});
event.status = EventStatus.NOT_SENT;
room.addPendingEvent(event, "txn");
expect(roomNotifState.color).toBe(NotificationColor.Unsent);
expect(roomNotifState.symbol).toBe("!");
expect(roomNotifState.count).toBeGreaterThan(0);
});
it("suggests nothing if the room is muted", () => {
const roomNotifState = new RoomNotificationState(room);
muteRoom(room);
setUnreads(room, 1234, 0);
room.updateMyMembership("join"); // emit
expect(roomNotifState.color).toBe(NotificationColor.None);
expect(roomNotifState.symbol).toBe(null);
expect(roomNotifState.count).toBe(0);
});
it("suggests a red ! if the user has been invited to a room", () => {
const roomNotifState = new RoomNotificationState(room);
room.updateMyMembership("invite"); // emit
expect(roomNotifState.color).toBe(NotificationColor.Red);
expect(roomNotifState.symbol).toBe("!");
expect(roomNotifState.count).toBeGreaterThan(0);
});
it("returns a proper count and color for regular unreads", () => {
const roomNotifState = new RoomNotificationState(room);
setUnreads(room, 4321, 0);
room.updateMyMembership("join"); // emit
expect(roomNotifState.color).toBe(NotificationColor.Grey);
expect(roomNotifState.symbol).toBe(null);
expect(roomNotifState.count).toBe(4321);
});
it("returns a proper count and color for highlights", () => {
const roomNotifState = new RoomNotificationState(room);
setUnreads(room, 0, 69);
room.updateMyMembership("join"); // emit
expect(roomNotifState.color).toBe(NotificationColor.Red);
expect(roomNotifState.symbol).toBe(null);
expect(roomNotifState.count).toBe(69);
});
it("includes threads", async () => {
const roomNotifState = new RoomNotificationState(room);
room.timeline.push(
new MatrixEvent({
room_id: room.roomId,
type: EventType.RoomMessage,
sender: "userId",
content: createMessageEventContent("timeline event"),
}),
);
addThread(room);
room.updateMyMembership("join"); // emit
expect(roomNotifState.color).toBe(NotificationColor.Bold);
expect(roomNotifState.symbol).toBe(null);
});
});

View file

@ -1,60 +0,0 @@
/*
Copyright 2022 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 { PendingEventOrdering } from "matrix-js-sdk/src/client";
import { Feature, ServerSupport } from "matrix-js-sdk/src/feature";
import { Room } from "matrix-js-sdk/src/models/room";
import { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { RoomNotificationStateStore } from "../../../src/stores/notifications/RoomNotificationStateStore";
import { stubClient } from "../../test-utils";
describe("RoomNotificationStateStore", () => {
const ROOM_ID = "!roomId:example.org";
let room;
let client;
beforeEach(() => {
stubClient();
client = MatrixClientPeg.get();
room = new Room(ROOM_ID, client, client.getUserId(), {
pendingEventOrdering: PendingEventOrdering.Detached,
});
});
it("does not use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull();
});
it("use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull();
});
it("does not use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Stable);
RoomNotificationStateStore.instance.getRoomState(room);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).toBeNull();
});
it("use legacy thread notification store", () => {
client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported);
RoomNotificationStateStore.instance.getRoomState(room);
expect(RoomNotificationStateStore.instance.getThreadsRoomState(room)).not.toBeNull();
});
});

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -33,6 +33,9 @@ import {
IPusher,
RoomType,
KNOWN_SAFE_ROOM_VERSION,
ConditionKind,
PushRuleActionName,
IPushRules,
} from "matrix-js-sdk/src/matrix";
import { normalize } from "matrix-js-sdk/src/utils";
import { ReEmitter } from "matrix-js-sdk/src/ReEmitter";
@ -139,7 +142,7 @@ export function createTestClient(): MatrixClient {
getThirdpartyUser: jest.fn().mockResolvedValue([]),
getAccountData: jest.fn().mockImplementation((type) => {
return mkEvent({
user: undefined,
user: "@user:example.com",
room: undefined,
type,
event: true,
@ -166,7 +169,7 @@ export function createTestClient(): MatrixClient {
decryptEventIfNeeded: () => Promise.resolve(),
isUserIgnored: jest.fn().mockReturnValue(false),
getCapabilities: jest.fn().mockResolvedValue({}),
supportsExperimentalThreads: () => false,
supportsThreads: () => false,
getRoomUpgradeHistory: jest.fn().mockReturnValue([]),
getOpenIdToken: jest.fn().mockResolvedValue(undefined),
registerWithIdentityServer: jest.fn().mockResolvedValue({}),
@ -208,6 +211,10 @@ export function createTestClient(): MatrixClient {
setPassword: jest.fn().mockRejectedValue({}),
groupCallEventHandler: { groupCalls: new Map<string, GroupCall>() },
redactEvent: jest.fn(),
createMessagesRequest: jest.fn().mockResolvedValue({
chunk: [],
}),
} as unknown as MatrixClient;
client.reEmitter = new ReEmitter(client);
@ -476,8 +483,12 @@ export function mkMessage({
return mkEvent(event);
}
export function mkStubRoom(roomId: string = null, name: string, client: MatrixClient): Room {
const stubTimeline = { getEvents: () => [] as MatrixEvent[] } as unknown as EventTimeline;
export function mkStubRoom(
roomId: string | null | undefined = null,
name: string | undefined,
client: MatrixClient | undefined,
): Room {
const stubTimeline = { getEvents: (): MatrixEvent[] => [] } as unknown as EventTimeline;
return {
canInvite: jest.fn(),
client,
@ -561,22 +572,25 @@ export function mkServerConfig(hsUrl: string, isUrl: string) {
// These methods make some use of some private methods on the AsyncStoreWithClient to simplify getting into a consistent
// ready state without needing to wire up a dispatcher and pretend to be a js-sdk client.
export const setupAsyncStoreWithClient = async <T = unknown>(store: AsyncStoreWithClient<T>, client: MatrixClient) => {
// @ts-ignore
export const setupAsyncStoreWithClient = async <T extends Object = any>(
store: AsyncStoreWithClient<T>,
client: MatrixClient,
) => {
// @ts-ignore protected access
store.readyStore.useUnitTestClient(client);
// @ts-ignore
// @ts-ignore protected access
await store.onReady();
};
export const resetAsyncStoreWithClient = async <T = unknown>(store: AsyncStoreWithClient<T>) => {
// @ts-ignore
export const resetAsyncStoreWithClient = async <T extends Object = any>(store: AsyncStoreWithClient<T>) => {
// @ts-ignore protected access
await store.onNotReady();
};
export const mockStateEventImplementation = (events: MatrixEvent[]) => {
const stateMap = new EnhancedMap<string, Map<string, MatrixEvent>>();
events.forEach((event) => {
stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey(), event);
stateMap.getOrCreate(event.getType(), new Map()).set(event.getStateKey()!, event);
});
// recreate the overloading in RoomState
@ -613,7 +627,7 @@ export const upsertRoomStateEvents = (room: Room, events: MatrixEvent[]): void =
if (!acc.has(eventType)) {
acc.set(eventType, new Map());
}
acc.get(eventType).set(event.getStateKey(), event);
acc.get(eventType)?.set(event.getStateKey()!, event);
return acc;
}, room.currentState.events || new Map<string, Map<string, MatrixEvent>>());
@ -670,3 +684,25 @@ export const mkPusher = (extra: Partial<IPusher> = {}): IPusher => ({
pushkey: "pushpush",
...extra,
});
/** Add a mute rule for a room. */
export function muteRoom(room: Room): void {
const client = room.client!;
client.pushRules = client.pushRules ?? ({ global: [] } as IPushRules);
client.pushRules.global = client.pushRules.global ?? {};
client.pushRules.global.override = [
{
default: true,
enabled: true,
rule_id: "rule_id",
conditions: [
{
kind: ConditionKind.EventMatch,
key: "room_id",
pattern: room.roomId,
},
],
actions: [PushRuleActionName.DontNotify],
},
];
}

View file

@ -436,7 +436,7 @@ describe("EventUtils", () => {
pendingEventOrdering: PendingEventOrdering.Detached,
});
jest.spyOn(client, "supportsExperimentalThreads").mockReturnValue(true);
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
jest.spyOn(client, "getRoom").mockReturnValue(room);
jest.spyOn(client, "fetchRoomEvent").mockImplementation(async (roomId, eventId) => {
return events[eventId] ?? Promise.reject();

View file

@ -0,0 +1,90 @@
/*
Copyright 2023 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 { render } from "@testing-library/react";
import type { IContent } from "matrix-js-sdk/src/matrix";
import type React from "react";
import { editBodyDiffToHtml } from "../../src/utils/MessageDiffUtils";
describe("editBodyDiffToHtml", () => {
function buildContent(message: string): IContent {
return {
body: message,
format: "org.matrix.custom.html",
formatted_body: message,
msgtype: "m.text",
};
}
function renderDiff(before: string, after: string) {
const node = editBodyDiffToHtml(buildContent(before), buildContent(after));
return render(node as React.ReactElement);
}
it.each([
["simple word changes", "hello", "world"],
["central word changes", "beginning middle end", "beginning :smile: end"],
["text deletions", "<b>hello</b> world", "<b>hello</b>"],
["text additions", "<b>hello</b>", "<b>hello</b> world"],
["block element additions", "hello", "hello <p>world</p>"],
["inline element additions", "hello", "hello <q>world</q>"],
["block element deletions", `hi <blockquote>there</blockquote>`, "hi"],
["inline element deletions", `hi <em>there</em>`, "hi"],
["element replacements", `hi <i>there</i>`, "hi <em>there</em>"],
["attribute modifications", `<a href="#hi">hi</a>`, `<a href="#bye">hi</a>`],
["attribute deletions", `<a href="#hi">hi</a>`, `<a>hi</a>`],
["attribute additions", `<a>hi</a>`, `<a href="#/room/!123">hi</a>`],
])("renders %s", (_label, before, after) => {
const { container } = renderDiff(before, after);
expect(container).toMatchSnapshot();
});
// see https://github.com/fiduswriter/diffDOM/issues/90
// fixed in diff-dom in 4.2.2+
it("deduplicates diff steps", () => {
const { container } = renderDiff("<div><em>foo</em> bar baz</div>", "<div><em>foo</em> bar bay</div>");
expect(container).toMatchSnapshot();
});
it("handles non-html input", () => {
const before: IContent = {
body: "who knows what's going on <strong>here</strong>",
format: "org.exotic.encoding",
formatted_body: "who knows what's going on <strong>here</strong>",
msgtype: "m.text",
};
const after: IContent = {
...before,
body: "who knows what's going on <strong>there</strong>",
formatted_body: "who knows what's going on <strong>there</strong>",
};
const { container } = render(editBodyDiffToHtml(before, after) as React.ReactElement);
expect(container).toMatchSnapshot();
});
// see https://github.com/vector-im/element-web/issues/23665
it("handles complex transformations", () => {
const { container } = renderDiff(
'<span data-mx-maths="{☃️}^\\infty"><code>{☃️}^\\infty</code></span>',
'<span data-mx-maths="{😃}^\\infty"><code>{😃}^\\infty</code></span>',
);
expect(container).toMatchSnapshot();
});
});

View file

@ -0,0 +1,467 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`editBodyDiffToHtml deduplicates diff steps 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<div>
<em>
foo
</em>
<span>
bar ba
<span
class="mx_EditHistoryMessage_deletion"
>
z
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
y
</span>
</span>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml handles complex transformations 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':snowman:'>☃️</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
data-mx-maths="{<span class='mx_Emoji' title=':smiley:'>😃</span>}^\\infty"
>
<code>
{
<span
class="mx_Emoji"
title=":snowman:"
>
☃️
</span>
}^\\infty
</code>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml handles non-html input 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
who knows what's going on &lt;strong&gt;
<span
class="mx_EditHistoryMessage_insertion"
>
t
</span>
here&lt;/strong&gt;
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders attribute additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span
target="undefined"
>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
rel="noreferrer noopener"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="undefined"
rel="noreferrer noopener"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders attribute deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders attribute modifications 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
<a
href="#hi"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<a
href="#bye"
rel="noreferrer noopener"
target="_blank"
>
hi
</a>
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders block element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_insertion"
>
<p>
world
</p>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders block element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<div
class="mx_EditHistoryMessage_deletion"
>
<blockquote>
there
</blockquote>
</div>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders central word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
beginning
<span
class="mx_EditHistoryMessage_insertion"
>
:s
</span>
mi
<span
class="mx_EditHistoryMessage_deletion"
>
dd
</span>
le
<span
class="mx_EditHistoryMessage_insertion"
>
:
</span>
end
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders element replacements 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
<i>
there
</i>
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
<em>
there
</em>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders inline element additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hello
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders inline element deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
hi
<span
class="mx_EditHistoryMessage_deletion"
>
</span>
</span>
<span
class="mx_EditHistoryMessage_deletion"
>
<em>
there
</em>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders simple word changes 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<span>
<span
class="mx_EditHistoryMessage_deletion"
>
hello
</span>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders text additions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_insertion"
>
world
</span>
</span>
</div>
`;
exports[`editBodyDiffToHtml renders text deletions 1`] = `
<div>
<span
class="mx_EventTile_body markdown-body"
dir="auto"
>
<b>
hello
</b>
<span
class="mx_EditHistoryMessage_deletion"
>
world
</span>
</span>
</div>
`;

View file

@ -1,5 +1,5 @@
/*
Copyright 2022 The Matrix.org Foundation C.I.C.
Copyright 2022 - 2023 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.
@ -14,26 +14,101 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { mocked } from "jest-mock";
import { EventType, IRoomEvent, MatrixClient, MatrixEvent, MsgType, Room, RoomMember } from "matrix-js-sdk/src/matrix";
import fetchMock from "fetch-mock-jest";
import { createTestClient, mkStubRoom, REPEATABLE_DATE } from "../../test-utils";
import { filterConsole, mkStubRoom, REPEATABLE_DATE, stubClient } from "../../test-utils";
import { ExportType, IExportOptions } from "../../../src/utils/exportUtils/exportUtils";
import SdkConfig from "../../../src/SdkConfig";
import HTMLExporter from "../../../src/utils/exportUtils/HtmlExport";
import DMRoomMap from "../../../src/utils/DMRoomMap";
import { mediaFromMxc } from "../../../src/customisations/Media";
jest.mock("jszip");
const EVENT_MESSAGE: IRoomEvent = {
event_id: "$1",
type: EventType.RoomMessage,
sender: "@bob:example.com",
origin_server_ts: 0,
content: {
msgtype: "m.text",
body: "Message",
avatar_url: "mxc://example.org/avatar.bmp",
},
};
const EVENT_ATTACHMENT: IRoomEvent = {
event_id: "$2",
type: EventType.RoomMessage,
sender: "@alice:example.com",
origin_server_ts: 1,
content: {
msgtype: MsgType.File,
body: "hello.txt",
filename: "hello.txt",
url: "mxc://example.org/test-id",
},
};
describe("HTMLExport", () => {
let client: jest.Mocked<MatrixClient>;
let room: Room;
filterConsole(
"Starting export",
"events in", // Fetched # events in # seconds
"events so far",
"Export successful!",
"does not have an m.room.create event",
"Creating HTML",
"Generating a ZIP",
"Cleaning up",
);
beforeEach(() => {
jest.useFakeTimers();
jest.setSystemTime(REPEATABLE_DATE);
client = stubClient() as jest.Mocked<MatrixClient>;
DMRoomMap.makeShared();
room = new Room("!myroom:example.org", client, "@me:example.org");
client.getRoom.mockReturnValue(room);
});
afterEach(() => {
mocked(SdkConfig.get).mockRestore();
});
function mockMessages(...events: IRoomEvent[]): void {
client.createMessagesRequest.mockImplementation((_roomId, fromStr, limit = 30) => {
const from = fromStr === null ? 0 : parseInt(fromStr);
const chunk = events.slice(from, limit);
return Promise.resolve({
chunk,
from: from.toString(),
to: (from + limit).toString(),
});
});
}
/** Retrieve a map of files within the zip. */
function getFiles(exporter: HTMLExporter): { [filename: string]: Blob } {
//@ts-ignore private access
const files = exporter.files;
return files.reduce((d, f) => ({ ...d, [f.name]: f.blob }), {});
}
function getMessageFile(exporter: HTMLExporter): Blob {
const files = getFiles(exporter);
return files["messages.html"]!;
}
/** set a mock fetch response for an MXC */
function mockMxc(mxc: string, body: string) {
const media = mediaFromMxc(mxc, client);
fetchMock.get(media.srcHttp, body);
}
it("should have an SDK-branded destination file name", () => {
const roomName = "My / Test / Room: Welcome";
const client = createTestClient();
const stubOptions: IExportOptions = {
attachmentsIncluded: false,
maxSize: 50000000,
@ -43,10 +118,201 @@ describe("HTMLExport", () => {
expect(exporter.destinationFileName).toMatchSnapshot();
jest.spyOn(SdkConfig, "get").mockImplementation(() => {
return { brand: "BrandedChat/WithSlashes/ForFun" };
});
SdkConfig.put({ brand: "BrandedChat/WithSlashes/ForFun" });
expect(exporter.destinationFileName).toMatchSnapshot();
});
it("should export", async () => {
const events = [...Array(50)].map<IRoomEvent>((_, i) => ({
event_id: `${i}`,
type: EventType.RoomMessage,
sender: `@user${i}:example.com`,
origin_server_ts: 5_000 + i * 1000,
content: {
msgtype: "m.text",
body: `Message #${i}`,
},
}));
mockMessages(...events);
const exporter = new HTMLExporter(
room,
ExportType.LastNMessages,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
numberOfMessages: events.length,
},
() => {},
);
await exporter.export();
const file = getMessageFile(exporter);
expect(await file.text()).toMatchSnapshot();
});
it("should include the room's avatar", async () => {
mockMessages(EVENT_MESSAGE);
const mxc = "mxc://www.example.com/avatars/nice-room.jpeg";
const avatar = "011011000110111101101100";
jest.spyOn(room, "getMxcAvatarUrl").mockReturnValue(mxc);
mockMxc(mxc, avatar);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
const files = getFiles(exporter);
expect(await files["room.png"]!.text()).toBe(avatar);
});
it("should include the creation event", async () => {
const creator = "@bob:example.com";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomCreate,
event_id: "$00001",
room_id: room.roomId,
sender: creator,
origin_server_ts: 0,
content: {},
state_key: "",
}),
]);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
expect(await getMessageFile(exporter).text()).toContain(`${creator} created this room.`);
});
it("should include the topic", async () => {
const topic = ":^-) (-^:";
mockMessages(EVENT_MESSAGE);
room.currentState.setStateEvents([
new MatrixEvent({
type: EventType.RoomTopic,
event_id: "$00001",
room_id: room.roomId,
sender: "@alice:example.com",
origin_server_ts: 0,
content: { topic },
state_key: "",
}),
]);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
expect(await getMessageFile(exporter).text()).toContain(`Topic: ${topic}`);
});
it("should include avatars", async () => {
mockMessages(EVENT_MESSAGE);
jest.spyOn(RoomMember.prototype, "getMxcAvatarUrl").mockReturnValue("mxc://example.org/avatar.bmp");
const avatarContent = "this is a bitmap all the pixels are red :^-)";
mockMxc("mxc://example.org/avatar.bmp", avatarContent);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
// Ensure that the avatar is present
const files = getFiles(exporter);
const file = files["users/@bob-example.com.png"];
expect(file).not.toBeUndefined();
// Ensure it has the expected content
expect(await file.text()).toBe(avatarContent);
});
it("should include attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const attachmentBody = "Lorem ipsum dolor sit amet";
mockMxc("mxc://example.org/test-id", attachmentBody);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: true,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
// Ensure that the attachment is present
const files = getFiles(exporter);
const file = files[Object.keys(files).find((k) => k.endsWith(".txt"))];
expect(file).not.toBeUndefined();
// Ensure that the attachment has the expected content
const text = await file.text();
expect(text).toBe(attachmentBody);
});
it("should omit attachments", async () => {
mockMessages(EVENT_MESSAGE, EVENT_ATTACHMENT);
const exporter = new HTMLExporter(
room,
ExportType.Timeline,
{
attachmentsIncluded: false,
maxSize: 1_024 * 1_024,
},
() => {},
);
await exporter.export();
// Ensure that the attachment is present
const files = getFiles(exporter);
for (const fileName of Object.keys(files)) {
expect(fileName).not.toMatch(/^files\/hello/);
}
});
});

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,26 @@
/*
Copyright 2023 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 getExportCSS from "../../../src/utils/exportUtils/exportCSS";
describe("exportCSS", () => {
describe("getExportCSS", () => {
it("supports documents missing stylesheets", async () => {
const css = await getExportCSS(new Set());
expect(css).not.toContain("color-scheme: light");
});
});
});

View file

@ -67,8 +67,10 @@ describe("VoiceBroadcastRecorder", () => {
describe("instance", () => {
const chunkLength = 30;
const headers1 = new Uint8Array([1, 2]);
const headers2 = new Uint8Array([3, 4]);
// 0... OpusHead
const headers1 = new Uint8Array([...Array(28).fill(0), 79, 112, 117, 115, 72, 101, 97, 100]);
// 0... OpusTags
const headers2 = new Uint8Array([...Array(28).fill(0), 79, 112, 117, 115, 84, 97, 103, 115]);
const chunk1 = new Uint8Array([5, 6]);
const chunk2a = new Uint8Array([7, 8]);
const chunk2b = new Uint8Array([9, 10]);
@ -79,12 +81,16 @@ describe("VoiceBroadcastRecorder", () => {
let onChunkRecorded: (chunk: ChunkRecordedPayload) => void;
const simulateFirstChunk = (): void => {
// send headers in wrong order and multiple times to test robustness for that
voiceRecording.onDataAvailable(headers2);
voiceRecording.onDataAvailable(headers1);
voiceRecording.onDataAvailable(headers1);
voiceRecording.onDataAvailable(headers2);
// set recorder seconds to something greater than the test chunk length of 30
// @ts-ignore
voiceRecording.recorderSeconds = 42;
voiceRecording.onDataAvailable(chunk1);
voiceRecording.onDataAvailable(headers1);
};
const expectOnFirstChunkRecorded = (): void => {
@ -155,7 +161,7 @@ describe("VoiceBroadcastRecorder", () => {
expect(voiceBroadcastRecorder.contentType).toBe(contentType);
});
describe("when the first page from recorder has been received", () => {
describe("when the first header from recorder has been received", () => {
beforeEach(() => {
voiceRecording.onDataAvailable(headers1);
});
@ -163,7 +169,7 @@ describe("VoiceBroadcastRecorder", () => {
itShouldNotEmitAChunkRecordedEvent();
});
describe("when a second page from recorder has been received", () => {
describe("when the second header from recorder has been received", () => {
beforeEach(() => {
voiceRecording.onDataAvailable(headers1);
voiceRecording.onDataAvailable(headers2);
@ -229,6 +235,10 @@ describe("VoiceBroadcastRecorder", () => {
// simulate a second chunk
voiceRecording.onDataAvailable(chunk2a);
// send headers again to test robustness for that
voiceRecording.onDataAvailable(headers2);
// add another 30 seconds for the next chunk
// @ts-ignore
voiceRecording.recorderSeconds = 72;

View file

@ -17,7 +17,7 @@ limitations under the License.
import { mocked } from "jest-mock";
import { screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { MatrixClient, MatrixEvent, MatrixEventEvent, Room } from "matrix-js-sdk/src/matrix";
import { Playback, PlaybackState } from "../../../src/audio/Playback";
import { PlaybackManager } from "../../../src/audio/PlaybackManager";
@ -268,6 +268,32 @@ describe("VoiceBroadcastPlayback", () => {
expect(chunk1Playback.play).toHaveBeenCalled();
});
});
describe("and receiving the first undecryptable chunk", () => {
beforeEach(() => {
jest.spyOn(chunk1Event, "isDecryptionFailure").mockReturnValue(true);
room.relations.aggregateChildEvent(chunk1Event);
});
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Error);
it("should not update the duration", () => {
expect(playback.durationSeconds).toBe(0);
});
describe("and the chunk is decrypted", () => {
beforeEach(() => {
mocked(chunk1Event.isDecryptionFailure).mockReturnValue(false);
chunk1Event.emit(MatrixEventEvent.Decrypted, chunk1Event);
});
itShouldSetTheStateTo(VoiceBroadcastPlaybackState.Paused);
it("should not update the duration", () => {
expect(playback.durationSeconds).toBe(2.3);
});
});
});
});
});

View file

@ -0,0 +1,118 @@
/*
Copyright 2023 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 { EventType, MatrixEvent, RelationType, Room } from "matrix-js-sdk/src/matrix";
import { MatrixClient } from "matrix-js-sdk/src/client";
import { mocked } from "jest-mock";
import { isRelatedToVoiceBroadcast, VoiceBroadcastInfoState } from "../../../src/voice-broadcast";
import { mkEvent, stubClient } from "../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
const mkRelatedEvent = (
room: Room,
relationType: RelationType,
relatesTo: MatrixEvent | undefined,
client: MatrixClient,
): MatrixEvent => {
const event = mkEvent({
event: true,
type: EventType.RoomMessage,
room: room.roomId,
content: {
"m.relates_to": {
rel_type: relationType,
event_id: relatesTo?.getId(),
},
},
user: client.getSafeUserId(),
});
room.addLiveEvents([event]);
return event;
};
describe("isRelatedToVoiceBroadcast", () => {
const roomId = "!room:example.com";
let client: MatrixClient;
let room: Room;
let broadcastEvent: MatrixEvent;
let nonBroadcastEvent: MatrixEvent;
beforeAll(() => {
client = stubClient();
room = new Room(roomId, client, client.getSafeUserId());
mocked(client.getRoom).mockImplementation((getRoomId: string): Room | null => {
if (getRoomId === roomId) return room;
return null;
});
broadcastEvent = mkVoiceBroadcastInfoStateEvent(
roomId,
VoiceBroadcastInfoState.Started,
client.getSafeUserId(),
"ABC123",
);
nonBroadcastEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
room: roomId,
content: {},
user: client.getSafeUserId(),
});
room.addLiveEvents([broadcastEvent, nonBroadcastEvent]);
});
it("should return true if related (reference) to a broadcast event", () => {
expect(
isRelatedToVoiceBroadcast(mkRelatedEvent(room, RelationType.Reference, broadcastEvent, client), client),
).toBe(true);
});
it("should return false if related (reference) is undefeind", () => {
expect(isRelatedToVoiceBroadcast(mkRelatedEvent(room, RelationType.Reference, undefined, client), client)).toBe(
false,
);
});
it("should return false if related (referenireplace) to a broadcast event", () => {
expect(
isRelatedToVoiceBroadcast(mkRelatedEvent(room, RelationType.Replace, broadcastEvent, client), client),
).toBe(false);
});
it("should return false if the event has no relation", () => {
const noRelationEvent = mkEvent({
event: true,
type: EventType.RoomMessage,
room: room.roomId,
content: {},
user: client.getSafeUserId(),
});
expect(isRelatedToVoiceBroadcast(noRelationEvent, client)).toBe(false);
});
it("should return false for an unknown room", () => {
const otherRoom = new Room("!other:example.com", client, client.getSafeUserId());
expect(
isRelatedToVoiceBroadcast(
mkRelatedEvent(otherRoom, RelationType.Reference, broadcastEvent, client),
client,
),
).toBe(false);
});
});