From e0e149be3de8364127f3dce6844bc3bc3686dbeb Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 27 Jan 2023 14:58:38 +0100 Subject: [PATCH 01/20] Ensure room is actually in space hierarchy when resolving its latest version Relates to: vector-im/element-web#24329 --- src/components/structures/SpaceHierarchy.tsx | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index af6f29838267..f99cf73f5f50 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -413,9 +413,18 @@ interface IHierarchyLevelProps { onToggleClick?(parentId: string, childId: string): void; } -const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom): IHierarchyRoom => { +const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, hierarchy: RoomHierarchy): IHierarchyRoom => { const history = cli.getRoomUpgradeHistory(room.room_id, true); - const cliRoom = history[history.length - 1]; + + // Pick latest room that is actually part of the hierarchy + let cliRoom = null; + for (let idx = history.length - 1; idx >= 0; --idx) { + if (hierarchy.roomMap[history[idx].roomId]) { + cliRoom = history[idx]; + break; + } + } + if (cliRoom) { return { ...room, @@ -461,7 +470,7 @@ export const HierarchyLevel: React.FC = ({ (result, ev: IHierarchyRelation) => { const room = hierarchy.roomMap.get(ev.state_key); if (room && roomSet.has(room)) { - result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room)); + result[room.room_type === RoomType.Space ? 0 : 1].push(toLocalRoom(cli, room, hierarchy)); } return result; }, From c557162592a38e99bf907a48f89d87eda4ca7d93 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 27 Jan 2023 15:14:20 +0100 Subject: [PATCH 02/20] Try to appease the linter --- src/components/structures/SpaceHierarchy.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index f99cf73f5f50..84ec987337f0 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -419,7 +419,7 @@ const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, hierarchy: RoomHie // Pick latest room that is actually part of the hierarchy let cliRoom = null; for (let idx = history.length - 1; idx >= 0; --idx) { - if (hierarchy.roomMap[history[idx].roomId]) { + if (hierarchy.roomMap.get(history[idx].roomId)) { cliRoom = history[idx]; break; } @@ -432,7 +432,7 @@ const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, hierarchy: RoomHie room_type: cliRoom.getType(), name: cliRoom.name, topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic, - avatar_url: cliRoom.getMxcAvatarUrl(), + avatar_url: cliRoom.getMxcAvatarUrl() ?? undefined, canonical_alias: cliRoom.getCanonicalAlias(), aliases: cliRoom.getAltAliases(), world_readable: From 0d0d7a3e2cfe5793c60bb3f3b4e110ea66eec758 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 27 Jan 2023 15:25:10 +0100 Subject: [PATCH 03/20] Try to appease the linter --- src/components/structures/SpaceHierarchy.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 84ec987337f0..f358e3788978 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -432,8 +432,8 @@ const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, hierarchy: RoomHie room_type: cliRoom.getType(), name: cliRoom.name, topic: cliRoom.currentState.getStateEvents(EventType.RoomTopic, "")?.getContent().topic, - avatar_url: cliRoom.getMxcAvatarUrl() ?? undefined, - canonical_alias: cliRoom.getCanonicalAlias(), + avatar_url: cliRoom.getMxcAvatarUrl(), + canonical_alias: cliRoom.getCanonicalAlias() ?? undefined, aliases: cliRoom.getAltAliases(), world_readable: cliRoom.currentState.getStateEvents(EventType.RoomHistoryVisibility, "")?.getContent() From 7788d50b02456d34d12d5f94aed4db7b18cf225a Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 27 Jan 2023 20:20:01 +0100 Subject: [PATCH 04/20] Add tests --- src/components/structures/SpaceHierarchy.tsx | 2 +- .../structures/SpaceHierarchy-test.tsx | 48 ++++++++++++++++++- 2 files changed, 47 insertions(+), 3 deletions(-) diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index f358e3788978..7d6888a19791 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -413,7 +413,7 @@ interface IHierarchyLevelProps { onToggleClick?(parentId: string, childId: string): void; } -const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, hierarchy: RoomHierarchy): IHierarchyRoom => { +export const toLocalRoom = (cli: MatrixClient, room: IHierarchyRoom, hierarchy: RoomHierarchy): IHierarchyRoom => { const history = cli.getRoomUpgradeHistory(room.room_id, true); // Pick latest room that is actually part of the hierarchy diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 918180281420..a2a63ae0b820 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -17,11 +17,12 @@ limitations under the License. import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; +import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; -import { stubClient } from "../../test-utils"; +import { mkStubRoom, stubClient } from "../../test-utils"; import dispatcher from "../../../src/dispatcher/dispatcher"; -import { showRoom } from "../../../src/components/structures/SpaceHierarchy"; +import { showRoom, toLocalRoom } from "../../../src/components/structures/SpaceHierarchy"; import { Action } from "../../../src/dispatcher/actions"; describe("SpaceHierarchy", () => { @@ -67,4 +68,47 @@ describe("SpaceHierarchy", () => { }); }); }); + + describe("toLocalRoom", () => { + let client: MatrixClient; + let roomV1: Room; + let roomV2: Room; + let roomV3: Room; + + beforeEach(() => { + stubClient(); + client = MatrixClientPeg.get(); + roomV1 = mkStubRoom("room-id-1", "Room V1", client); + roomV2 = mkStubRoom("room-id-2", "Room V2", client); + roomV3 = mkStubRoom("room-id-3", "Room V3", client); + jest.spyOn(client, "getRoomUpgradeHistory").mockReturnValue([roomV1, roomV2, roomV3]); + }); + + it("grabs last room that is in hierarchy when latest version is in hierarchy", () => { + const hierarchy = { roomMap: new Map([ + [roomV1.roomId, { room_id: roomV1.roomId } as IHierarchyRoom], + [roomV2.roomId, { room_id: roomV2.roomId } as IHierarchyRoom], + [roomV3.roomId, { room_id: roomV3.roomId } as IHierarchyRoom], + ]) } as RoomHierarchy; + const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV1.room_id).toEqual(roomV3.roomId); + const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV2.room_id).toEqual(roomV3.roomId); + const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV3.room_id).toEqual(roomV3.roomId); + }); + + it("grabs last room that is in hierarchy when latest version is *not* in hierarchy", () => { + const hierarchy = { roomMap: new Map([ + [roomV1.roomId, { room_id: roomV1.roomId } as IHierarchyRoom], + [roomV2.roomId, { room_id: roomV2.roomId } as IHierarchyRoom] + ]) } as RoomHierarchy; + const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV1.room_id).toEqual(roomV2.roomId); + const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV2.room_id).toEqual(roomV2.roomId); + const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV3.room_id).toEqual(roomV2.roomId); + }); + }); }); From 6f4581943b003c533303e41d746b9f4112cbc40c Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 27 Jan 2023 20:29:50 +0100 Subject: [PATCH 05/20] Prettify it --- .../structures/SpaceHierarchy-test.tsx | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index a2a63ae0b820..796e8848d485 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -85,11 +85,13 @@ describe("SpaceHierarchy", () => { }); it("grabs last room that is in hierarchy when latest version is in hierarchy", () => { - const hierarchy = { roomMap: new Map([ - [roomV1.roomId, { room_id: roomV1.roomId } as IHierarchyRoom], - [roomV2.roomId, { room_id: roomV2.roomId } as IHierarchyRoom], - [roomV3.roomId, { room_id: roomV3.roomId } as IHierarchyRoom], - ]) } as RoomHierarchy; + const hierarchy = { + roomMap: new Map([ + [roomV1.roomId, { room_id: roomV1.roomId } as IHierarchyRoom], + [roomV2.roomId, { room_id: roomV2.roomId } as IHierarchyRoom], + [roomV3.roomId, { room_id: roomV3.roomId } as IHierarchyRoom], + ]), + } as RoomHierarchy; const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as IHierarchyRoom, hierarchy); expect(localRoomV1.room_id).toEqual(roomV3.roomId); const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as IHierarchyRoom, hierarchy); @@ -99,10 +101,12 @@ describe("SpaceHierarchy", () => { }); it("grabs last room that is in hierarchy when latest version is *not* in hierarchy", () => { - const hierarchy = { roomMap: new Map([ - [roomV1.roomId, { room_id: roomV1.roomId } as IHierarchyRoom], - [roomV2.roomId, { room_id: roomV2.roomId } as IHierarchyRoom] - ]) } as RoomHierarchy; + const hierarchy = { + roomMap: new Map([ + [roomV1.roomId, { room_id: roomV1.roomId } as IHierarchyRoom], + [roomV2.roomId, { room_id: roomV2.roomId } as IHierarchyRoom], + ]), + } as RoomHierarchy; const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as IHierarchyRoom, hierarchy); expect(localRoomV1.room_id).toEqual(roomV2.roomId); const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as IHierarchyRoom, hierarchy); From d0de2a6851ff21d383ed18f054f8a32f37b06619 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Mon, 30 Jan 2023 09:53:41 +0100 Subject: [PATCH 06/20] Add test case for no-version-in-hierarchy situation --- test/components/structures/SpaceHierarchy-test.tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 796e8848d485..27e55e95c105 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -114,5 +114,15 @@ describe("SpaceHierarchy", () => { const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as IHierarchyRoom, hierarchy); expect(localRoomV3.room_id).toEqual(roomV2.roomId); }); + + it("returns specified room when none of the versions is in hierarchy", () => { + const hierarchy = { roomMap: new Map([]) } as RoomHierarchy; + const localRoomV1 = toLocalRoom(client, { room_id: roomV1.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV1.room_id).toEqual(roomV1.roomId); + const localRoomV2 = toLocalRoom(client, { room_id: roomV2.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV2.room_id).toEqual(roomV2.roomId); + const localRoomV3 = toLocalRoom(client, { room_id: roomV3.roomId } as IHierarchyRoom, hierarchy); + expect(localRoomV3.room_id).toEqual(roomV3.roomId); + }); }); }); From c63daf62ede3761290d7361299fa0dea370229d8 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 1 Feb 2023 19:51:37 +0100 Subject: [PATCH 07/20] Add snapshot test --- .../structures/SpaceHierarchy-test.tsx | 86 +++++++++- .../SpaceHierarchy-test.tsx.snap | 159 ++++++++++++++++++ 2 files changed, 244 insertions(+), 1 deletion(-) create mode 100644 test/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 27e55e95c105..3d29b77646d0 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import React from "react"; +import { render } from "@testing-library/react"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room } from "matrix-js-sdk/src/models/room"; import { RoomHierarchy } from "matrix-js-sdk/src/room-hierarchy"; @@ -22,8 +24,17 @@ import { IHierarchyRoom } from "matrix-js-sdk/src/@types/spaces"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { mkStubRoom, stubClient } from "../../test-utils"; import dispatcher from "../../../src/dispatcher/dispatcher"; -import { showRoom, toLocalRoom } from "../../../src/components/structures/SpaceHierarchy"; +import { HierarchyLevel, showRoom, toLocalRoom } from "../../../src/components/structures/SpaceHierarchy"; import { Action } from "../../../src/dispatcher/actions"; +import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; + +// Fake random strings to give a predictable snapshot for checkbox IDs +jest.mock("matrix-js-sdk/src/randomstring", () => { + return { + randomString: () => "abdefghi", + }; +}); describe("SpaceHierarchy", () => { describe("showRoom", () => { @@ -125,4 +136,77 @@ describe("SpaceHierarchy", () => { expect(localRoomV3.room_id).toEqual(roomV3.roomId); }); }); + + describe("", () => { + let client: MatrixClient; + let dmRoomMap: DMRoomMap; + + let root: Room; + let room1: Room; + let room2: Room; + + let hierarchyRoot: IHierarchyRoom + let hierarchyRoom1: IHierarchyRoom + let hierarchyRoom2: IHierarchyRoom + + let roomHierarchy: RoomHierarchy; + + beforeEach(() => { + stubClient(); + client = MatrixClientPeg.get(); + + dmRoomMap = { + getUserIdForRoomId: jest.fn() + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + + root = mkStubRoom("room-id-1", "Room 1", client); + room1 = mkStubRoom("room-id-2", "Room 2", client); + room2 = mkStubRoom("room-id-3", "Room 3", client); + + hierarchyRoot = { + room_id: root.roomId, + num_joined_members: 1, + children_state: [{ + state_key: room1.roomId, + content: { order: "1" } + }, { + state_key: room2.roomId, + content: { order: "2" } + }] + } as IHierarchyRoom + hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom + hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom + + roomHierarchy = { + roomMap: new Map([ + [root.roomId, hierarchyRoot], + [room1.roomId, hierarchyRoom1], + [room2.roomId, hierarchyRoom2], + ]), + isSuggested: jest.fn() + } as unknown as RoomHierarchy; + }); + + it("renders", () => { + const defaultProps = { + root: hierarchyRoot, + roomSet: new Set([hierarchyRoom1, hierarchyRoom2]), + hierarchy: roomHierarchy, + parents: new Set(), + selectedMap: new Map>(), + onViewRoomClick: jest.fn(), + onJoinRoomClick: jest.fn(), + onToggleClick: jest.fn(), + }; + const getComponent = (props = {}): React.ReactElement => ( + + ; + + ); + + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + }); }); diff --git a/test/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap b/test/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap new file mode 100644 index 000000000000..25340face147 --- /dev/null +++ b/test/components/structures/__snapshots__/SpaceHierarchy-test.tsx.snap @@ -0,0 +1,159 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`SpaceHierarchy renders 1`] = ` +
+
  • +
    +
    +
    + +
    +
    + Unnamed Room +
    + Joined +
    +
    +
    + 2 members + · + +
    +
    +
    +
    + View +
    + + +
    +
  • +
  • +
    +
    +
    + +
    +
    + Unnamed Room +
    + Joined +
    +
    +
    + 3 members + · + +
    +
    +
    +
    + View +
    + + +
    +
  • + ; +
    +`; From bf94d50be2f99f20896a26334d5ab94c503f4887 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Wed, 1 Feb 2023 19:53:01 +0100 Subject: [PATCH 08/20] Prettyfy --- .../structures/SpaceHierarchy-test.tsx | 33 ++++++++++--------- 1 file changed, 18 insertions(+), 15 deletions(-) diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 3d29b77646d0..34746b2960a2 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -145,9 +145,9 @@ describe("SpaceHierarchy", () => { let room1: Room; let room2: Room; - let hierarchyRoot: IHierarchyRoom - let hierarchyRoom1: IHierarchyRoom - let hierarchyRoom2: IHierarchyRoom + let hierarchyRoot: IHierarchyRoom; + let hierarchyRoom1: IHierarchyRoom; + let hierarchyRoom2: IHierarchyRoom; let roomHierarchy: RoomHierarchy; @@ -156,7 +156,7 @@ describe("SpaceHierarchy", () => { client = MatrixClientPeg.get(); dmRoomMap = { - getUserIdForRoomId: jest.fn() + getUserIdForRoomId: jest.fn(), } as unknown as DMRoomMap; jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); @@ -167,16 +167,19 @@ describe("SpaceHierarchy", () => { hierarchyRoot = { room_id: root.roomId, num_joined_members: 1, - children_state: [{ - state_key: room1.roomId, - content: { order: "1" } - }, { - state_key: room2.roomId, - content: { order: "2" } - }] - } as IHierarchyRoom - hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom - hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom + children_state: [ + { + state_key: room1.roomId, + content: { order: "1" }, + }, + { + state_key: room2.roomId, + content: { order: "2" }, + }, + ], + } as IHierarchyRoom; + hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom; + hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom; roomHierarchy = { roomMap: new Map([ @@ -184,7 +187,7 @@ describe("SpaceHierarchy", () => { [room1.roomId, hierarchyRoom1], [room2.roomId, hierarchyRoom2], ]), - isSuggested: jest.fn() + isSuggested: jest.fn(), } as unknown as RoomHierarchy; }); From 509459f1d7fa7688c5e1686b297b073c8d107815 Mon Sep 17 00:00:00 2001 From: Germain Date: Thu, 2 Feb 2023 17:18:35 +0000 Subject: [PATCH 09/20] Exclude devtools view from coverage checks (#10049) --- sonar-project.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/sonar-project.properties b/sonar-project.properties index a48c03603fcd..a8d8f0cf860c 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -10,5 +10,5 @@ sonar.exclusions=__mocks__,docs sonar.typescript.tsconfigPath=./tsconfig.json sonar.javascript.lcov.reportPaths=coverage/lcov.info -sonar.coverage.exclusions=test/**/*,cypress/**/* +sonar.coverage.exclusions=test/**/*,cypress/**/*,src/components/views/dialogs/devtools/**/* sonar.testExecutionReportPaths=coverage/jest-sonar-report.xml From 568042b9e7e62bd0031751c3e3ce2548bea773df Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 2 Feb 2023 20:20:57 +0100 Subject: [PATCH 10/20] Move test setup to describe scope --- .../structures/SpaceHierarchy-test.tsx | 108 +++++++----------- 1 file changed, 43 insertions(+), 65 deletions(-) diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 34746b2960a2..99a11a92a7da 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -81,19 +81,12 @@ describe("SpaceHierarchy", () => { }); describe("toLocalRoom", () => { - let client: MatrixClient; - let roomV1: Room; - let roomV2: Room; - let roomV3: Room; - - beforeEach(() => { - stubClient(); - client = MatrixClientPeg.get(); - roomV1 = mkStubRoom("room-id-1", "Room V1", client); - roomV2 = mkStubRoom("room-id-2", "Room V2", client); - roomV3 = mkStubRoom("room-id-3", "Room V3", client); - jest.spyOn(client, "getRoomUpgradeHistory").mockReturnValue([roomV1, roomV2, roomV3]); - }); + stubClient(); + let client = MatrixClientPeg.get(); + let roomV1 = mkStubRoom("room-id-1", "Room V1", client); + let roomV2 = mkStubRoom("room-id-2", "Room V2", client); + let roomV3 = mkStubRoom("room-id-3", "Room V3", client); + jest.spyOn(client, "getRoomUpgradeHistory").mockReturnValue([roomV1, roomV2, roomV3]); it("grabs last room that is in hierarchy when latest version is in hierarchy", () => { const hierarchy = { @@ -138,58 +131,43 @@ describe("SpaceHierarchy", () => { }); describe("", () => { - let client: MatrixClient; - let dmRoomMap: DMRoomMap; - - let root: Room; - let room1: Room; - let room2: Room; - - let hierarchyRoot: IHierarchyRoom; - let hierarchyRoom1: IHierarchyRoom; - let hierarchyRoom2: IHierarchyRoom; - - let roomHierarchy: RoomHierarchy; - - beforeEach(() => { - stubClient(); - client = MatrixClientPeg.get(); - - dmRoomMap = { - getUserIdForRoomId: jest.fn(), - } as unknown as DMRoomMap; - jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); - - root = mkStubRoom("room-id-1", "Room 1", client); - room1 = mkStubRoom("room-id-2", "Room 2", client); - room2 = mkStubRoom("room-id-3", "Room 3", client); - - hierarchyRoot = { - room_id: root.roomId, - num_joined_members: 1, - children_state: [ - { - state_key: room1.roomId, - content: { order: "1" }, - }, - { - state_key: room2.roomId, - content: { order: "2" }, - }, - ], - } as IHierarchyRoom; - hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom; - hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom; - - roomHierarchy = { - roomMap: new Map([ - [root.roomId, hierarchyRoot], - [room1.roomId, hierarchyRoom1], - [room2.roomId, hierarchyRoom2], - ]), - isSuggested: jest.fn(), - } as unknown as RoomHierarchy; - }); + stubClient(); + let client = MatrixClientPeg.get(); + + let dmRoomMap = { + getUserIdForRoomId: jest.fn(), + } as unknown as DMRoomMap; + jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); + + let root = mkStubRoom("room-id-1", "Room 1", client); + let room1 = mkStubRoom("room-id-2", "Room 2", client); + let room2 = mkStubRoom("room-id-3", "Room 3", client); + + let hierarchyRoot = { + room_id: root.roomId, + num_joined_members: 1, + children_state: [ + { + state_key: room1.roomId, + content: { order: "1" }, + }, + { + state_key: room2.roomId, + content: { order: "2" }, + }, + ], + } as IHierarchyRoom; + let hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom; + let hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom; + + let roomHierarchy = { + roomMap: new Map([ + [root.roomId, hierarchyRoot], + [room1.roomId, hierarchyRoom1], + [room2.roomId, hierarchyRoom2], + ]), + isSuggested: jest.fn(), + } as unknown as RoomHierarchy; it("renders", () => { const defaultProps = { From d3f378240ed15ca95b1b55520e554ab6e8fef8b9 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Thu, 2 Feb 2023 20:38:42 +0100 Subject: [PATCH 11/20] Appease the linter --- .../structures/SpaceHierarchy-test.tsx | 26 +++++++++---------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/test/components/structures/SpaceHierarchy-test.tsx b/test/components/structures/SpaceHierarchy-test.tsx index 99a11a92a7da..2c5cb0e85a11 100644 --- a/test/components/structures/SpaceHierarchy-test.tsx +++ b/test/components/structures/SpaceHierarchy-test.tsx @@ -82,10 +82,10 @@ describe("SpaceHierarchy", () => { describe("toLocalRoom", () => { stubClient(); - let client = MatrixClientPeg.get(); - let roomV1 = mkStubRoom("room-id-1", "Room V1", client); - let roomV2 = mkStubRoom("room-id-2", "Room V2", client); - let roomV3 = mkStubRoom("room-id-3", "Room V3", client); + const client = MatrixClientPeg.get(); + const roomV1 = mkStubRoom("room-id-1", "Room V1", client); + const roomV2 = mkStubRoom("room-id-2", "Room V2", client); + const roomV3 = mkStubRoom("room-id-3", "Room V3", client); jest.spyOn(client, "getRoomUpgradeHistory").mockReturnValue([roomV1, roomV2, roomV3]); it("grabs last room that is in hierarchy when latest version is in hierarchy", () => { @@ -132,18 +132,18 @@ describe("SpaceHierarchy", () => { describe("", () => { stubClient(); - let client = MatrixClientPeg.get(); + const client = MatrixClientPeg.get(); - let dmRoomMap = { + const dmRoomMap = { getUserIdForRoomId: jest.fn(), } as unknown as DMRoomMap; jest.spyOn(DMRoomMap, "shared").mockReturnValue(dmRoomMap); - let root = mkStubRoom("room-id-1", "Room 1", client); - let room1 = mkStubRoom("room-id-2", "Room 2", client); - let room2 = mkStubRoom("room-id-3", "Room 3", client); + const root = mkStubRoom("room-id-1", "Room 1", client); + const room1 = mkStubRoom("room-id-2", "Room 2", client); + const room2 = mkStubRoom("room-id-3", "Room 3", client); - let hierarchyRoot = { + const hierarchyRoot = { room_id: root.roomId, num_joined_members: 1, children_state: [ @@ -157,10 +157,10 @@ describe("SpaceHierarchy", () => { }, ], } as IHierarchyRoom; - let hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom; - let hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom; + const hierarchyRoom1 = { room_id: room1.roomId, num_joined_members: 2 } as IHierarchyRoom; + const hierarchyRoom2 = { room_id: root.roomId, num_joined_members: 3 } as IHierarchyRoom; - let roomHierarchy = { + const roomHierarchy = { roomMap: new Map([ [root.roomId, hierarchyRoot], [room1.roomId, hierarchyRoom1], From 544baa30ed60a5f8cc806004a8fba15453f5063a Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 3 Feb 2023 09:22:26 +1300 Subject: [PATCH 12/20] use Poll model with relations API in poll rendering (#9877) * wip * remove dupe * use poll model relations in all cases * update mpollbody tests to use poll instance * update poll fetching login in pinned messages card * add pinned polls to room polls state * add spinner while relations are still loading * handle no poll in end poll dialog * strict errors * strict fix * more strict fix --- res/css/views/messages/_MPollBody.pcss | 8 + .../context_menus/MessageContextMenu.tsx | 2 +- .../views/dialogs/EndPollDialog.tsx | 40 +- src/components/views/messages/MPollBody.tsx | 345 +++--------- .../views/right_panel/PinnedMessagesCard.tsx | 1 + .../views/rooms/PinnedEventTile.tsx | 43 -- .../views/messages/MPollBody-test.tsx | 519 +++++++----------- .../__snapshots__/MPollBody-test.tsx.snap | 4 +- .../right_panel/PinnedMessagesCard-test.tsx | 52 +- 9 files changed, 347 insertions(+), 667 deletions(-) diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index e691ff827e2e..e9ea2bc3dc1f 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -150,8 +150,16 @@ limitations under the License. } .mx_MPollBody_totalVotes { + display: flex; + flex-direction: inline; + justify-content: start; color: $secondary-content; font-size: $font-12px; + + .mx_Spinner { + flex: 0; + margin-left: $spacing-8; + } } } diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 3d38fc5a70f2..677565f7d819 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -195,7 +195,7 @@ export default class MessageContextMenu extends React.Component return ( M_POLL_START.matches(mxEvent.getType()) && this.state.canRedact && - !isPollEnded(mxEvent, MatrixClientPeg.get(), this.props.getRelationsForEvent) + !isPollEnded(mxEvent, MatrixClientPeg.get()) ); } diff --git a/src/components/views/dialogs/EndPollDialog.tsx b/src/components/views/dialogs/EndPollDialog.tsx index 946f209d31cc..463605553e22 100644 --- a/src/components/views/dialogs/EndPollDialog.tsx +++ b/src/components/views/dialogs/EndPollDialog.tsx @@ -35,26 +35,34 @@ interface IProps extends IDialogProps { } export default class EndPollDialog extends React.Component { - private onFinished = (endPoll: boolean): void => { - const topAnswer = findTopAnswer(this.props.event, this.props.matrixClient, this.props.getRelationsForEvent); + private onFinished = async (endPoll: boolean): Promise => { + if (endPoll) { + const room = this.props.matrixClient.getRoom(this.props.event.getRoomId()); + const poll = room?.polls.get(this.props.event.getId()!); - const message = - topAnswer === "" - ? _t("The poll has ended. No votes were cast.") - : _t("The poll has ended. Top answer: %(topAnswer)s", { topAnswer }); + if (!poll) { + throw new Error("No poll instance found in room."); + } - if (endPoll) { - const endEvent = PollEndEvent.from(this.props.event.getId(), message).serialize(); + try { + const responses = await poll.getResponses(); + const topAnswer = findTopAnswer(this.props.event, responses); + + const message = + topAnswer === "" + ? _t("The poll has ended. No votes were cast.") + : _t("The poll has ended. Top answer: %(topAnswer)s", { topAnswer }); + + const endEvent = PollEndEvent.from(this.props.event.getId()!, message).serialize(); - this.props.matrixClient - .sendEvent(this.props.event.getRoomId(), endEvent.type, endEvent.content) - .catch((e: any) => { - console.error("Failed to submit poll response event:", e); - Modal.createDialog(ErrorDialog, { - title: _t("Failed to end poll"), - description: _t("Sorry, the poll did not end. Please try again."), - }); + await this.props.matrixClient.sendEvent(this.props.event.getRoomId()!, endEvent.type, endEvent.content); + } catch (e) { + console.error("Failed to submit poll response event:", e); + Modal.createDialog(ErrorDialog, { + title: _t("Failed to end poll"), + description: _t("Sorry, the poll did not end. Please try again."), }); + } } this.props.onFinished(endPoll); }; diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index a9317957c6c9..f5bb9e811469 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -14,17 +14,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { ReactNode } from "react"; import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; -import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; -import { Relations, RelationsEvent } from "matrix-js-sdk/src/models/relations"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { Relations } from "matrix-js-sdk/src/models/relations"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { M_POLL_END, M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { M_POLL_KIND_DISCLOSED, M_POLL_RESPONSE, M_POLL_START } from "matrix-js-sdk/src/@types/polls"; import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations"; -import { NamespacedValue } from "matrix-events-sdk"; import { PollStartEvent, PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { PollResponseEvent } from "matrix-js-sdk/src/extensible_events_v1/PollResponseEvent"; +import { Poll, PollEvent } from "matrix-js-sdk/src/models/poll"; import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; @@ -36,11 +36,14 @@ import ErrorDialog from "../dialogs/ErrorDialog"; import { GetRelationsForEvent } from "../rooms/EventTile"; import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import Spinner from "../elements/Spinner"; interface IState { + poll?: Poll; + // poll instance has fetched at least one page of responses + pollInitialised: boolean; selected?: string | null | undefined; // Which option was clicked by the local user - voteRelations: RelatedRelations; // Voting (response) events - endRelations: RelatedRelations; // Poll end events + voteRelations?: Relations; // Voting (response) events } export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, eventId: string): RelatedRelations { @@ -59,15 +62,7 @@ export function createVoteRelations(getRelationsForEvent: GetRelationsForEvent, return new RelatedRelations(relationsList); } -export function findTopAnswer( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - getRelationsForEvent?: GetRelationsForEvent, -): string { - if (!getRelationsForEvent) { - return ""; - } - +export function findTopAnswer(pollEvent: MatrixEvent, voteRelations: Relations): string { const pollEventId = pollEvent.getId(); if (!pollEventId) { logger.warn( @@ -87,25 +82,7 @@ export function findTopAnswer( return poll.answers.find((a) => a.id === answerId)?.text ?? ""; }; - const voteRelations = createVoteRelations(getRelationsForEvent, pollEventId); - - const relationsList: Relations[] = []; - - const pollEndRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.name); - if (pollEndRelations) { - relationsList.push(pollEndRelations); - } - - const pollEndAltRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.altName); - if (pollEndAltRelations) { - relationsList.push(pollEndAltRelations); - } - - const endRelations = new RelatedRelations(relationsList); - - const userVotes: Map = collectUserVotes( - allVotes(pollEvent, matrixClient, voteRelations, endRelations), - ); + const userVotes: Map = collectUserVotes(allVotes(voteRelations)); const votes: Map = countVotes(userVotes, poll); const highestScore: number = Math.max(...votes.values()); @@ -122,62 +99,13 @@ export function findTopAnswer( return formatCommaSeparatedList(bestAnswerTexts, 3); } -export function isPollEnded( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - getRelationsForEvent?: GetRelationsForEvent, -): boolean { - if (!getRelationsForEvent) { - return false; - } - - const pollEventId = pollEvent.getId(); - if (!pollEventId) { - logger.warn( - "isPollEnded: Poll event must have event ID in order to determine whether it has ended " + - "- assuming poll has not ended", - ); - return false; - } - - const roomId = pollEvent.getRoomId(); - if (!roomId) { - logger.warn( - "isPollEnded: Poll event must have room ID in order to determine whether it has ended " + - "- assuming poll has not ended", - ); +export function isPollEnded(pollEvent: MatrixEvent, matrixClient: MatrixClient): boolean { + const room = matrixClient.getRoom(pollEvent.getRoomId()); + const poll = room?.polls.get(pollEvent.getId()!); + if (!poll || poll.isFetchingResponses) { return false; } - - const roomCurrentState = matrixClient.getRoom(roomId)?.currentState; - function userCanRedact(endEvent: MatrixEvent): boolean { - const endEventSender = endEvent.getSender(); - return ( - endEventSender && roomCurrentState && roomCurrentState.maySendRedactionForEvent(pollEvent, endEventSender) - ); - } - - const relationsList: Relations[] = []; - - const pollEndRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.name); - if (pollEndRelations) { - relationsList.push(pollEndRelations); - } - - const pollEndAltRelations = getRelationsForEvent(pollEventId, "m.reference", M_POLL_END.altName); - if (pollEndAltRelations) { - relationsList.push(pollEndAltRelations); - } - - const endRelations = new RelatedRelations(relationsList); - - if (!endRelations) { - return false; - } - - const authorisedRelations = endRelations.getRelations().filter(userCanRedact); - - return authorisedRelations.length > 0; + return poll.isEnded; } export function pollAlreadyHasVotes(mxEvent: MatrixEvent, getRelationsForEvent?: GetRelationsForEvent): boolean { @@ -215,75 +143,58 @@ export default class MPollBody extends React.Component { public static contextType = MatrixClientContext; public context!: React.ContextType; private seenEventIds: string[] = []; // Events we have already seen - private voteRelationsReceived = false; - private endRelationsReceived = false; public constructor(props: IBodyProps) { super(props); this.state = { selected: null, - voteRelations: this.fetchVoteRelations(), - endRelations: this.fetchEndRelations(), + pollInitialised: false, }; + } - this.addListeners(this.state.voteRelations, this.state.endRelations); - this.props.mxEvent.on(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); + public componentDidMount(): void { + const room = this.context.getRoom(this.props.mxEvent.getRoomId()); + const poll = room?.polls.get(this.props.mxEvent.getId()!); + if (poll) { + this.setPollInstance(poll); + } else { + room?.on(PollEvent.New, this.setPollInstance.bind(this)); + } } public componentWillUnmount(): void { - this.props.mxEvent.off(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - this.removeListeners(this.state.voteRelations, this.state.endRelations); + this.removeListeners(); } - private addListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations): void { - if (voteRelations) { - voteRelations.on(RelationsEvent.Add, this.onRelationsChange); - voteRelations.on(RelationsEvent.Remove, this.onRelationsChange); - voteRelations.on(RelationsEvent.Redaction, this.onRelationsChange); - } - if (endRelations) { - endRelations.on(RelationsEvent.Add, this.onRelationsChange); - endRelations.on(RelationsEvent.Remove, this.onRelationsChange); - endRelations.on(RelationsEvent.Redaction, this.onRelationsChange); + private async setPollInstance(poll: Poll): Promise { + if (poll.pollId !== this.props.mxEvent.getId()) { + return; } - } + this.setState({ poll }, () => { + this.addListeners(); + }); + const responses = await poll.getResponses(); + const voteRelations = responses; - private removeListeners(voteRelations?: RelatedRelations, endRelations?: RelatedRelations): void { - if (voteRelations) { - voteRelations.off(RelationsEvent.Add, this.onRelationsChange); - voteRelations.off(RelationsEvent.Remove, this.onRelationsChange); - voteRelations.off(RelationsEvent.Redaction, this.onRelationsChange); - } - if (endRelations) { - endRelations.off(RelationsEvent.Add, this.onRelationsChange); - endRelations.off(RelationsEvent.Remove, this.onRelationsChange); - endRelations.off(RelationsEvent.Redaction, this.onRelationsChange); - } + this.setState({ pollInitialised: true, voteRelations }); } - private onRelationsCreated = (relationType: string, eventType: string): void => { - if (relationType !== "m.reference") { - return; - } + private addListeners(): void { + this.state.poll?.on(PollEvent.Responses, this.onResponsesChange); + this.state.poll?.on(PollEvent.End, this.onRelationsChange); + } - if (M_POLL_RESPONSE.matches(eventType)) { - this.voteRelationsReceived = true; - const newVoteRelations = this.fetchVoteRelations(); - this.addListeners(newVoteRelations); - this.removeListeners(this.state.voteRelations); - this.setState({ voteRelations: newVoteRelations }); - } else if (M_POLL_END.matches(eventType)) { - this.endRelationsReceived = true; - const newEndRelations = this.fetchEndRelations(); - this.addListeners(newEndRelations); - this.removeListeners(this.state.endRelations); - this.setState({ endRelations: newEndRelations }); + private removeListeners(): void { + if (this.state.poll) { + this.state.poll.off(PollEvent.Responses, this.onResponsesChange); + this.state.poll.off(PollEvent.End, this.onRelationsChange); } + } - if (this.voteRelationsReceived && this.endRelationsReceived) { - this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onRelationsCreated); - } + private onResponsesChange = (responses: Relations): void => { + this.setState({ voteRelations: responses }); + this.onRelationsChange(); }; private onRelationsChange = (): void => { @@ -295,19 +206,19 @@ export default class MPollBody extends React.Component { }; private selectOption(answerId: string): void { - if (this.isEnded()) { + if (this.state.poll?.isEnded) { return; } const userVotes = this.collectUserVotes(); - const userId = this.context.getUserId(); + const userId = this.context.getSafeUserId(); const myVote = userVotes.get(userId)?.answers[0]; if (answerId === myVote) { return; } - const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()).serialize(); + const response = PollResponseEvent.from([answerId], this.props.mxEvent.getId()!).serialize(); - this.context.sendEvent(this.props.mxEvent.getRoomId(), response.type, response.content).catch((e: any) => { + this.context.sendEvent(this.props.mxEvent.getRoomId()!, response.type, response.content).catch((e: any) => { console.error("Failed to submit poll response event:", e); Modal.createDialog(ErrorDialog, { @@ -323,51 +234,14 @@ export default class MPollBody extends React.Component { this.selectOption(e.currentTarget.value); }; - private fetchVoteRelations(): RelatedRelations | null { - return this.fetchRelations(M_POLL_RESPONSE); - } - - private fetchEndRelations(): RelatedRelations | null { - return this.fetchRelations(M_POLL_END); - } - - private fetchRelations(eventType: NamespacedValue): RelatedRelations | null { - if (this.props.getRelationsForEvent) { - const relationsList: Relations[] = []; - - const eventId = this.props.mxEvent.getId(); - if (!eventId) { - return null; - } - - const relations = this.props.getRelationsForEvent(eventId, "m.reference", eventType.name); - if (relations) { - relationsList.push(relations); - } - - // If there is an alternatve experimental event type, also look for that - if (eventType.altName) { - const altRelations = this.props.getRelationsForEvent(eventId, "m.reference", eventType.altName); - if (altRelations) { - relationsList.push(altRelations); - } - } - - return new RelatedRelations(relationsList); - } else { - return null; - } - } - /** * @returns userId -> UserVote */ private collectUserVotes(): Map { - return collectUserVotes( - allVotes(this.props.mxEvent, this.context, this.state.voteRelations, this.state.endRelations), - this.context.getUserId(), - this.state.selected, - ); + if (!this.state.voteRelations) { + return new Map(); + } + return collectUserVotes(allVotes(this.state.voteRelations), this.context.getUserId(), this.state.selected); } /** @@ -379,10 +253,10 @@ export default class MPollBody extends React.Component { * have already seen. */ private unselectIfNewEventFromMe(): void { - const newEvents: MatrixEvent[] = this.state.voteRelations - .getRelations() - .filter(isPollResponse) - .filter((mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!)); + const relations = this.state.voteRelations?.getRelations() || []; + const newEvents: MatrixEvent[] = relations.filter( + (mxEvent: MatrixEvent) => !this.seenEventIds.includes(mxEvent.getId()!), + ); let newSelected = this.state.selected; if (newEvents.length > 0) { @@ -392,7 +266,7 @@ export default class MPollBody extends React.Component { } } } - const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()); + const newEventIds = newEvents.map((mxEvent: MatrixEvent) => mxEvent.getId()!); this.seenEventIds = this.seenEventIds.concat(newEventIds); this.setState({ selected: newSelected }); } @@ -405,30 +279,30 @@ export default class MPollBody extends React.Component { return sum; } - private isEnded(): boolean { - return isPollEnded(this.props.mxEvent, this.context, this.props.getRelationsForEvent); - } + public render(): ReactNode { + const { poll, pollInitialised } = this.state; + if (!poll?.pollEvent) { + return null; + } - public render(): JSX.Element { - const poll = this.props.mxEvent.unstableExtensibleEvent as PollStartEvent; - if (!poll?.isEquivalentTo(M_POLL_START)) return null; // invalid + const pollEvent = poll.pollEvent; - const ended = this.isEnded(); - const pollId = this.props.mxEvent.getId(); + const pollId = this.props.mxEvent.getId()!; + const isFetchingResponses = !pollInitialised || poll.isFetchingResponses; const userVotes = this.collectUserVotes(); - const votes = countVotes(userVotes, poll); + const votes = countVotes(userVotes, pollEvent); const totalVotes = this.totalVotes(votes); const winCount = Math.max(...votes.values()); const userId = this.context.getUserId(); const myVote = userVotes?.get(userId!)?.answers[0]; - const disclosed = M_POLL_KIND_DISCLOSED.matches(poll.kind.name); + const disclosed = M_POLL_KIND_DISCLOSED.matches(pollEvent.kind.name); // Disclosed: votes are hidden until I vote or the poll ends // Undisclosed: votes are hidden until poll ends - const showResults = ended || (disclosed && myVote !== undefined); + const showResults = poll.isEnded || (disclosed && myVote !== undefined); let totalText: string; - if (ended) { + if (poll.isEnded) { totalText = _t("Final result based on %(count)s votes", { count: totalVotes }); } else if (!disclosed) { totalText = _t("Results will be visible when the poll is ended"); @@ -449,11 +323,11 @@ export default class MPollBody extends React.Component { return (

    - {poll.question.text} + {pollEvent.question.text} {editedSpan}

    - {poll.answers.map((answer: PollAnswerSubevent) => { + {pollEvent.answers.map((answer: PollAnswerSubevent) => { let answerVotes = 0; let votesText = ""; @@ -462,11 +336,12 @@ export default class MPollBody extends React.Component { votesText = _t("%(count)s votes", { count: answerVotes }); } - const checked = (!ended && myVote === answer.id) || (ended && answerVotes === winCount); + const checked = + (!poll.isEnded && myVote === answer.id) || (poll.isEnded && answerVotes === winCount); const cls = classNames({ mx_MPollBody_option: true, mx_MPollBody_option_checked: checked, - mx_MPollBody_option_ended: ended, + mx_MPollBody_option_ended: poll.isEnded, }); const answerPercent = totalVotes === 0 ? 0 : Math.round((100.0 * answerVotes) / totalVotes); @@ -477,7 +352,7 @@ export default class MPollBody extends React.Component { className={cls} onClick={() => this.selectOption(answer.id)} > - {ended ? ( + {poll.isEnded ? ( ) : ( {
    {totalText} + {isFetchingResponses && }
    ); @@ -562,68 +438,17 @@ function userResponseFromPollResponseEvent(event: MatrixEvent): UserVote { throw new Error("Failed to parse Poll Response Event to determine user response"); } - return new UserVote(event.getTs(), event.getSender(), response.answerIds); + return new UserVote(event.getTs(), event.getSender()!, response.answerIds); } -export function allVotes( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - voteRelations: RelatedRelations, - endRelations: RelatedRelations, -): Array { - const endTs = pollEndTs(pollEvent, matrixClient, endRelations); - - function isOnOrBeforeEnd(responseEvent: MatrixEvent): boolean { - // From MSC3381: - // "Votes sent on or before the end event's timestamp are valid votes" - return endTs === null || responseEvent.getTs() <= endTs; - } - +export function allVotes(voteRelations: Relations): Array { if (voteRelations) { - return voteRelations - .getRelations() - .filter(isPollResponse) - .filter(isOnOrBeforeEnd) - .map(userResponseFromPollResponseEvent); + return voteRelations.getRelations().map(userResponseFromPollResponseEvent); } else { return []; } } -/** - * Returns the earliest timestamp from the supplied list of end_poll events - * or null if there are no authorised events. - */ -export function pollEndTs( - pollEvent: MatrixEvent, - matrixClient: MatrixClient, - endRelations: RelatedRelations, -): number | null { - if (!endRelations) { - return null; - } - - const roomCurrentState = matrixClient.getRoom(pollEvent.getRoomId()).currentState; - function userCanRedact(endEvent: MatrixEvent): boolean { - return roomCurrentState.maySendRedactionForEvent(pollEvent, endEvent.getSender()); - } - - const tss: number[] = endRelations - .getRelations() - .filter(userCanRedact) - .map((evt: MatrixEvent) => evt.getTs()); - - if (tss.length === 0) { - return null; - } else { - return Math.min(...tss); - } -} - -function isPollResponse(responseEvent: MatrixEvent): boolean { - return responseEvent.unstableExtensibleEvent?.isEquivalentTo(M_POLL_RESPONSE); -} - /** * Figure out the correct vote for each user. * @param userResponses current vote responses in the poll @@ -662,7 +487,7 @@ function countVotes(userVotes: Map, pollStart: PollStartEvent) if (!tempResponse.spoiled) { for (const answerId of tempResponse.answerIds) { if (collected.has(answerId)) { - collected.set(answerId, collected.get(answerId) + 1); + collected.set(answerId, collected.get(answerId)! + 1); } else { collected.set(answerId, 1); } diff --git a/src/components/views/right_panel/PinnedMessagesCard.tsx b/src/components/views/right_panel/PinnedMessagesCard.tsx index e57636c96fda..f393ddd9a7ef 100644 --- a/src/components/views/right_panel/PinnedMessagesCard.tsx +++ b/src/components/views/right_panel/PinnedMessagesCard.tsx @@ -133,6 +133,7 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator if (event.isEncrypted()) { await cli.decryptEventIfNeeded(event); // TODO await? } + await room.processPollEvents([event]); if (event && PinningUtils.isPinnable(event)) { // Inject sender information diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx index 26b7f63c256c..307ad27f4ecf 100644 --- a/src/components/views/rooms/PinnedEventTile.tsx +++ b/src/components/views/rooms/PinnedEventTile.tsx @@ -19,8 +19,6 @@ import React from "react"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; import { EventType, RelationType } from "matrix-js-sdk/src/@types/event"; -import { logger } from "matrix-js-sdk/src/logger"; -import { M_POLL_START, M_POLL_RESPONSE, M_POLL_END } from "matrix-js-sdk/src/@types/polls"; import dis from "../../../dispatcher/dispatcher"; import { Action } from "../../../dispatcher/actions"; @@ -69,47 +67,6 @@ export default class PinnedEventTile extends React.Component { } }; - public async componentDidMount(): Promise { - // Fetch poll responses - if (M_POLL_START.matches(this.props.event.getType())) { - const eventId = this.props.event.getId(); - const roomId = this.props.event.getRoomId(); - const room = this.context.getRoom(roomId); - - try { - await Promise.all( - [M_POLL_RESPONSE.name, M_POLL_RESPONSE.altName, M_POLL_END.name, M_POLL_END.altName].map( - async (eventType): Promise => { - const relations = new Relations(RelationType.Reference, eventType, room); - relations.setTargetEvent(this.props.event); - - if (!this.relations.has(RelationType.Reference)) { - this.relations.set(RelationType.Reference, new Map()); - } - this.relations.get(RelationType.Reference).set(eventType, relations); - - let nextBatch: string | undefined; - do { - const page = await this.context.relations( - roomId, - eventId, - RelationType.Reference, - eventType, - { from: nextBatch }, - ); - nextBatch = page.nextBatch; - page.events.forEach((event) => relations.addEvent(event)); - } while (nextBatch); - }, - ), - ); - } catch (err) { - logger.error(`Error fetching responses to pinned poll ${eventId} in room ${roomId}`); - logger.error(err); - } - } - } - public render(): JSX.Element { const sender = this.props.event.getSender(); diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index a6f9b5e11c61..574a552a0ea7 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -16,9 +16,8 @@ limitations under the License. import React from "react"; import { fireEvent, render, RenderResult } from "@testing-library/react"; -import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { Relations } from "matrix-js-sdk/src/models/relations"; -import { RelatedRelations } from "matrix-js-sdk/src/models/related-relations"; import { M_POLL_END, M_POLL_KIND_DISCLOSED, @@ -29,126 +28,60 @@ import { PollAnswer, } from "matrix-js-sdk/src/@types/polls"; import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; -import { MockedObject } from "jest-mock"; -import { - UserVote, - allVotes, - findTopAnswer, - pollEndTs, - isPollEnded, -} from "../../../../src/components/views/messages/MPollBody"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import { allVotes, findTopAnswer, isPollEnded } from "../../../../src/components/views/messages/MPollBody"; import { IBodyProps } from "../../../../src/components/views/messages/IBodyProps"; -import { getMockClientWithEventEmitter } from "../../../test-utils"; +import { flushPromises, getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import MPollBody from "../../../../src/components/views/messages/MPollBody"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { MediaEventHelper } from "../../../../src/utils/MediaEventHelper"; const CHECKED = "mx_MPollBody_option_checked"; +const userId = "@me:example.com"; const mockClient = getMockClientWithEventEmitter({ - getUserId: jest.fn().mockReturnValue("@me:example.com"), + ...mockClientMethodsUser(userId), sendEvent: jest.fn().mockReturnValue(Promise.resolve({ event_id: "fake_send_id" })), getRoom: jest.fn(), + decryptEventIfNeeded: jest.fn().mockResolvedValue(true), + relations: jest.fn(), }); -setRedactionAllowedForMeOnly(mockClient); - describe("MPollBody", () => { beforeEach(() => { mockClient.sendEvent.mockClear(); - }); - - it("finds no votes if there are none", () => { - expect( - allVotes( - { getRoomId: () => "$room" } as MatrixEvent, - MatrixClientPeg.get(), - new RelatedRelations([newVoteRelations([])]), - new RelatedRelations([newEndRelations([])]), - ), - ).toEqual([]); - }); - it("can find all the valid responses to a poll", () => { - const ev1 = responseEvent(); - const ev2 = responseEvent(); - const badEvent = badResponseEvent(); - - const voteRelations = new RelatedRelations([newVoteRelations([ev1, badEvent, ev2])]); - expect( - allVotes( - { getRoomId: () => "$room" } as MatrixEvent, - MatrixClientPeg.get(), - voteRelations, - new RelatedRelations([newEndRelations([])]), - ), - ).toEqual([ - new UserVote(ev1.getTs(), ev1.getSender()!, ev1.getContent()[M_POLL_RESPONSE.name].answers), - new UserVote( - badEvent.getTs(), - badEvent.getSender()!, - [], // should be spoiled - ), - new UserVote(ev2.getTs(), ev2.getSender()!, ev2.getContent()[M_POLL_RESPONSE.name].answers), - ]); + mockClient.getRoom.mockReturnValue(null); + mockClient.relations.mockResolvedValue({ events: [] }); }); - it("finds the first end poll event", () => { - const endRelations = new RelatedRelations([ - newEndRelations([ - endEvent("@me:example.com", 25), - endEvent("@me:example.com", 12), - endEvent("@me:example.com", 45), - endEvent("@me:example.com", 13), - ]), - ]); - - setRedactionAllowedForMeOnly(mockClient); - - expect(pollEndTs({ getRoomId: () => "$room" } as MatrixEvent, mockClient, endRelations)).toBe(12); + it("finds no votes if there are none", () => { + expect(allVotes(newVoteRelations([]))).toEqual([]); }); - it("ignores unauthorised end poll event when finding end ts", () => { - const endRelations = new RelatedRelations([ - newEndRelations([ - endEvent("@me:example.com", 25), - endEvent("@unauthorised:example.com", 12), - endEvent("@me:example.com", 45), - endEvent("@me:example.com", 13), - ]), - ]); - - setRedactionAllowedForMeOnly(mockClient); - - expect(pollEndTs({ getRoomId: () => "$room" } as MatrixEvent, mockClient, endRelations)).toBe(13); - }); + it("renders a loader while responses are still loading", async () => { + const votes = [ + responseEvent("@me:example.com", "pizza"), + responseEvent("@bellc:example.com", "pizza"), + responseEvent("@catrd:example.com", "poutine"), + responseEvent("@dune2:example.com", "wings"), + ]; + // render without waiting for responses + const renderResult = await newMPollBody(votes, [], undefined, undefined, false); - it("counts only votes before the end poll event", () => { - const voteRelations = new RelatedRelations([ - newVoteRelations([ - responseEvent("sf@matrix.org", "wings", 13), - responseEvent("jr@matrix.org", "poutine", 40), - responseEvent("ak@matrix.org", "poutine", 37), - responseEvent("id@matrix.org", "wings", 13), - responseEvent("ps@matrix.org", "wings", 19), - ]), - ]); - const endRelations = new RelatedRelations([newEndRelations([endEvent("@me:example.com", 25)])]); - expect( - allVotes({ getRoomId: () => "$room" } as MatrixEvent, MatrixClientPeg.get(), voteRelations, endRelations), - ).toEqual([ - new UserVote(13, "sf@matrix.org", ["wings"]), - new UserVote(13, "id@matrix.org", ["wings"]), - new UserVote(19, "ps@matrix.org", ["wings"]), - ]); + // votes still displayed + expect(votesCount(renderResult, "pizza")).toBe("2 votes"); + expect(votesCount(renderResult, "poutine")).toBe("1 vote"); + expect(votesCount(renderResult, "italian")).toBe("0 votes"); + expect(votesCount(renderResult, "wings")).toBe("1 vote"); + // spinner rendered + expect(renderResult.getByTestId("totalVotes").innerHTML).toMatchSnapshot(); }); - it("renders no votes if none were made", () => { + it("renders no votes if none were made", async () => { const votes: MatrixEvent[] = []; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe(""); expect(votesCount(renderResult, "poutine")).toBe(""); expect(votesCount(renderResult, "italian")).toBe(""); @@ -157,14 +90,14 @@ describe("MPollBody", () => { expect(renderResult.getByText("What should we order for the party?")).toBeTruthy(); }); - it("finds votes from multiple people", () => { + it("finds votes from multiple people", async () => { const votes = [ responseEvent("@me:example.com", "pizza"), responseEvent("@bellc:example.com", "pizza"), responseEvent("@catrd:example.com", "poutine"), responseEvent("@dune2:example.com", "wings"), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe("2 votes"); expect(votesCount(renderResult, "poutine")).toBe("1 vote"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); @@ -172,7 +105,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 4 votes"); }); - it("ignores end poll events from unauthorised users", () => { + it("ignores end poll events from unauthorised users", async () => { const votes = [ responseEvent("@me:example.com", "pizza"), responseEvent("@bellc:example.com", "pizza"), @@ -180,7 +113,7 @@ describe("MPollBody", () => { responseEvent("@dune2:example.com", "wings"), ]; const ends = [endEvent("@notallowed:example.com", 12)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); // Even though an end event was sent, we render the poll as unfinished // because this person is not allowed to send these events @@ -191,14 +124,14 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 4 votes"); }); - it("hides scores if I have not voted", () => { + it("hides scores if I have not voted", async () => { const votes = [ responseEvent("@alice:example.com", "pizza"), responseEvent("@bellc:example.com", "pizza"), responseEvent("@catrd:example.com", "poutine"), responseEvent("@dune2:example.com", "wings"), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe(""); expect(votesCount(renderResult, "poutine")).toBe(""); expect(votesCount(renderResult, "italian")).toBe(""); @@ -206,9 +139,9 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("4 votes cast. Vote to see the results"); }); - it("hides a single vote if I have not voted", () => { + it("hides a single vote if I have not voted", async () => { const votes = [responseEvent("@alice:example.com", "pizza")]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe(""); expect(votesCount(renderResult, "poutine")).toBe(""); expect(votesCount(renderResult, "italian")).toBe(""); @@ -216,7 +149,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("1 vote cast. Vote to see the results"); }); - it("takes someone's most recent vote if they voted several times", () => { + it("takes someone's most recent vote if they voted several times", async () => { const votes = [ responseEvent("@me:example.com", "pizza", 12), responseEvent("@me:example.com", "wings", 20), // latest me @@ -224,7 +157,7 @@ describe("MPollBody", () => { responseEvent("@qbert:example.com", "poutine", 16), // latest qbert responseEvent("@qbert:example.com", "wings", 15), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe("0 votes"); expect(votesCount(renderResult, "poutine")).toBe("1 vote"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); @@ -232,14 +165,14 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); }); - it("uses my local vote", () => { + it("uses my local vote", async () => { // Given I haven't voted const votes = [ responseEvent("@nf:example.com", "pizza", 15), responseEvent("@fg:example.com", "pizza", 15), responseEvent("@hi:example.com", "pizza", 15), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); // When I vote for Italian clickOption(renderResult, "italian"); @@ -253,7 +186,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 4 votes"); }); - it("overrides my other votes with my local vote", () => { + it("overrides my other votes with my local vote", async () => { // Given two of us have voted for Italian const votes = [ responseEvent("@me:example.com", "pizza", 12), @@ -261,7 +194,7 @@ describe("MPollBody", () => { responseEvent("@me:example.com", "italian", 14), responseEvent("@nf:example.com", "italian", 15), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); // When I click Wings clickOption(renderResult, "wings"); @@ -279,7 +212,7 @@ describe("MPollBody", () => { expect(voteButton(renderResult, "italian").className.includes(CHECKED)).toBe(false); }); - it("cancels my local vote if another comes in", () => { + it("cancels my local vote if another comes in", async () => { // Given I voted locally const votes = [responseEvent("@me:example.com", "pizza", 100)]; const mxEvent = new MatrixEvent({ @@ -288,14 +221,15 @@ describe("MPollBody", () => { room_id: "#myroom:example.com", content: newPollStart(undefined, undefined, true), }); - const props = getMPollBodyPropsFromEvent(mxEvent, votes); + const props = getMPollBodyPropsFromEvent(mxEvent); + const room = await setupRoomWithPollEvents(mxEvent, votes); const renderResult = renderMPollBodyWithWrapper(props); - const voteRelations = props!.getRelationsForEvent!("$mypoll", "m.reference", M_POLL_RESPONSE.name); - expect(voteRelations).toBeDefined(); + // wait for /relations promise to resolve + await flushPromises(); clickOption(renderResult, "pizza"); // When a new vote from me comes in - voteRelations!.addEvent(responseEvent("@me:example.com", "wings", 101)); + await room.processPollEvents([responseEvent("@me:example.com", "wings", 101)]); // Then the new vote is counted, not the old one expect(votesCount(renderResult, "pizza")).toBe("0 votes"); @@ -306,7 +240,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 1 vote"); }); - it("doesn't cancel my local vote if someone else votes", () => { + it("doesn't cancel my local vote if someone else votes", async () => { // Given I voted locally const votes = [responseEvent("@me:example.com", "pizza")]; const mxEvent = new MatrixEvent({ @@ -315,15 +249,16 @@ describe("MPollBody", () => { room_id: "#myroom:example.com", content: newPollStart(undefined, undefined, true), }); - const props = getMPollBodyPropsFromEvent(mxEvent, votes); + const props = getMPollBodyPropsFromEvent(mxEvent); + const room = await setupRoomWithPollEvents(mxEvent, votes); const renderResult = renderMPollBodyWithWrapper(props); + // wait for /relations promise to resolve + await flushPromises(); - const voteRelations = props!.getRelationsForEvent!("$mypoll", "m.reference", M_POLL_RESPONSE.name); - expect(voteRelations).toBeDefined(); clickOption(renderResult, "pizza"); // When a new vote from someone else comes in - voteRelations!.addEvent(responseEvent("@xx:example.com", "wings", 101)); + await room.processPollEvents([responseEvent("@xx:example.com", "wings", 101)]); // Then my vote is still for pizza // NOTE: the new event does not affect the counts for other people - @@ -341,10 +276,10 @@ describe("MPollBody", () => { expect(voteButton(renderResult, "wings").className.includes(CHECKED)).toBe(false); }); - it("highlights my vote even if I did it on another device", () => { + it("highlights my vote even if I did it on another device", async () => { // Given I voted italian const votes = [responseEvent("@me:example.com", "italian"), responseEvent("@nf:example.com", "wings")]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); // But I didn't click anything locally @@ -353,10 +288,10 @@ describe("MPollBody", () => { expect(voteButton(renderResult, "wings").className.includes(CHECKED)).toBe(false); }); - it("ignores extra answers", () => { + it("ignores extra answers", async () => { // When cb votes for 2 things, we consider the first only const votes = [responseEvent("@cb:example.com", ["pizza", "wings"]), responseEvent("@me:example.com", "wings")]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe("1 vote"); expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); @@ -364,13 +299,13 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); }); - it("allows un-voting by passing an empty vote", () => { + it("allows un-voting by passing an empty vote", async () => { const votes = [ responseEvent("@nc:example.com", "pizza", 12), responseEvent("@nc:example.com", [], 13), responseEvent("@me:example.com", "italian"), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe("0 votes"); expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("1 vote"); @@ -378,14 +313,14 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 1 vote"); }); - it("allows re-voting after un-voting", () => { + it("allows re-voting after un-voting", async () => { const votes = [ responseEvent("@op:example.com", "pizza", 12), responseEvent("@op:example.com", [], 13), responseEvent("@op:example.com", "italian", 14), responseEvent("@me:example.com", "italian"), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe("0 votes"); expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("2 votes"); @@ -393,7 +328,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 2 votes"); }); - it("treats any invalid answer as a spoiled ballot", () => { + it("treats any invalid answer as a spoiled ballot", async () => { // Note that uy's second vote has a valid first answer, but // the ballot is still spoiled because the second answer is // invalid, even though we would ignore it if we continued. @@ -403,7 +338,7 @@ describe("MPollBody", () => { responseEvent("@uy:example.com", "italian", 14), responseEvent("@uy:example.com", "doesntexist", 15), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(votesCount(renderResult, "pizza")).toBe("0 votes"); expect(votesCount(renderResult, "poutine")).toBe("0 votes"); expect(votesCount(renderResult, "italian")).toBe("0 votes"); @@ -411,7 +346,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 0 votes"); }); - it("allows re-voting after a spoiled ballot", () => { + it("allows re-voting after a spoiled ballot", async () => { const votes = [ responseEvent("@me:example.com", "pizza", 12), responseEvent("@me:example.com", ["pizza", "doesntexist"], 13), @@ -419,7 +354,7 @@ describe("MPollBody", () => { responseEvent("@uy:example.com", "doesntexist", 15), responseEvent("@uy:example.com", "poutine", 16), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); expect(renderResult.container.querySelectorAll('input[type="radio"]')).toHaveLength(4); expect(votesCount(renderResult, "pizza")).toBe("0 votes"); expect(votesCount(renderResult, "poutine")).toBe("1 vote"); @@ -428,25 +363,25 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Based on 1 vote"); }); - it("renders nothing if poll has no answers", () => { + it("renders nothing if poll has no answers", async () => { const answers: PollAnswer[] = []; const votes: MatrixEvent[] = []; const ends: MatrixEvent[] = []; - const { container } = newMPollBody(votes, ends, answers); + const { container } = await newMPollBody(votes, ends, answers); expect(container.childElementCount).toEqual(0); }); - it("renders the first 20 answers if 21 were given", () => { + it("renders the first 20 answers if 21 were given", async () => { const answers = Array.from(Array(21).keys()).map((i) => { return { id: `id${i}`, [M_TEXT.name]: `Name ${i}` }; }); const votes: MatrixEvent[] = []; const ends: MatrixEvent[] = []; - const { container } = newMPollBody(votes, ends, answers); + const { container } = await newMPollBody(votes, ends, answers); expect(container.querySelectorAll(".mx_MPollBody_option").length).toBe(20); }); - it("hides scores if I voted but the poll is undisclosed", () => { + it("hides scores if I voted but the poll is undisclosed", async () => { const votes = [ responseEvent("@me:example.com", "pizza"), responseEvent("@alice:example.com", "pizza"), @@ -454,7 +389,7 @@ describe("MPollBody", () => { responseEvent("@catrd:example.com", "poutine"), responseEvent("@dune2:example.com", "wings"), ]; - const renderResult = newMPollBody(votes, [], undefined, false); + const renderResult = await newMPollBody(votes, [], undefined, false); expect(votesCount(renderResult, "pizza")).toBe(""); expect(votesCount(renderResult, "poutine")).toBe(""); expect(votesCount(renderResult, "italian")).toBe(""); @@ -462,7 +397,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Results will be visible when the poll is ended"); }); - it("highlights my vote if the poll is undisclosed", () => { + it("highlights my vote if the poll is undisclosed", async () => { const votes = [ responseEvent("@me:example.com", "pizza"), responseEvent("@alice:example.com", "poutine"), @@ -470,7 +405,7 @@ describe("MPollBody", () => { responseEvent("@catrd:example.com", "poutine"), responseEvent("@dune2:example.com", "wings"), ]; - const { container } = newMPollBody(votes, [], undefined, false); + const { container } = await newMPollBody(votes, [], undefined, false); // My vote is marked expect(container.querySelector('input[value="pizza"]')!).toBeChecked(); @@ -479,7 +414,7 @@ describe("MPollBody", () => { expect(container.querySelector('input[value="poutine"]')!).not.toBeChecked(); }); - it("shows scores if the poll is undisclosed but ended", () => { + it("shows scores if the poll is undisclosed but ended", async () => { const votes = [ responseEvent("@me:example.com", "pizza"), responseEvent("@alice:example.com", "pizza"), @@ -488,7 +423,7 @@ describe("MPollBody", () => { responseEvent("@dune2:example.com", "wings"), ]; const ends = [endEvent("@me:example.com", 12)]; - const renderResult = newMPollBody(votes, ends, undefined, false); + const renderResult = await newMPollBody(votes, ends, undefined, false); expect(endedVotesCount(renderResult, "pizza")).toBe("3 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); @@ -496,16 +431,16 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); - it("sends a vote event when I choose an option", () => { + it("sends a vote event when I choose an option", async () => { const votes: MatrixEvent[] = []; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); clickOption(renderResult, "wings"); expect(mockClient.sendEvent).toHaveBeenCalledWith(...expectedResponseEventCall("wings")); }); - it("sends only one vote event when I click several times", () => { + it("sends only one vote event when I click several times", async () => { const votes: MatrixEvent[] = []; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); clickOption(renderResult, "wings"); clickOption(renderResult, "wings"); clickOption(renderResult, "wings"); @@ -513,9 +448,9 @@ describe("MPollBody", () => { expect(mockClient.sendEvent).toHaveBeenCalledWith(...expectedResponseEventCall("wings")); }); - it("sends no vote event when I click what I already chose", () => { + it("sends no vote event when I click what I already chose", async () => { const votes = [responseEvent("@me:example.com", "wings")]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); clickOption(renderResult, "wings"); clickOption(renderResult, "wings"); clickOption(renderResult, "wings"); @@ -523,9 +458,9 @@ describe("MPollBody", () => { expect(mockClient.sendEvent).not.toHaveBeenCalled(); }); - it("sends several events when I click different options", () => { + it("sends several events when I click different options", async () => { const votes: MatrixEvent[] = []; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); clickOption(renderResult, "wings"); clickOption(renderResult, "italian"); clickOption(renderResult, "poutine"); @@ -535,17 +470,17 @@ describe("MPollBody", () => { expect(mockClient.sendEvent).toHaveBeenCalledWith(...expectedResponseEventCall("poutine")); }); - it("sends no events when I click in an ended poll", () => { + it("sends no events when I click in an ended poll", async () => { const ends = [endEvent("@me:example.com", 25)]; const votes = [responseEvent("@uy:example.com", "wings", 15), responseEvent("@uy:example.com", "poutine", 15)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); clickOption(renderResult, "wings"); clickOption(renderResult, "italian"); clickOption(renderResult, "poutine"); expect(mockClient.sendEvent).not.toHaveBeenCalled(); }); - it("finds the top answer among several votes", () => { + it("finds the top answer among several votes", async () => { // 2 votes for poutine, 1 for pizza. "me" made an invalid vote. const votes = [ responseEvent("@me:example.com", "pizza", 12), @@ -557,46 +492,30 @@ describe("MPollBody", () => { responseEvent("@fa:example.com", "poutine", 18), ]; - expect(runFindTopAnswer(votes, [])).toEqual("Poutine"); - }); - - it("finds all top answers when there is a draw", () => { - const votes = [ - responseEvent("@uy:example.com", "italian", 14), - responseEvent("@ab:example.com", "pizza", 17), - responseEvent("@fa:example.com", "poutine", 18), - ]; - expect(runFindTopAnswer(votes, [])).toEqual("Italian, Pizza and Poutine"); + expect(runFindTopAnswer(votes)).toEqual("Poutine"); }); - it("finds all top answers ignoring late votes", () => { + it("finds all top answers when there is a draw", async () => { const votes = [ responseEvent("@uy:example.com", "italian", 14), responseEvent("@ab:example.com", "pizza", 17), - responseEvent("@io:example.com", "poutine", 30), // Late responseEvent("@fa:example.com", "poutine", 18), - responseEvent("@of:example.com", "poutine", 31), // Late ]; - const ends = [endEvent("@me:example.com", 25)]; - expect(runFindTopAnswer(votes, ends)).toEqual("Italian, Pizza and Poutine"); + expect(runFindTopAnswer(votes)).toEqual("Italian, Pizza and Poutine"); }); - it("is silent about the top answer if there are no votes", () => { - expect(runFindTopAnswer([], [])).toEqual(""); + it("is silent about the top answer if there are no votes", async () => { + expect(runFindTopAnswer([])).toEqual(""); }); - it("is silent about the top answer if there are no votes when ended", () => { - expect(runFindTopAnswer([], [endEvent("@me:example.com", 13)])).toEqual(""); - }); - - it("shows non-radio buttons if the poll is ended", () => { + it("shows non-radio buttons if the poll is ended", async () => { const events = [endEvent()]; - const { container } = newMPollBody([], events); + const { container } = await newMPollBody([], events); expect(container.querySelector(".mx_StyledRadioButton")).not.toBeInTheDocument(); expect(container.querySelector('input[type="radio"]')).not.toBeInTheDocument(); }); - it("counts votes as normal if the poll is ended", () => { + it("counts votes as normal if the poll is ended", async () => { const votes = [ responseEvent("@me:example.com", "pizza", 12), responseEvent("@me:example.com", "wings", 20), // latest me @@ -605,7 +524,7 @@ describe("MPollBody", () => { responseEvent("@qbert:example.com", "wings", 15), ]; const ends = [endEvent("@me:example.com", 25)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("0 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); @@ -613,10 +532,10 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 2 votes"); }); - it("counts a single vote as normal if the poll is ended", () => { + it("counts a single vote as normal if the poll is ended", async () => { const votes = [responseEvent("@qbert:example.com", "poutine", 16)]; const ends = [endEvent("@me:example.com", 25)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("0 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); @@ -624,7 +543,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 1 vote"); }); - it("shows ended vote counts of different numbers", () => { + it("shows ended vote counts of different numbers", async () => { const votes = [ responseEvent("@me:example.com", "wings", 20), responseEvent("@qb:example.com", "wings", 14), @@ -633,7 +552,7 @@ describe("MPollBody", () => { responseEvent("@hi:example.com", "pizza", 15), ]; const ends = [endEvent("@me:example.com", 25)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); expect(renderResult.container.querySelectorAll(".mx_StyledRadioButton")).toHaveLength(0); expect(renderResult.container.querySelectorAll('input[type="radio"]')).toHaveLength(0); @@ -644,7 +563,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); - it("ignores votes that arrived after poll ended", () => { + it("ignores votes that arrived after poll ended", async () => { const votes = [ responseEvent("@sd:example.com", "wings", 30), // Late responseEvent("@ff:example.com", "wings", 20), @@ -655,7 +574,7 @@ describe("MPollBody", () => { responseEvent("@ld:example.com", "pizza", 15), ]; const ends = [endEvent("@me:example.com", 25)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); @@ -664,7 +583,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); - it("counts votes that arrived after an unauthorised poll end event", () => { + it("counts votes that arrived after an unauthorised poll end event", async () => { const votes = [ responseEvent("@sd:example.com", "wings", 30), // Late responseEvent("@ff:example.com", "wings", 20), @@ -678,7 +597,7 @@ describe("MPollBody", () => { endEvent("@unauthorised:example.com", 5), // Should be ignored endEvent("@me:example.com", 25), ]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); @@ -687,7 +606,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); - it("ignores votes that arrived after the first end poll event", () => { + it("ignores votes that arrived after the first end poll event", async () => { // From MSC3381: // "Votes sent on or before the end event's timestamp are valid votes" @@ -705,7 +624,7 @@ describe("MPollBody", () => { endEvent("@me:example.com", 25), endEvent("@me:example.com", 75), ]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); @@ -714,7 +633,7 @@ describe("MPollBody", () => { expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); - it("highlights the winning vote in an ended poll", () => { + it("highlights the winning vote in an ended poll", async () => { // Given I voted for pizza but the winner is wings const votes = [ responseEvent("@me:example.com", "pizza", 20), @@ -722,7 +641,7 @@ describe("MPollBody", () => { responseEvent("@xy:example.com", "wings", 15), ]; const ends = [endEvent("@me:example.com", 25)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); // Then the winner is highlighted expect(endedVoteChecked(renderResult, "wings")).toBe(true); @@ -733,14 +652,14 @@ describe("MPollBody", () => { expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_MPollBody_endedOptionWinner")).toBe(false); }); - it("highlights multiple winning votes", () => { + it("highlights multiple winning votes", async () => { const votes = [ responseEvent("@me:example.com", "pizza", 20), responseEvent("@xy:example.com", "wings", 15), responseEvent("@fg:example.com", "poutine", 15), ]; const ends = [endEvent("@me:example.com", 25)]; - const renderResult = newMPollBody(votes, ends); + const renderResult = await newMPollBody(votes, ends); expect(endedVoteChecked(renderResult, "pizza")).toBe(true); expect(endedVoteChecked(renderResult, "wings")).toBe(true); @@ -749,53 +668,41 @@ describe("MPollBody", () => { expect(renderResult.container.getElementsByClassName("mx_MPollBody_option_checked")).toHaveLength(3); }); - it("highlights nothing if poll has no votes", () => { + it("highlights nothing if poll has no votes", async () => { const ends = [endEvent("@me:example.com", 25)]; - const renderResult = newMPollBody([], ends); + const renderResult = await newMPollBody([], ends); expect(renderResult.container.getElementsByClassName("mx_MPollBody_option_checked")).toHaveLength(0); }); - it("says poll is not ended if there is no end event", () => { + it("says poll is not ended if there is no end event", async () => { const ends: MatrixEvent[] = []; - expect(runIsPollEnded(ends)).toBe(false); + const result = await runIsPollEnded(ends); + expect(result).toBe(false); }); - it("says poll is ended if there is an end event", () => { + it("says poll is ended if there is an end event", async () => { const ends = [endEvent("@me:example.com", 25)]; - expect(runIsPollEnded(ends)).toBe(true); - }); - - it("says poll is not ended if endRelations is undefined", () => { - const pollEvent = new MatrixEvent(); - setRedactionAllowedForMeOnly(mockClient); - expect(isPollEnded(pollEvent, mockClient, undefined)).toBe(false); + const result = await runIsPollEnded(ends); + expect(result).toBe(true); }); - it("says poll is not ended if asking for relations returns undefined", () => { + it("says poll is not ended if poll is fetching responses", async () => { const pollEvent = new MatrixEvent({ + type: M_POLL_START.name, event_id: "$mypoll", room_id: "#myroom:example.com", content: newPollStart([]), }); - mockClient.getRoom.mockImplementation((_roomId) => { - return { - currentState: { - maySendRedactionForEvent: (_evt: MatrixEvent, userId: string) => { - return userId === "@me:example.com"; - }, - }, - } as unknown as Room; - }); - const getRelationsForEvent = (eventId: string, relationType: string, eventType: string) => { - expect(eventId).toBe("$mypoll"); - expect(relationType).toBe("m.reference"); - expect(M_POLL_END.matches(eventType)).toBe(true); - return undefined; - }; - expect(isPollEnded(pollEvent, MatrixClientPeg.get(), getRelationsForEvent)).toBe(false); + const ends = [endEvent("@me:example.com", 25)]; + + await setupRoomWithPollEvents(pollEvent, [], ends); + const poll = mockClient.getRoom(pollEvent.getRoomId()!)!.polls.get(pollEvent.getId()!)!; + // start fetching, dont await + poll.getResponses(); + expect(isPollEnded(pollEvent, mockClient)).toBe(false); }); - it("Displays edited content and new answer IDs if the poll has been edited", () => { + it("Displays edited content and new answer IDs if the poll has been edited", async () => { const pollEvent = new MatrixEvent({ type: M_POLL_START.name, event_id: "$mypoll", @@ -824,7 +731,7 @@ describe("MPollBody", () => { }, }); pollEvent.makeReplaced(replacingEvent); - const { getByTestId, container } = newMPollBodyFromEvent(pollEvent, []); + const { getByTestId, container } = await newMPollBodyFromEvent(pollEvent, []); expect(getByTestId("pollQuestion").innerHTML).toEqual( 'new question (edited)', ); @@ -840,13 +747,13 @@ describe("MPollBody", () => { expect(options[2].innerHTML).toEqual("new answer 3"); }); - it("renders a poll with no votes", () => { + it("renders a poll with no votes", async () => { const votes: MatrixEvent[] = []; - const { container } = newMPollBody(votes); + const { container } = await newMPollBody(votes); expect(container).toMatchSnapshot(); }); - it("renders a poll with only non-local votes", () => { + it("renders a poll with only non-local votes", async () => { const votes = [ responseEvent("@op:example.com", "pizza", 12), responseEvent("@op:example.com", [], 13), @@ -854,11 +761,11 @@ describe("MPollBody", () => { responseEvent("@me:example.com", "wings", 15), responseEvent("@qr:example.com", "italian", 16), ]; - const { container } = newMPollBody(votes); + const { container } = await newMPollBody(votes); expect(container).toMatchSnapshot(); }); - it("renders a poll with local, non-local and invalid votes", () => { + it("renders a poll with local, non-local and invalid votes", async () => { const votes = [ responseEvent("@a:example.com", "pizza", 12), responseEvent("@b:example.com", [], 13), @@ -867,12 +774,13 @@ describe("MPollBody", () => { responseEvent("@e:example.com", "wings", 15), responseEvent("@me:example.com", "italian", 16), ]; - const renderResult = newMPollBody(votes); + const renderResult = await newMPollBody(votes); clickOption(renderResult, "italian"); + expect(renderResult.container).toMatchSnapshot(); }); - it("renders a poll that I have not voted in", () => { + it("renders a poll that I have not voted in", async () => { const votes = [ responseEvent("@op:example.com", "pizza", 12), responseEvent("@op:example.com", [], 13), @@ -880,17 +788,17 @@ describe("MPollBody", () => { responseEvent("@yo:example.com", "wings", 15), responseEvent("@qr:example.com", "italian", 16), ]; - const { container } = newMPollBody(votes); + const { container } = await newMPollBody(votes); expect(container).toMatchSnapshot(); }); - it("renders a finished poll with no votes", () => { + it("renders a finished poll with no votes", async () => { const ends = [endEvent("@me:example.com", 25)]; - const { container } = newMPollBody([], ends); + const { container } = await newMPollBody([], ends); expect(container).toMatchSnapshot(); }); - it("renders a finished poll", () => { + it("renders a finished poll", async () => { const votes = [ responseEvent("@op:example.com", "pizza", 12), responseEvent("@op:example.com", [], 13), @@ -899,11 +807,11 @@ describe("MPollBody", () => { responseEvent("@qr:example.com", "italian", 16), ]; const ends = [endEvent("@me:example.com", 25)]; - const { container } = newMPollBody(votes, ends); + const { container } = await newMPollBody(votes, ends); expect(container).toMatchSnapshot(); }); - it("renders a finished poll with multiple winners", () => { + it("renders a finished poll with multiple winners", async () => { const votes = [ responseEvent("@ed:example.com", "pizza", 12), responseEvent("@rf:example.com", "pizza", 12), @@ -913,11 +821,11 @@ describe("MPollBody", () => { responseEvent("@yh:example.com", "poutine", 14), ]; const ends = [endEvent("@me:example.com", 25)]; - const { container } = newMPollBody(votes, ends); + const { container } = await newMPollBody(votes, ends); expect(container).toMatchSnapshot(); }); - it("renders an undisclosed, unfinished poll", () => { + it("renders an undisclosed, unfinished poll", async () => { const votes = [ responseEvent("@ed:example.com", "pizza", 12), responseEvent("@rf:example.com", "pizza", 12), @@ -927,11 +835,11 @@ describe("MPollBody", () => { responseEvent("@yh:example.com", "poutine", 14), ]; const ends: MatrixEvent[] = []; - const { container } = newMPollBody(votes, ends, undefined, false); + const { container } = await newMPollBody(votes, ends, undefined, false); expect(container).toMatchSnapshot(); }); - it("renders an undisclosed, finished poll", () => { + it("renders an undisclosed, finished poll", async () => { const votes = [ responseEvent("@ed:example.com", "pizza", 12), responseEvent("@rf:example.com", "pizza", 12), @@ -941,65 +849,47 @@ describe("MPollBody", () => { responseEvent("@yh:example.com", "poutine", 14), ]; const ends = [endEvent("@me:example.com", 25)]; - const { container } = newMPollBody(votes, ends, undefined, false); + const { container } = await newMPollBody(votes, ends, undefined, false); expect(container).toMatchSnapshot(); }); }); function newVoteRelations(relationEvents: Array): Relations { - return newRelations(relationEvents, M_POLL_RESPONSE.name); + return newRelations(relationEvents, M_POLL_RESPONSE.name, [M_POLL_RESPONSE.altName!]); } -function newEndRelations(relationEvents: Array): Relations { - return newRelations(relationEvents, M_POLL_END.name); -} - -function newRelations(relationEvents: Array, eventType: string): Relations { - const voteRelations = new Relations("m.reference", eventType, mockClient); +function newRelations(relationEvents: Array, eventType: string, altEventTypes?: string[]): Relations { + const voteRelations = new Relations("m.reference", eventType, mockClient, altEventTypes); for (const ev of relationEvents) { voteRelations.addEvent(ev); } return voteRelations; } -function newMPollBody( +async function newMPollBody( relationEvents: Array, endEvents: Array = [], answers?: PollAnswer[], disclosed = true, -): RenderResult { + waitForResponsesLoad = true, +): Promise { const mxEvent = new MatrixEvent({ type: M_POLL_START.name, event_id: "$mypoll", room_id: "#myroom:example.com", content: newPollStart(answers, undefined, disclosed), }); - return newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); + const result = newMPollBodyFromEvent(mxEvent, relationEvents, endEvents); + // flush promises from loading relations + if (waitForResponsesLoad) { + await flushPromises(); + } + return result; } -function getMPollBodyPropsFromEvent( - mxEvent: MatrixEvent, - relationEvents: Array, - endEvents: Array = [], -): IBodyProps { - const voteRelations = newVoteRelations(relationEvents); - const endRelations = newEndRelations(endEvents); - - const getRelationsForEvent = (eventId: string, relationType: string, eventType: string) => { - expect(eventId).toBe("$mypoll"); - expect(relationType).toBe("m.reference"); - if (M_POLL_RESPONSE.matches(eventType)) { - return voteRelations; - } else if (M_POLL_END.matches(eventType)) { - return endRelations; - } else { - fail("Unexpected eventType: " + eventType); - } - }; - +function getMPollBodyPropsFromEvent(mxEvent: MatrixEvent): IBodyProps { return { mxEvent, - getRelationsForEvent, // We don't use any of these props, but they're required. highlightLink: "unused", highlights: [], @@ -1018,15 +908,35 @@ function renderMPollBodyWithWrapper(props: IBodyProps): RenderResult { }); } -function newMPollBodyFromEvent( +async function newMPollBodyFromEvent( mxEvent: MatrixEvent, relationEvents: Array, endEvents: Array = [], -): RenderResult { - const props = getMPollBodyPropsFromEvent(mxEvent, relationEvents, endEvents); +): Promise { + const props = getMPollBodyPropsFromEvent(mxEvent); + + await setupRoomWithPollEvents(mxEvent, relationEvents, endEvents); + return renderMPollBodyWithWrapper(props); } +async function setupRoomWithPollEvents( + mxEvent: MatrixEvent, + relationEvents: Array, + endEvents: Array = [], +): Promise { + const room = new Room(mxEvent.getRoomId()!, mockClient, userId); + room.processPollEvents([mxEvent, ...relationEvents, ...endEvents]); + setRedactionAllowedForMeOnly(room); + // wait for events to process on room + await flushPromises(); + mockClient.getRoom.mockReturnValue(room); + mockClient.relations.mockResolvedValue({ + events: [...relationEvents, ...endEvents], + }); + return room; +} + function clickOption({ getByTestId }: RenderResult, value: string) { fireEvent.click(getByTestId(`pollOption-${value}`)); } @@ -1081,21 +991,6 @@ function newPollStart(answers?: PollAnswer[], question?: string, disclosed = tru }; } -function badResponseEvent(): MatrixEvent { - return new MatrixEvent({ - event_id: nextId(), - type: M_POLL_RESPONSE.name, - sender: "@malicious:example.com", - content: { - "m.relates_to": { - rel_type: "m.reference", - event_id: "$mypoll", - }, - // Does not actually contain a response - }, - }); -} - function responseEvent( sender = "@alice:example.com", answers: string | Array = "italian", @@ -1133,8 +1028,7 @@ function expectedResponseEvent(answer: string) { }, roomId: "#myroom:example.com", eventType: M_POLL_RESPONSE.name, - txnId: undefined, - callback: undefined, + txnId: "$123", }; } function expectedResponseEventCall(answer: string) { @@ -1160,7 +1054,7 @@ function endEvent(sender = "@me:example.com", ts = 0): MatrixEvent { }); } -function runIsPollEnded(ends: MatrixEvent[]) { +async function runIsPollEnded(ends: MatrixEvent[]) { const pollEvent = new MatrixEvent({ event_id: "$mypoll", room_id: "#myroom:example.com", @@ -1168,19 +1062,12 @@ function runIsPollEnded(ends: MatrixEvent[]) { content: newPollStart(), }); - setRedactionAllowedForMeOnly(mockClient); + await setupRoomWithPollEvents(pollEvent, [], ends); - const getRelationsForEvent = (eventId: string, relationType: string, eventType: string) => { - expect(eventId).toBe("$mypoll"); - expect(relationType).toBe("m.reference"); - expect(M_POLL_END.matches(eventType)).toBe(true); - return newEndRelations(ends); - }; - - return isPollEnded(pollEvent, mockClient, getRelationsForEvent); + return isPollEnded(pollEvent, mockClient); } -function runFindTopAnswer(votes: MatrixEvent[], ends: MatrixEvent[]) { +function runFindTopAnswer(votes: MatrixEvent[]) { const pollEvent = new MatrixEvent({ event_id: "$mypoll", room_id: "#myroom:example.com", @@ -1188,30 +1075,12 @@ function runFindTopAnswer(votes: MatrixEvent[], ends: MatrixEvent[]) { content: newPollStart(), }); - const getRelationsForEvent = (eventId: string, relationType: string, eventType: string) => { - expect(eventId).toBe("$mypoll"); - expect(relationType).toBe("m.reference"); - if (M_POLL_RESPONSE.matches(eventType)) { - return newVoteRelations(votes); - } else if (M_POLL_END.matches(eventType)) { - return newEndRelations(ends); - } else { - fail(`eventType should be end or vote but was ${eventType}`); - } - }; - - return findTopAnswer(pollEvent, MatrixClientPeg.get(), getRelationsForEvent); + return findTopAnswer(pollEvent, newVoteRelations(votes)); } -function setRedactionAllowedForMeOnly(matrixClient: MockedObject) { - matrixClient.getRoom.mockImplementation((_roomId: string) => { - return { - currentState: { - maySendRedactionForEvent: (_evt: MatrixEvent, userId: string) => { - return userId === "@me:example.com"; - }, - }, - } as Room; +function setRedactionAllowedForMeOnly(room: Room) { + jest.spyOn(room.currentState, "maySendRedactionForEvent").mockImplementation((_evt: MatrixEvent, id: string) => { + return id === userId; }); } diff --git a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap index 2263527148b9..7bc530048cc4 100644 --- a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap @@ -456,6 +456,8 @@ exports[`MPollBody renders a finished poll with no votes 1`] = ` `; +exports[`MPollBody renders a loader while responses are still loading 1`] = `"Based on 4 votes
    "`; + exports[`MPollBody renders a poll that I have not voted in 1`] = `
    ", () => { stubClient(); const cli = mocked(MatrixClientPeg.get()); cli.getUserId.mockReturnValue("@alice:example.org"); - cli.setRoomAccountData.mockReturnValue(undefined); + cli.setRoomAccountData.mockResolvedValue({}); cli.relations.mockResolvedValue({ originalEvent: {} as unknown as MatrixEvent, events: [] }); const mkRoom = (localPins: MatrixEvent[], nonLocalPins: MatrixEvent[]): Room => { - const room = mkStubRoom("!room:example.org", "room", cli); + const room = new Room("!room:example.org", cli, "@me:example.org"); // Deferred since we may be adding or removing pins later const pins = () => [...localPins, ...nonLocalPins]; // Insert pin IDs into room state - mocked(room.currentState).getStateEvents.mockImplementation((): any => + jest.spyOn(room.currentState, "getStateEvents").mockImplementation((): any => mkEvent({ event: true, type: EventType.RoomPinnedEvents, @@ -61,6 +61,8 @@ describe("", () => { }), ); + jest.spyOn(room.currentState, "on"); + // Insert local pins into local timeline set room.getUnfilteredTimelineSet = () => ({ @@ -75,6 +77,8 @@ describe("", () => { return Promise.resolve(event as IMinimalEvent); }); + cli.getRoom.mockReturnValue(room); + return room; }; @@ -131,8 +135,8 @@ describe("", () => { it("updates when messages are pinned", async () => { // Start with nothing pinned - const localPins = []; - const nonLocalPins = []; + const localPins: MatrixEvent[] = []; + const nonLocalPins: MatrixEvent[] = []; const pins = await mountPins(mkRoom(localPins, nonLocalPins)); expect(pins.find(PinnedEventTile).length).toBe(0); @@ -240,31 +244,27 @@ describe("", () => { ["@eve:example.org", 1], ].map(([user, option], i) => mkEvent({ - ...PollResponseEvent.from([answers[option].id], poll.getId()).serialize(), + ...PollResponseEvent.from([answers[option as number].id], poll.getId()!).serialize(), event: true, room: "!room:example.org", user: user as string, }), ); + const end = mkEvent({ - ...PollEndEvent.from(poll.getId(), "Closing the poll").serialize(), + ...PollEndEvent.from(poll.getId()!, "Closing the poll").serialize(), event: true, room: "!room:example.org", user: "@alice:example.org", }); // Make the responses available - cli.relations.mockImplementation(async (roomId, eventId, relationType, eventType, { from }) => { + cli.relations.mockImplementation(async (roomId, eventId, relationType, eventType, opts) => { if (eventId === poll.getId() && relationType === RelationType.Reference) { - switch (eventType) { - case M_POLL_RESPONSE.name: - // Paginate the results, for added challenge - return from === "page2" - ? { originalEvent: poll, events: responses.slice(2) } - : { originalEvent: poll, events: responses.slice(0, 2), nextBatch: "page2" }; - case M_POLL_END.name: - return { originalEvent: null, events: [end] }; - } + // Paginate the results, for added challenge + return opts?.from === "page2" + ? { originalEvent: poll, events: responses.slice(2) } + : { originalEvent: poll, events: [...responses.slice(0, 2), end], nextBatch: "page2" }; } // type does not allow originalEvent to be falsy // but code seems to @@ -272,8 +272,20 @@ describe("", () => { return { originalEvent: undefined as unknown as MatrixEvent, events: [] }; }); - const pins = await mountPins(mkRoom([], [poll])); + const room = mkRoom([], [poll]); + // poll end event validates against this + jest.spyOn(room.currentState, "maySendRedactionForEvent").mockReturnValue(true); + + const pins = await mountPins(room); + // two pages of results + await flushPromises(); + await flushPromises(); + + const pollInstance = room.polls.get(poll.getId()!); + expect(pollInstance).toBeTruthy(); + const pinTile = pins.find(MPollBody); + expect(pinTile.length).toEqual(1); expect(pinTile.find(".mx_MPollBody_option_ended").length).toEqual(2); expect(pinTile.find(".mx_MPollBody_optionVoteCount").first().text()).toEqual("2 votes"); From ebb8408f28af410e1f3244aee6dd4f7c8ef4becc Mon Sep 17 00:00:00 2001 From: Kerry Date: Fri, 3 Feb 2023 10:39:23 +1300 Subject: [PATCH 13/20] Poll history - read only list of polls in current timeline (#10055) * add settings while under development * very basic tests for roomsummarycard * empty poll history dialog and option in room summary * pollS history in settings * render an ugly list of polls in current timeline * readonly poll history list items * fix scroll window * use short year code in date format, tidy * no results message + tests * strict fix * mock intldatetimeformat for stable date formatting * extract date format fn into date-utils * jsdoc --- res/css/_components.pcss | 3 + .../views/dialogs/polls/_PollListItem.pcss | 40 ++++++++ .../dialogs/polls/_PollHistoryDialog.pcss | 23 +++++ .../views/dialogs/polls/_PollHistoryList.pcss | 44 +++++++++ src/DateUtils.ts | 13 +++ .../views/dialogs/polls/PollHistoryDialog.tsx | 11 ++- .../views/dialogs/polls/PollHistoryList.tsx | 40 ++++++++ .../views/dialogs/polls/PollListItem.tsx | 43 ++++++++ .../views/dialogs/polls/usePollHistory.ts | 40 ++++++++ .../views/right_panel/RoomSummaryCard.tsx | 7 +- src/i18n/strings/en_EN.json | 1 + .../dialogs/polls/PollHistoryDialog-test.tsx | 86 ++++++++++++++++ .../views/dialogs/polls/PollListItem-test.tsx | 53 ++++++++++ .../PollHistoryDialog-test.tsx.snap | 99 +++++++++++++++++++ .../__snapshots__/PollListItem-test.tsx.snap | 22 +++++ .../right_panel/RoomSummaryCard-test.tsx | 2 +- .../RoomSummaryCard-test.tsx.snap | 1 + test/test-utils/date.ts | 18 ++++ test/test-utils/poll.ts | 11 ++- test/utils/DateUtils-test.ts | 22 ++++- 20 files changed, 572 insertions(+), 7 deletions(-) create mode 100644 res/css/components/views/dialogs/polls/_PollListItem.pcss create mode 100644 res/css/views/dialogs/polls/_PollHistoryDialog.pcss create mode 100644 res/css/views/dialogs/polls/_PollHistoryList.pcss create mode 100644 src/components/views/dialogs/polls/PollHistoryList.tsx create mode 100644 src/components/views/dialogs/polls/PollListItem.tsx create mode 100644 src/components/views/dialogs/polls/usePollHistory.ts create mode 100644 test/components/views/dialogs/polls/PollHistoryDialog-test.tsx create mode 100644 test/components/views/dialogs/polls/PollListItem-test.tsx create mode 100644 test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap create mode 100644 test/components/views/dialogs/polls/__snapshots__/PollListItem-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index fe50417c0064..195fc6cce77b 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -17,6 +17,7 @@ @import "./components/views/beacon/_ShareLatestLocation.pcss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; @import "./components/views/context_menus/_KebabContextMenu.pcss"; +@import "./components/views/dialogs/polls/_PollListItem.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; @import "./components/views/elements/_LearnMore.pcss"; @import "./components/views/location/_EnableLiveShare.pcss"; @@ -161,6 +162,8 @@ @import "./views/dialogs/_UserSettingsDialog.pcss"; @import "./views/dialogs/_VerifyEMailDialog.pcss"; @import "./views/dialogs/_WidgetCapabilitiesPromptDialog.pcss"; +@import "./views/dialogs/polls/_PollHistoryDialog.pcss"; +@import "./views/dialogs/polls/_PollHistoryList.pcss"; @import "./views/dialogs/security/_AccessSecretStorageDialog.pcss"; @import "./views/dialogs/security/_CreateCrossSigningDialog.pcss"; @import "./views/dialogs/security/_CreateKeyBackupDialog.pcss"; diff --git a/res/css/components/views/dialogs/polls/_PollListItem.pcss b/res/css/components/views/dialogs/polls/_PollListItem.pcss new file mode 100644 index 000000000000..7b19e675943c --- /dev/null +++ b/res/css/components/views/dialogs/polls/_PollListItem.pcss @@ -0,0 +1,40 @@ +/* +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. +*/ + +.mx_PollListItem { + width: 100%; + display: grid; + justify-content: left; + align-items: center; + grid-gap: $spacing-8; + grid-template-columns: auto auto auto; + grid-template-rows: auto; + + color: $primary-content; +} + +.mx_PollListItem_icon { + height: 14px; + width: 14px; + color: $quaternary-content; + padding-left: $spacing-8; +} + +.mx_PollListItem_question { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} diff --git a/res/css/views/dialogs/polls/_PollHistoryDialog.pcss b/res/css/views/dialogs/polls/_PollHistoryDialog.pcss new file mode 100644 index 000000000000..39a53344ede8 --- /dev/null +++ b/res/css/views/dialogs/polls/_PollHistoryDialog.pcss @@ -0,0 +1,23 @@ +/* +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. +*/ + +.mx_PollHistoryDialog_content { + height: 600px; + width: 100%; + + display: flex; + flex-direction: column; +} diff --git a/res/css/views/dialogs/polls/_PollHistoryList.pcss b/res/css/views/dialogs/polls/_PollHistoryList.pcss new file mode 100644 index 000000000000..6a0a003ce1e4 --- /dev/null +++ b/res/css/views/dialogs/polls/_PollHistoryList.pcss @@ -0,0 +1,44 @@ +/* +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. +*/ + +.mx_PollHistoryList { + display: flex; + flex-direction: column; + flex: 1 1 auto; + max-height: 100%; +} + +.mx_PollHistoryList_list { + overflow: auto; + list-style: none; + margin-block: 0; + padding-inline: 0; + flex: 1 1 0; + align-content: flex-start; + display: grid; + grid-gap: $spacing-20; + padding-right: $spacing-64; + margin: $spacing-32 0; +} + +.mx_PollHistoryList_noResults { + height: 100%; + width: 100%; + display: flex; + align-items: center; + justify-content: center; + color: $secondary-content; +} diff --git a/src/DateUtils.ts b/src/DateUtils.ts index c1aa69aacd6d..c279c1ad1b24 100644 --- a/src/DateUtils.ts +++ b/src/DateUtils.ts @@ -269,3 +269,16 @@ export function formatPreciseDuration(durationMs: number): string { } return _t("%(value)ss", { value: seconds }); } + +/** + * Formats a timestamp to a short date + * (eg 25/12/22 in uk locale) + * localised by system locale + * @param timestamp - epoch timestamp + * @returns {string} formattedDate + */ +export const formatLocalDateShort = (timestamp: number): string => + new Intl.DateTimeFormat( + undefined, // locales + { day: "2-digit", month: "2-digit", year: "2-digit" }, + ).format(timestamp); diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx index 364f740c6cde..4671da924655 100644 --- a/src/components/views/dialogs/polls/PollHistoryDialog.tsx +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -15,19 +15,26 @@ limitations under the License. */ import React from "react"; +import { MatrixClient } from "matrix-js-sdk/src/client"; import { _t } from "../../../../languageHandler"; import BaseDialog from "../BaseDialog"; import { IDialogProps } from "../IDialogProps"; +import { PollHistoryList } from "./PollHistoryList"; +import { getPolls } from "./usePollHistory"; type PollHistoryDialogProps = Pick & { roomId: string; + matrixClient: MatrixClient; }; +export const PollHistoryDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { + const pollStartEvents = getPolls(roomId, matrixClient); -export const PollHistoryDialog: React.FC = ({ onFinished }) => { return ( - {/* @TODO(kerrya) to be implemented in PSG-906 */} +
    + +
    ); }; diff --git a/src/components/views/dialogs/polls/PollHistoryList.tsx b/src/components/views/dialogs/polls/PollHistoryList.tsx new file mode 100644 index 000000000000..ff0ea3a7cfcc --- /dev/null +++ b/src/components/views/dialogs/polls/PollHistoryList.tsx @@ -0,0 +1,40 @@ +/* +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 { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import PollListItem from "./PollListItem"; +import { _t } from "../../../../languageHandler"; + +type PollHistoryListProps = { + pollStartEvents: MatrixEvent[]; +}; +export const PollHistoryList: React.FC = ({ pollStartEvents }) => { + return ( +
    + {!!pollStartEvents.length ? ( +
      + {pollStartEvents.map((pollStartEvent) => ( + + ))} +
    + ) : ( + {_t("There are no polls in this room")} + )} +
    + ); +}; diff --git a/src/components/views/dialogs/polls/PollListItem.tsx b/src/components/views/dialogs/polls/PollListItem.tsx new file mode 100644 index 000000000000..49df399bd733 --- /dev/null +++ b/src/components/views/dialogs/polls/PollListItem.tsx @@ -0,0 +1,43 @@ +/* +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 { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import { Icon as PollIcon } from "../../../../../res/img/element-icons/room/composer/poll.svg"; +import { formatLocalDateShort } from "../../../../DateUtils"; + +interface Props { + event: MatrixEvent; +} + +const PollListItem: React.FC = ({ event }) => { + const pollEvent = event.unstableExtensibleEvent as unknown as PollStartEvent; + if (!pollEvent) { + return null; + } + const formattedDate = formatLocalDateShort(event.getTs()); + return ( +
  • + {formattedDate} + + {pollEvent.question.text} +
  • + ); +}; + +export default PollListItem; diff --git a/src/components/views/dialogs/polls/usePollHistory.ts b/src/components/views/dialogs/polls/usePollHistory.ts new file mode 100644 index 000000000000..aa730b84ee57 --- /dev/null +++ b/src/components/views/dialogs/polls/usePollHistory.ts @@ -0,0 +1,40 @@ +/* +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 { M_POLL_START } from "matrix-js-sdk/src/@types/polls"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { MatrixClient } from "matrix-js-sdk/src/client"; + +/** + * Get poll start events in a rooms live timeline + * @param roomId - id of room to retrieve polls for + * @param matrixClient - client + * @returns {MatrixEvent[]} - array fo poll start events + */ +export const getPolls = (roomId: string, matrixClient: MatrixClient): MatrixEvent[] => { + const room = matrixClient.getRoom(roomId); + + if (!room) { + throw new Error("Cannot find room"); + } + + // @TODO(kerrya) poll history will be actively fetched in PSG-1043 + // for now, just display polls that are in the current timeline + const timelineEvents = room.getLiveTimeline().getEvents(); + const pollStartEvents = timelineEvents.filter((event) => M_POLL_START.matches(event.getType())); + + return pollStartEvents; +}; diff --git a/src/components/views/right_panel/RoomSummaryCard.tsx b/src/components/views/right_panel/RoomSummaryCard.tsx index e221106bb904..37d9a6f97a6d 100644 --- a/src/components/views/right_panel/RoomSummaryCard.tsx +++ b/src/components/views/right_panel/RoomSummaryCard.tsx @@ -286,6 +286,7 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { const onRoomPollHistoryClick = (): void => { Modal.createDialog(PollHistoryDialog, { roomId: room.roomId, + matrixClient: cli, }); }; @@ -353,7 +354,11 @@ const RoomSummaryCard: React.FC = ({ room, onClose }) => { {_t("Export chat")} )} - ": "If you've forgotten your Security Key you can ", + "There are no polls in this room": "There are no polls in this room", "Send custom account data event": "Send custom account data event", "Send custom room account data event": "Send custom room account data event", "Event Type": "Event Type", diff --git a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx new file mode 100644 index 000000000000..4557b145e718 --- /dev/null +++ b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx @@ -0,0 +1,86 @@ +/* +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 } from "@testing-library/react"; +import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; + +import { PollHistoryDialog } from "../../../../../src/components/views/dialogs/polls/PollHistoryDialog"; +import { + getMockClientWithEventEmitter, + makePollStartEvent, + mockClientMethodsUser, + mockIntlDateTimeFormat, + unmockIntlDateTimeFormat, +} from "../../../../test-utils"; + +describe("", () => { + const userId = "@alice:domain.org"; + const roomId = "!room:domain.org"; + const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + getRoom: jest.fn(), + }); + const room = new Room(roomId, mockClient, userId); + + const defaultProps = { + roomId, + matrixClient: mockClient, + onFinished: jest.fn(), + }; + const getComponent = () => render(); + + beforeAll(() => { + mockIntlDateTimeFormat(); + }); + + beforeEach(() => { + mockClient.getRoom.mockReturnValue(room); + const timeline = room.getLiveTimeline(); + jest.spyOn(timeline, "getEvents").mockReturnValue([]); + }); + + afterAll(() => { + unmockIntlDateTimeFormat(); + }); + + it("throws when room is not found", () => { + mockClient.getRoom.mockReturnValue(null); + + expect(() => getComponent()).toThrow("Cannot find room"); + }); + + it("renders a no polls message when there are no polls in the timeline", () => { + const { getByText } = getComponent(); + + expect(getByText("There are no polls in this room")).toBeTruthy(); + }); + + it("renders a list of polls when there are polls in the timeline", () => { + const pollStart1 = makePollStartEvent("Question?", userId, undefined, 1675300825090, "$1"); + const pollStart2 = makePollStartEvent("Where?", userId, undefined, 1675300725090, "$2"); + const pollStart3 = makePollStartEvent("What?", userId, undefined, 1675200725090, "$3"); + const message = new MatrixEvent({ + type: "m.room.message", + content: {}, + }); + const timeline = room.getLiveTimeline(); + jest.spyOn(timeline, "getEvents").mockReturnValue([pollStart1, pollStart2, pollStart3, message]); + const { container } = getComponent(); + + expect(container).toMatchSnapshot(); + }); +}); diff --git a/test/components/views/dialogs/polls/PollListItem-test.tsx b/test/components/views/dialogs/polls/PollListItem-test.tsx new file mode 100644 index 000000000000..b9e8ffcc7496 --- /dev/null +++ b/test/components/views/dialogs/polls/PollListItem-test.tsx @@ -0,0 +1,53 @@ +/* +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 } from "@testing-library/react"; +import { MatrixEvent } from "matrix-js-sdk/src/matrix"; + +import PollListItem from "../../../../../src/components/views/dialogs/polls/PollListItem"; +import { makePollStartEvent, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../../../../test-utils"; + +describe("", () => { + const event = makePollStartEvent("Question?", "@me:domain.org"); + event.getContent().origin; + const defaultProps = { event }; + const getComponent = (props = {}) => render(); + + beforeAll(() => { + // mock default locale to en-GB and set timezone + // so these tests run the same everywhere + mockIntlDateTimeFormat(); + }); + + afterAll(() => { + unmockIntlDateTimeFormat(); + }); + + it("renders a poll", () => { + const { container } = getComponent(); + expect(container).toMatchSnapshot(); + }); + + it("renders null when event does not have an extensible poll start event", () => { + const event = new MatrixEvent({ + type: "m.room.message", + content: {}, + }); + const { container } = getComponent({ event }); + expect(container.firstElementChild).toBeFalsy(); + }); +}); diff --git a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap new file mode 100644 index 000000000000..fd572bc2d162 --- /dev/null +++ b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap @@ -0,0 +1,99 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders a list of polls when there are polls in the timeline 1`] = ` +
    +
    +
    diff --git a/test/test-utils/date.ts b/test/test-utils/date.ts index c3010b2ae94f..f474f06a9a7e 100644 --- a/test/test-utils/date.ts +++ b/test/test-utils/date.ts @@ -15,3 +15,21 @@ limitations under the License. */ export const REPEATABLE_DATE = new Date(2022, 10, 17, 16, 58, 32, 517); + +// allow setting default locale and set timezone +// defaults to en-GB / Europe/London +// so tests run the same everywhere +export const mockIntlDateTimeFormat = (defaultLocale = "en-GB", defaultTimezone = "Europe/London"): void => { + // unmock so we can use real DateTimeFormat in mockImplementation + if (jest.isMockFunction(global.Intl.DateTimeFormat)) { + unmockIntlDateTimeFormat(); + } + const DateTimeFormat = Intl.DateTimeFormat; + jest.spyOn(global.Intl, "DateTimeFormat").mockImplementation( + (locale, options) => new DateTimeFormat(locale || defaultLocale, { ...options, timeZone: defaultTimezone }), + ); +}; + +export const unmockIntlDateTimeFormat = (): void => { + jest.spyOn(global.Intl, "DateTimeFormat").mockRestore(); +}; diff --git a/test/test-utils/poll.ts b/test/test-utils/poll.ts index 5096f8c51ad4..ffb23ee6096e 100644 --- a/test/test-utils/poll.ts +++ b/test/test-utils/poll.ts @@ -18,7 +18,13 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import { M_POLL_START, PollAnswer, M_POLL_KIND_DISCLOSED } from "matrix-js-sdk/src/@types/polls"; import { M_TEXT } from "matrix-js-sdk/src/@types/extensible_events"; -export const makePollStartEvent = (question: string, sender: string, answers?: PollAnswer[]): MatrixEvent => { +export const makePollStartEvent = ( + question: string, + sender: string, + answers?: PollAnswer[], + ts?: number, + id?: string, +): MatrixEvent => { if (!answers) { answers = [ { id: "socks", [M_TEXT.name]: "Socks" }, @@ -27,7 +33,7 @@ export const makePollStartEvent = (question: string, sender: string, answers?: P } return new MatrixEvent({ - event_id: "$mypoll", + event_id: id || "$mypoll", room_id: "#myroom:example.com", sender: sender, type: M_POLL_START.name, @@ -41,5 +47,6 @@ export const makePollStartEvent = (question: string, sender: string, answers?: P }, [M_TEXT.name]: `${question}: answers`, }, + origin_server_ts: ts || 0, }); }; diff --git a/test/utils/DateUtils-test.ts b/test/utils/DateUtils-test.ts index 2c72b261775b..9b7cd084ac6d 100644 --- a/test/utils/DateUtils-test.ts +++ b/test/utils/DateUtils-test.ts @@ -21,8 +21,9 @@ import { formatFullDateNoDayISO, formatTimeLeft, formatPreciseDuration, + formatLocalDateShort, } from "../../src/DateUtils"; -import { REPEATABLE_DATE } from "../test-utils"; +import { REPEATABLE_DATE, mockIntlDateTimeFormat, unmockIntlDateTimeFormat } from "../test-utils"; describe("formatSeconds", () => { it("correctly formats time with hours", () => { @@ -137,3 +138,22 @@ describe("formatTimeLeft", () => { expect(formatTimeLeft(seconds)).toBe(expected); }); }); + +describe("formatLocalDateShort()", () => { + afterAll(() => { + unmockIntlDateTimeFormat(); + }); + const timestamp = new Date("Fri Dec 17 2021 09:09:00 GMT+0100 (Central European Standard Time)").getTime(); + it("formats date correctly by locale", () => { + // format is DD/MM/YY + mockIntlDateTimeFormat("en-UK"); + expect(formatLocalDateShort(timestamp)).toEqual("17/12/21"); + + // US date format is MM/DD/YY + mockIntlDateTimeFormat("en-US"); + expect(formatLocalDateShort(timestamp)).toEqual("12/17/21"); + + mockIntlDateTimeFormat("de-DE"); + expect(formatLocalDateShort(timestamp)).toEqual("17.12.21"); + }); +}); From 089557005a9c1eb2590d43790cf94fc82f75c374 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 3 Feb 2023 09:39:25 +0100 Subject: [PATCH 14/20] Autofocus security key field (#10048) --- .../security/AccessSecretStorageDialog.tsx | 1 + .../AccessSecretStorageDialog-test.tsx | 169 ++++++++---------- 2 files changed, 71 insertions(+), 99 deletions(-) diff --git a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx index 9bad916ff5dd..d7154b3aa2fb 100644 --- a/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx +++ b/src/components/views/dialogs/security/AccessSecretStorageDialog.tsx @@ -404,6 +404,7 @@ export default class AccessSecretStorageDialog extends React.PureComponent diff --git a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx index 8e5d40432293..9a9b103fac28 100644 --- a/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx +++ b/test/components/views/dialogs/AccessSecretStorageDialog-test.tsx @@ -15,113 +15,97 @@ limitations under the License. */ import React from "react"; -// eslint-disable-next-line deprecate/import -import { mount, ReactWrapper } from "enzyme"; -import { act } from "react-dom/test-utils"; import { IPassphraseInfo } from "matrix-js-sdk/src/crypto/api"; +import { act, fireEvent, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { MatrixClient } from "matrix-js-sdk/src/matrix"; +import { Mocked } from "jest-mock"; -import { findByAttr, getMockClientWithEventEmitter, unmockClientPeg } from "../../../test-utils"; -import { findById, flushPromises } from "../../../test-utils"; +import { getMockClientWithEventEmitter, mockPlatformPeg } from "../../../test-utils"; import AccessSecretStorageDialog from "../../../../src/components/views/dialogs/security/AccessSecretStorageDialog"; +const securityKey = "EsTc WKmb ivvk jLS7 Y1NH 5CcQ mP1E JJwj B3Fd pFWm t4Dp dbyu"; + describe("AccessSecretStorageDialog", () => { - const mockClient = getMockClientWithEventEmitter({ - keyBackupKeyFromRecoveryKey: jest.fn(), - checkSecretStorageKey: jest.fn(), - isValidRecoveryKey: jest.fn(), - }); + let mockClient: Mocked; + const defaultProps = { onFinished: jest.fn(), checkPrivateKey: jest.fn(), keyInfo: undefined, }; - const getComponent = (props = {}): ReactWrapper => - mount(); - - beforeEach(() => { - jest.clearAllMocks(); - mockClient.keyBackupKeyFromRecoveryKey.mockReturnValue("a raw key" as unknown as Uint8Array); - mockClient.isValidRecoveryKey.mockReturnValue(false); - }); - - afterAll(() => { - unmockClientPeg(); - }); - it("Closes the dialog when the form is submitted with a valid key", async () => { - const onFinished = jest.fn(); - const checkPrivateKey = jest.fn().mockResolvedValue(true); - const wrapper = getComponent({ onFinished, checkPrivateKey }); + const renderComponent = (props = {}): void => { + render(); + }; - // force into valid state + const enterSecurityKey = (placeholder = "Security Key"): void => { act(() => { - wrapper.setState({ - recoveryKeyValid: true, - recoveryKey: "a", + fireEvent.change(screen.getByPlaceholderText(placeholder), { + target: { + value: securityKey, + }, }); + // wait for debounce + jest.advanceTimersByTime(250); }); - const e = { preventDefault: () => {} }; + }; - act(() => { - wrapper.find("form").simulate("submit", e); - }); + const submitDialog = async (): Promise => { + await userEvent.click(screen.getByText("Continue"), { delay: null }); + }; - await flushPromises(); + beforeAll(() => { + jest.useFakeTimers(); + mockPlatformPeg(); + }); - expect(checkPrivateKey).toHaveBeenCalledWith({ recoveryKey: "a" }); - expect(onFinished).toHaveBeenCalledWith({ recoveryKey: "a" }); + afterAll(() => { + jest.useRealTimers(); + jest.restoreAllMocks(); }); - it("Considers a valid key to be valid", async () => { - const checkPrivateKey = jest.fn().mockResolvedValue(true); - const wrapper = getComponent({ checkPrivateKey }); - mockClient.keyBackupKeyFromRecoveryKey.mockReturnValue("a raw key" as unknown as Uint8Array); + beforeEach(() => { + mockClient = getMockClientWithEventEmitter({ + keyBackupKeyFromRecoveryKey: jest.fn(), + checkSecretStorageKey: jest.fn(), + isValidRecoveryKey: jest.fn(), + }); + }); + + it("Closes the dialog when the form is submitted with a valid key", async () => { mockClient.checkSecretStorageKey.mockResolvedValue(true); + mockClient.isValidRecoveryKey.mockReturnValue(true); - const v = "asdf"; - const e = { target: { value: v } }; - act(() => { - findById(wrapper, "mx_securityKey").find("input").simulate("change", e); - wrapper.setProps({}); - }); - await act(async () => { - // force a validation now because it debounces - // @ts-ignore - await wrapper.instance().validateRecoveryKey(); - wrapper.setProps({}); - }); + const onFinished = jest.fn(); + const checkPrivateKey = jest.fn().mockResolvedValue(true); + renderComponent({ onFinished, checkPrivateKey }); + + // check that the input field is focused + expect(screen.getByPlaceholderText("Security Key")).toHaveFocus(); - const submitButton = findByAttr("data-testid")(wrapper, "dialog-primary-button").at(0); - // submit button is enabled when key is valid - expect(submitButton.props().disabled).toBeFalsy(); - expect(wrapper.find(".mx_AccessSecretStorageDialog_recoveryKeyFeedback").text()).toEqual("Looks good!"); + await enterSecurityKey(); + await submitDialog(); + + expect(screen.getByText("Looks good!")).toBeInTheDocument(); + expect(checkPrivateKey).toHaveBeenCalledWith({ recoveryKey: securityKey }); + expect(onFinished).toHaveBeenCalledWith({ recoveryKey: securityKey }); }); it("Notifies the user if they input an invalid Security Key", async () => { - const checkPrivateKey = jest.fn().mockResolvedValue(false); - const wrapper = getComponent({ checkPrivateKey }); - const e = { target: { value: "a" } }; + const onFinished = jest.fn(); + const checkPrivateKey = jest.fn().mockResolvedValue(true); + renderComponent({ onFinished, checkPrivateKey }); + mockClient.keyBackupKeyFromRecoveryKey.mockImplementation(() => { throw new Error("that's no key"); }); - act(() => { - findById(wrapper, "mx_securityKey").find("input").simulate("change", e); - }); - // force a validation now because it debounces - // @ts-ignore private - await wrapper.instance().validateRecoveryKey(); - - const submitButton = findByAttr("data-testid")(wrapper, "dialog-primary-button").at(0); - // submit button is disabled when recovery key is invalid - expect(submitButton.props().disabled).toBeTruthy(); - expect(wrapper.find(".mx_AccessSecretStorageDialog_recoveryKeyFeedback").text()).toEqual( - "Invalid Security Key", - ); - - wrapper.setProps({}); - const notification = wrapper.find(".mx_AccessSecretStorageDialog_recoveryKeyFeedback"); - expect(notification.props().children).toEqual("Invalid Security Key"); + await enterSecurityKey(); + await submitDialog(); + + expect(screen.getByText("Continue")).toBeDisabled(); + expect(screen.getByText("Invalid Security Key")).toBeInTheDocument(); }); it("Notifies the user if they input an invalid passphrase", async function () { @@ -139,30 +123,17 @@ describe("AccessSecretStorageDialog", () => { }, }; const checkPrivateKey = jest.fn().mockResolvedValue(false); - const wrapper = getComponent({ checkPrivateKey, keyInfo }); + renderComponent({ checkPrivateKey, keyInfo }); mockClient.isValidRecoveryKey.mockReturnValue(false); - // update passphrase - act(() => { - const e = { target: { value: "a" } }; - findById(wrapper, "mx_passPhraseInput").at(1).simulate("change", e); - }); - wrapper.setProps({}); - - // input updated - expect(findById(wrapper, "mx_passPhraseInput").at(0).props().value).toEqual("a"); + await enterSecurityKey("Security Phrase"); + expect(screen.getByPlaceholderText("Security Phrase")).toHaveValue(securityKey); + await submitDialog(); - // submit the form - act(() => { - wrapper.find("form").at(0).simulate("submit"); - }); - await flushPromises(); - - wrapper.setProps({}); - const notification = wrapper.find(".mx_AccessSecretStorageDialog_keyStatus"); - expect(notification.props().children).toEqual([ - "\uD83D\uDC4E ", - "Unable to access secret storage. Please verify that you " + "entered the correct Security Phrase.", - ]); + expect( + screen.getByText( + "👎 Unable to access secret storage. Please verify that you entered the correct Security Phrase.", + ), + ).toBeInTheDocument(); }); }); From 2bde31dcffcc8a01b4dfbd4b3940fda6c4856448 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 3 Feb 2023 08:59:21 +0000 Subject: [PATCH 15/20] Switch to linkify-react for element Linkification as it handles React subtrees without exploding (#10060 * Switch to linkify-react instead of our faulty implementation Fixes a series of soft crashes where errors include "The node to be removed is not a child of this node." * Improve types * Fix types * Update snapshots * Add test * Fix test --- cypress/e2e/spaces/spaces.spec.ts | 29 ++++++- package.json | 1 + src/HtmlUtils.tsx | 14 +++- src/SlashCommands.tsx | 9 +- src/components/structures/MatrixChat.tsx | 2 +- src/components/structures/SpaceHierarchy.tsx | 35 +++++--- src/components/views/elements/Linkify.tsx | 39 --------- src/components/views/elements/RoomTopic.tsx | 15 ++-- src/components/views/messages/TextualBody.tsx | 2 +- .../views/rooms/LinkPreviewWidget.tsx | 19 +---- src/components/views/rooms/RoomInfoLine.tsx | 2 +- src/linkify-matrix.ts | 8 +- .../__snapshots__/RoomView-test.tsx.snap | 32 +++----- .../views/elements/Linkify-test.tsx | 82 ------------------- yarn.lock | 5 ++ 15 files changed, 101 insertions(+), 193 deletions(-) delete mode 100644 src/components/views/elements/Linkify.tsx delete mode 100644 test/components/views/elements/Linkify-test.tsx diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index acf914c60f10..f89fa297d017 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -17,6 +17,7 @@ limitations under the License. /// import type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { Preset } from "matrix-js-sdk/src/@types/partials"; import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; @@ -32,7 +33,7 @@ function openSpaceContextMenu(spaceName: string): Chainable { return cy.get(".mx_SpacePanel_contextMenu"); } -function spaceCreateOptions(spaceName: string): ICreateRoomOpts { +function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateRoomOpts { return { creation_content: { type: "m.space", @@ -44,6 +45,7 @@ function spaceCreateOptions(spaceName: string): ICreateRoomOpts { name: spaceName, }, }, + ...roomIds.map(spaceChildInitialState), ], }; } @@ -283,4 +285,29 @@ describe("Spaces", () => { cy.checkA11y(undefined, axeOptions); cy.get(".mx_SpacePanel").percySnapshotElement("Space panel expanded", { widths: [258] }); }); + + it("should not soft crash when joining a room from space hierarchy which has a link in its topic", () => { + cy.getBot(homeserver, { displayName: "BotBob" }).then({ timeout: 10000 }, async (bot) => { + const { room_id: roomId } = await bot.createRoom({ + preset: "public_chat" as Preset, + name: "Test Room", + topic: "This is a topic https://github.com/matrix-org/matrix-react-sdk/pull/10060 with a link", + }); + const { room_id: spaceId } = await bot.createRoom(spaceCreateOptions("Test Space", [roomId])); + await bot.invite(spaceId, user.userId); + }); + + cy.getSpacePanelButton("Test Space").should("exist"); + cy.wait(500); // without this we can end up clicking too quickly and it ends up having no effect + cy.viewSpaceByName("Test Space"); + cy.contains(".mx_AccessibleButton", "Accept").click(); + + cy.contains(".mx_SpaceHierarchy_roomTile.mx_AccessibleButton", "Test Room").within(() => { + cy.contains("Join").should("exist").realHover().click(); + cy.contains("View", { timeout: 5000 }).should("exist").click(); + }); + + // Assert we get shown the new room intro, and thus not the soft crash screen + cy.get(".mx_NewRoomIntro").should("exist"); + }); }); diff --git a/package.json b/package.json index 8b8c3209f59e..fe618fc40b49 100644 --- a/package.json +++ b/package.json @@ -86,6 +86,7 @@ "jszip": "^3.7.0", "katex": "^0.16.0", "linkify-element": "4.0.0-beta.4", + "linkify-react": "4.0.0-beta.4", "linkify-string": "4.0.0-beta.4", "linkifyjs": "4.0.0-beta.4", "lodash": "^4.17.20", diff --git a/src/HtmlUtils.tsx b/src/HtmlUtils.tsx index e7d19f0834c8..f2452327e501 100644 --- a/src/HtmlUtils.tsx +++ b/src/HtmlUtils.tsx @@ -17,16 +17,17 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React, { ReactNode } from "react"; +import React, { ReactElement, ReactNode } from "react"; import sanitizeHtml from "sanitize-html"; import cheerio from "cheerio"; import classNames from "classnames"; import EMOJIBASE_REGEX from "emojibase-regex"; -import { split } from "lodash"; +import { merge, split } from "lodash"; import katex from "katex"; import { decode } from "html-entities"; import { IContent } from "matrix-js-sdk/src/models/event"; import { Optional } from "matrix-events-sdk"; +import _Linkify from "linkify-react"; import { _linkifyElement, @@ -682,6 +683,15 @@ export function topicToHtml( ); } +/* Wrapper around linkify-react merging in our default linkify options */ +export function Linkify({ as, options, children }: React.ComponentProps): ReactElement { + return ( + <_Linkify as={as} options={merge({}, linkifyMatrixOptions, options)}> + {children} + + ); +} + /** * Linkifies the given string. This is a wrapper around 'linkifyjs/string'. * diff --git a/src/SlashCommands.tsx b/src/SlashCommands.tsx index 9b23bd41386a..3434090d8ea5 100644 --- a/src/SlashCommands.tsx +++ b/src/SlashCommands.tsx @@ -33,7 +33,7 @@ import dis from "./dispatcher/dispatcher"; import { _t, _td, ITranslatableError, newTranslatableError } from "./languageHandler"; import Modal from "./Modal"; import MultiInviter from "./utils/MultiInviter"; -import { linkifyElement, topicToHtml } from "./HtmlUtils"; +import { Linkify, topicToHtml } from "./HtmlUtils"; import QuestionDialog from "./components/views/dialogs/QuestionDialog"; import WidgetUtils from "./utils/WidgetUtils"; import { textToHtmlRainbow } from "./utils/colour"; @@ -501,14 +501,11 @@ export const Commands = [ ? ContentHelpers.parseTopicContent(content) : { text: _t("This room has no topic.") }; - const ref = (e): void => { - if (e) linkifyElement(e); - }; - const body = topicToHtml(topic.text, topic.html, ref, true); + const body = topicToHtml(topic.text, topic.html, undefined, true); Modal.createDialog(InfoDialog, { title: room.name, - description:
    {body}
    , + description: {body}, hasCloseButton: true, className: "markdown-body", }); diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index e517aaaf83d6..44424619a75b 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -136,9 +136,9 @@ import { SdkContextClass, SDKContext } from "../../contexts/SDKContext"; import { viewUserDeviceSettings } from "../../actions/handlers/viewUserDeviceSettings"; import { cleanUpBroadcasts, VoiceBroadcastResumer } from "../../voice-broadcast"; import GenericToast from "../views/toasts/GenericToast"; -import { Linkify } from "../views/elements/Linkify"; import RovingSpotlightDialog, { Filter } from "../views/dialogs/spotlight/SpotlightDialog"; import { findDMForUser } from "../../utils/dm/findDMForUser"; +import { Linkify } from "../../HtmlUtils"; // legacy export export { default as Views } from "../../Views"; diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 7d6888a19791..60a80bc25f0b 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -51,7 +51,7 @@ import TextWithTooltip from "../views/elements/TextWithTooltip"; import { useStateToggle } from "../../hooks/useStateToggle"; import { getChildOrder } from "../../stores/spaces/SpaceStore"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; -import { linkifyElement, topicToHtml } from "../../HtmlUtils"; +import { Linkify, topicToHtml } from "../../HtmlUtils"; import { useDispatcher } from "../../hooks/useDispatcher"; import { Action } from "../../dispatcher/actions"; import { IState, RovingTabIndexProvider, useRovingTabIndex } from "../../accessibility/RovingTabIndex"; @@ -210,6 +210,25 @@ const Tile: React.FC = ({ topic = room.topic; } + let topicSection: ReactNode | undefined; + if (topic) { + topicSection = ( + + {" · "} + {topic} + + ); + } + let joinedSection: ReactElement | undefined; if (joinedRoom) { joinedSection =
    {_t("Joined")}
    ; @@ -231,19 +250,9 @@ const Tile: React.FC = ({ {joinedSection} {suggestedSection}
    -
    e && linkifyElement(e)} - onClick={(ev) => { - // prevent clicks on links from bubbling up to the room tile - if ((ev.target as HTMLElement).tagName === "A") { - ev.stopPropagation(); - } - }} - > +
    {description} - {topic && " · "} - {topic} + {topicSection}
    diff --git a/src/components/views/elements/Linkify.tsx b/src/components/views/elements/Linkify.tsx deleted file mode 100644 index 077ccd76d0d5..000000000000 --- a/src/components/views/elements/Linkify.tsx +++ /dev/null @@ -1,39 +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 React, { useLayoutEffect, useRef } from "react"; - -import { linkifyElement } from "../../../HtmlUtils"; - -interface Props { - as?: string; - children: React.ReactNode; - onClick?: (ev: MouseEvent) => void; -} - -export function Linkify({ as = "div", children, onClick }: Props): JSX.Element { - const ref = useRef(); - - useLayoutEffect(() => { - linkifyElement(ref.current); - }, [children]); - - return React.createElement(as, { - children, - ref, - onClick, - }); -} diff --git a/src/components/views/elements/RoomTopic.tsx b/src/components/views/elements/RoomTopic.tsx index 7f2d068d35d4..e760d38218a3 100644 --- a/src/components/views/elements/RoomTopic.tsx +++ b/src/components/views/elements/RoomTopic.tsx @@ -29,9 +29,8 @@ import InfoDialog from "../dialogs/InfoDialog"; import { useDispatcher } from "../../../hooks/useDispatcher"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import AccessibleButton from "./AccessibleButton"; -import { Linkify } from "./Linkify"; import TooltipTarget from "./TooltipTarget"; -import { topicToHtml } from "../../../HtmlUtils"; +import { Linkify, topicToHtml } from "../../../HtmlUtils"; interface IProps extends React.HTMLProps { room?: Room; @@ -71,12 +70,14 @@ export default function RoomTopic({ room, ...props }: IProps): JSX.Element { description: (
    { - if ((ev.target as HTMLElement).tagName.toUpperCase() === "A") { - modal.close(); - } + options={{ + attributes: { + onClick() { + modal.close(); + }, + }, }} + as="p" > {body} diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 1679c08f1294..9f6bed732dfc 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -436,7 +436,7 @@ export default class TextualBody extends React.Component { private onBodyLinkClick = (e: MouseEvent): void => { let target = e.target as HTMLLinkElement; // links processed by linkifyjs have their own handler so don't handle those here - if (target.classList.contains(linkifyOpts.className)) return; + if (target.classList.contains(linkifyOpts.className as string)) return; if (target.nodeName !== "A") { // Jump to parent as the `` may contain children, e.g. an anchor wrapping an inline code section target = target.closest("a"); diff --git a/src/components/views/rooms/LinkPreviewWidget.tsx b/src/components/views/rooms/LinkPreviewWidget.tsx index 225434db008b..23bd19ff4dc8 100644 --- a/src/components/views/rooms/LinkPreviewWidget.tsx +++ b/src/components/views/rooms/LinkPreviewWidget.tsx @@ -19,7 +19,7 @@ import { decode } from "html-entities"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { IPreviewUrlResponse } from "matrix-js-sdk/src/client"; -import { linkifyElement } from "../../../HtmlUtils"; +import { Linkify } from "../../../HtmlUtils"; import SettingsStore from "../../../settings/SettingsStore"; import Modal from "../../../Modal"; import * as ImageUtils from "../../../ImageUtils"; @@ -35,21 +35,8 @@ interface IProps { } export default class LinkPreviewWidget extends React.Component { - private readonly description = createRef(); private image = createRef(); - public componentDidMount(): void { - if (this.description.current) { - linkifyElement(this.description.current); - } - } - - public componentDidUpdate(): void { - if (this.description.current) { - linkifyElement(this.description.current); - } - } - private onImageClick = (ev): void => { const p = this.props.preview; if (ev.button != 0 || ev.metaKey) return; @@ -155,8 +142,8 @@ export default class LinkPreviewWidget extends React.Component { {" - " + p["og:site_name"]} )}
    -
    - {description} +
    + {description}
    diff --git a/src/components/views/rooms/RoomInfoLine.tsx b/src/components/views/rooms/RoomInfoLine.tsx index 18b5b1c766a6..114a613dcad6 100644 --- a/src/components/views/rooms/RoomInfoLine.tsx +++ b/src/components/views/rooms/RoomInfoLine.tsx @@ -37,7 +37,7 @@ const RoomInfoLine: FC = ({ room }) => { const summary = useAsyncMemo(async (): Promise> | null> => { if (room.getMyMembership() !== "invite") return null; try { - return room.client.getRoomSummary(room.roomId); + return await room.client.getRoomSummary(room.roomId); } catch (e) { return null; } diff --git a/src/linkify-matrix.ts b/src/linkify-matrix.ts index f1a9b91c5526..9ac9d8ed95da 100644 --- a/src/linkify-matrix.ts +++ b/src/linkify-matrix.ts @@ -16,7 +16,7 @@ limitations under the License. */ import * as linkifyjs from "linkifyjs"; -import { registerCustomProtocol, registerPlugin } from "linkifyjs"; +import { Opts, registerCustomProtocol, registerPlugin } from "linkifyjs"; import linkifyElement from "linkify-element"; import linkifyString from "linkify-string"; import { RoomMember } from "matrix-js-sdk/src/models/room-member"; @@ -139,9 +139,9 @@ export const ELEMENT_URL_PATTERN = "(?:app|beta|staging|develop)\\.element\\.io/" + ")(#.*)"; -export const options = { - events: function (href: string, type: Type | string): Partial { - switch (type) { +export const options: Opts = { + events: function (href: string, type: string): Partial { + switch (type as Type) { case Type.URL: { // intercept local permalinks to users and show them like userids (in userinfo of current room) try { diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap index 47318525d56d..894410341309 100644 --- a/test/components/structures/__snapshots__/RoomView-test.tsx.snap +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -62,11 +62,9 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
    -
    - -
    +
    @@ -161,11 +159,9 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
    -
    - -
    +
    @@ -356,11 +352,9 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
    -
    - -
    +
    @@ -623,11 +617,9 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
    -
    - -
    +
    diff --git a/test/components/views/elements/Linkify-test.tsx b/test/components/views/elements/Linkify-test.tsx deleted file mode 100644 index 2fa841b1f11c..000000000000 --- a/test/components/views/elements/Linkify-test.tsx +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2021 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 React, { useState } from "react"; - -import { Linkify } from "../../../../src/components/views/elements/Linkify"; - -describe("Linkify", () => { - it("linkifies the context", () => { - const { container } = render(https://perdu.com); - expect(container.innerHTML).toBe( - '
    ", - ); - }); - - it("correctly linkifies a room alias", () => { - const { container } = render(#element-web:matrix.org); - expect(container.innerHTML).toBe( - "", - ); - }); - - it("changes the root tag name", () => { - const TAG_NAME = "p"; - - const { container } = render(Hello world!); - - expect(container.querySelectorAll("p")).toHaveLength(1); - }); - - it("relinkifies on update", () => { - function DummyTest() { - const [n, setN] = useState(0); - function onClick() { - setN(n + 1); - } - - // upon clicking the element, change the content, and expect - // linkify to update - return ( -
    - {n % 2 === 0 ? "https://perdu.com" : "https://matrix.org"} -
    - ); - } - - const { container } = render(); - - expect(container.innerHTML).toBe( - "", - ); - - fireEvent.click(container.querySelector("div")); - - expect(container.innerHTML).toBe( - "", - ); - }); -}); diff --git a/yarn.lock b/yarn.lock index f5f4930289a3..b548d9f91366 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6258,6 +6258,11 @@ linkify-element@4.0.0-beta.4: resolved "https://registry.yarnpkg.com/linkify-element/-/linkify-element-4.0.0-beta.4.tgz#31bb5dff7430c4debc34030466bd8f3e297793a7" integrity sha512-dsu5qxk6MhQHxXUlPjul33JknQPx7Iv/N8zisH4JtV31qVk0qZg/5gn10Hr76GlMuixcdcxVvGHNfVcvbut13w== +linkify-react@4.0.0-beta.4: + version "4.0.0-beta.4" + resolved "https://registry.yarnpkg.com/linkify-react/-/linkify-react-4.0.0-beta.4.tgz#75311ade523a52d43054dd841d724d746d43f60d" + integrity sha512-o4vFe28vtk6i8a6tbtkLyusIyhLJSYoHC3gEpmJEVqi6Hy3aguVEenYmtaOjmAQehDrBYeHv9s4qcneZOf7SWQ== + linkify-string@4.0.0-beta.4: version "4.0.0-beta.4" resolved "https://registry.yarnpkg.com/linkify-string/-/linkify-string-4.0.0-beta.4.tgz#0982509bc6ce81c554bff8d7121057193b84ea32" From b7cd28bd2983d50ab140084a196f4d1ad9dcd928 Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Fri, 3 Feb 2023 09:14:44 +0000 Subject: [PATCH 16/20] Support MSC3946 in RoomListStore (#10054) --- src/stores/room-list/RoomListStore.ts | 8 +-- test/stores/room-list/RoomListStore-test.ts | 56 +++++++++++++++++++-- 2 files changed, 56 insertions(+), 8 deletions(-) diff --git a/src/stores/room-list/RoomListStore.ts b/src/stores/room-list/RoomListStore.ts index 39d66aa9bf11..09d9cca2e4e8 100644 --- a/src/stores/room-list/RoomListStore.ts +++ b/src/stores/room-list/RoomListStore.ts @@ -286,9 +286,9 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements // If we're joining an upgraded room, we'll want to make sure we don't proliferate // the dead room in the list. const roomState: RoomState = membershipPayload.room.currentState; - const createEvent = roomState.getStateEvents(EventType.RoomCreate, ""); - if (createEvent && createEvent.getContent()["predecessor"]) { - const prevRoom = this.matrixClient.getRoom(createEvent.getContent()["predecessor"]["room_id"]); + const predecessor = roomState.findPredecessor(SettingsStore.getValue("feature_dynamic_room_predecessors")); + if (predecessor) { + const prevRoom = this.matrixClient.getRoom(predecessor.roomId); if (prevRoom) { const isSticky = this.algorithm.stickyRoom === prevRoom; if (isSticky) { @@ -298,6 +298,8 @@ export class RoomListStoreClass extends AsyncStoreWithClient implements // Note: we hit the algorithm instead of our handleRoomUpdate() function to // avoid redundant updates. this.algorithm.handleRoomUpdate(prevRoom, RoomUpdateCause.RoomRemoved); + } else { + logger.warn(`Unable to find predecessor room with id ${predecessor.roomId}`); } } diff --git a/test/stores/room-list/RoomListStore-test.ts b/test/stores/room-list/RoomListStore-test.ts index 7ceb6393a277..8ce3dff22a3e 100644 --- a/test/stores/room-list/RoomListStore-test.ts +++ b/test/stores/room-list/RoomListStore-test.ts @@ -17,6 +17,7 @@ limitations under the License. import { EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; import { MatrixDispatcher } from "../../../src/dispatcher/dispatcher"; +import SettingsStore from "../../../src/settings/SettingsStore"; import { ListAlgorithm, SortAlgorithm } from "../../../src/stores/room-list/algorithms/models"; import { OrderedDefaultTagIDs, RoomUpdateCause } from "../../../src/stores/room-list/models"; import RoomListStore, { RoomListStoreClass } from "../../../src/stores/room-list/RoomListStore"; @@ -24,14 +25,14 @@ import { stubClient, upsertRoomStateEvents } from "../../test-utils"; describe("RoomListStore", () => { const client = stubClient(); - const roomWithCreatePredecessorId = "!roomid:example.com"; + const newRoomId = "!roomid:example.com"; const roomNoPredecessorId = "!roomnopreid:example.com"; const oldRoomId = "!oldroomid:example.com"; const userId = "@user:example.com"; const createWithPredecessor = new MatrixEvent({ type: EventType.RoomCreate, sender: userId, - room_id: roomWithCreatePredecessorId, + room_id: newRoomId, content: { predecessor: { room_id: oldRoomId, event_id: "tombstone_event_id" }, }, @@ -41,19 +42,32 @@ describe("RoomListStore", () => { const createNoPredecessor = new MatrixEvent({ type: EventType.RoomCreate, sender: userId, - room_id: roomWithCreatePredecessorId, + room_id: newRoomId, content: {}, event_id: "$create", state_key: "", }); - const roomWithCreatePredecessor = new Room(roomWithCreatePredecessorId, client, userId, {}); + const predecessor = new MatrixEvent({ + type: EventType.RoomPredecessor, + sender: userId, + room_id: newRoomId, + content: { + predecessor_room_id: oldRoomId, + last_known_event_id: "tombstone_event_id", + }, + event_id: "$pred", + state_key: "", + }); + const roomWithPredecessorEvent = new Room(newRoomId, client, userId, {}); + upsertRoomStateEvents(roomWithPredecessorEvent, [predecessor]); + const roomWithCreatePredecessor = new Room(newRoomId, client, userId, {}); upsertRoomStateEvents(roomWithCreatePredecessor, [createWithPredecessor]); const roomNoPredecessor = new Room(roomNoPredecessorId, client, userId, {}); upsertRoomStateEvents(roomNoPredecessor, [createNoPredecessor]); const oldRoom = new Room(oldRoomId, client, userId, {}); client.getRoom = jest.fn().mockImplementation((roomId) => { switch (roomId) { - case roomWithCreatePredecessorId: + case newRoomId: return roomWithCreatePredecessor; case oldRoomId: return oldRoom; @@ -123,4 +137,36 @@ describe("RoomListStore", () => { // And no other updates happen expect(handleRoomUpdate).toHaveBeenCalledTimes(1); }); + + describe("When feature_dynamic_room_predecessors = true", () => { + beforeEach(() => { + jest.spyOn(SettingsStore, "getValue").mockImplementation( + (settingName) => settingName === "feature_dynamic_room_predecessors", + ); + }); + + afterEach(() => { + jest.spyOn(SettingsStore, "getValue").mockReset(); + }); + + it("Removes old room if it finds a predecessor in the m.predecessor event", () => { + // Given a store we can spy on + const { store, handleRoomUpdate } = createStore(); + + // When we tell it we joined a new room that has an old room as + // predecessor in the create event + const payload = { + oldMembership: "invite", + membership: "join", + room: roomWithPredecessorEvent, + }; + store.onDispatchMyMembership(payload); + + // Then the old room is removed + expect(handleRoomUpdate).toHaveBeenCalledWith(oldRoom, RoomUpdateCause.RoomRemoved); + + // And the new room is added + expect(handleRoomUpdate).toHaveBeenCalledWith(roomWithPredecessorEvent, RoomUpdateCause.NewRoom); + }); + }); }); From 469228f45e80ecef612fea5ffc62da58c671fd55 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Fri, 3 Feb 2023 10:48:12 +0100 Subject: [PATCH 17/20] Fix new line created when enter is pressed (#10064) --- .../hooks/useInputEventProcessor.ts | 6 ++++-- .../components/WysiwygComposer-test.tsx | 20 ++++++++++++++++++- 2 files changed, 23 insertions(+), 3 deletions(-) diff --git a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts index 52d5f19e33d4..405539fc7093 100644 --- a/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts +++ b/src/components/views/rooms/wysiwyg_composer/hooks/useInputEventProcessor.ts @@ -168,13 +168,15 @@ function handleInputEvent(event: InputEvent, send: Send, isCtrlEnterToSend: bool case "insertParagraph": if (!isCtrlEnterToSend) { send(); + return null; } - return null; + break; case "sendMessage": if (isCtrlEnterToSend) { send(); + return null; } - return null; + break; } return event; diff --git a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx index 44f2a5a996ce..4d485b5a3faa 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/WysiwygComposer-test.tsx @@ -149,8 +149,10 @@ describe("WysiwygComposer", () => { it("Should not call onSend when Enter is pressed", async () => { // When + const textbox = screen.getByRole("textbox"); + fireEvent( - screen.getByRole("textbox"), + textbox, new InputEvent("input", { inputType: "insertParagraph", }), @@ -158,6 +160,22 @@ describe("WysiwygComposer", () => { // Then it does not send a message await waitFor(() => expect(onSend).toBeCalledTimes(0)); + + fireEvent( + textbox, + new InputEvent("input", { + inputType: "insertText", + data: "other", + }), + ); + + // The focus is on the last text node + await waitFor(() => { + const selection = document.getSelection(); + if (selection) { + expect(selection.focusNode?.textContent).toEqual("other"); + } + }); }); it("Should send a message when Ctrl+Enter is pressed", async () => { From 6dd578e5a7cad0abe2fadcdfcd075b4fe6d3f621 Mon Sep 17 00:00:00 2001 From: Germain Date: Fri, 3 Feb 2023 10:07:24 +0000 Subject: [PATCH 18/20] Devtools for stuck notifications (#10042) --- .../views/dialogs/DevtoolsDialog.tsx | 4 +- .../dialogs/devtools/RoomNotifications.tsx | 180 ++++++++++++++++++ .../dialogs/devtools/VerificationExplorer.tsx | 4 +- src/i18n/strings/en_EN.json | 29 ++- src/stores/notifications/NotificationColor.ts | 19 ++ .../DevtoolsDialog-test.tsx.snap | 5 + .../devtools/RoomNotifications-test.tsx | 50 +++++ .../RoomNotifications-test.tsx.snap | 62 ++++++ .../notifications/NotificationColor-test.ts | 31 +++ 9 files changed, 378 insertions(+), 6 deletions(-) create mode 100644 src/components/views/dialogs/devtools/RoomNotifications.tsx create mode 100644 test/components/views/dialogs/devtools/RoomNotifications-test.tsx create mode 100644 test/components/views/dialogs/devtools/__snapshots__/RoomNotifications-test.tsx.snap create mode 100644 test/stores/notifications/NotificationColor-test.ts diff --git a/src/components/views/dialogs/DevtoolsDialog.tsx b/src/components/views/dialogs/DevtoolsDialog.tsx index f16dd94f6ba3..0df6ce420652 100644 --- a/src/components/views/dialogs/DevtoolsDialog.tsx +++ b/src/components/views/dialogs/DevtoolsDialog.tsx @@ -33,6 +33,7 @@ import { SettingLevel } from "../../../settings/SettingLevel"; import ServerInfo from "./devtools/ServerInfo"; import { Features } from "../../../settings/Settings"; import CopyableText from "../elements/CopyableText"; +import RoomNotifications from "./devtools/RoomNotifications"; enum Category { Room, @@ -44,13 +45,14 @@ const categoryLabels: Record = { [Category.Other]: _td("Other"), }; -export type Tool = React.FC; +export type Tool = React.FC | ((props: IDevtoolsProps) => JSX.Element); const Tools: Record = { [Category.Room]: [ [_td("Send custom timeline event"), TimelineEventEditor], [_td("Explore room state"), RoomStateExplorer], [_td("Explore room account data"), RoomAccountDataExplorer], [_td("View servers in room"), ServersInRoom], + [_td("Notifications debug"), RoomNotifications], [_td("Verification explorer"), VerificationExplorer], [_td("Active Widgets"), WidgetExplorer], ], diff --git a/src/components/views/dialogs/devtools/RoomNotifications.tsx b/src/components/views/dialogs/devtools/RoomNotifications.tsx new file mode 100644 index 000000000000..7ddfb5d8baa6 --- /dev/null +++ b/src/components/views/dialogs/devtools/RoomNotifications.tsx @@ -0,0 +1,180 @@ +/* +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 { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { Thread } from "matrix-js-sdk/src/models/thread"; +import React, { useContext } from "react"; + +import MatrixClientContext from "../../../../contexts/MatrixClientContext"; +import { useNotificationState } from "../../../../hooks/useRoomNotificationState"; +import { _t } from "../../../../languageHandler"; +import { determineUnreadState } from "../../../../RoomNotifs"; +import { humanReadableNotificationColor } from "../../../../stores/notifications/NotificationColor"; +import { doesRoomOrThreadHaveUnreadMessages } from "../../../../Unread"; +import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; + +export default function RoomNotifications({ onBack }: IDevtoolsProps): JSX.Element { + const { room } = useContext(DevtoolsContext); + const cli = useContext(MatrixClientContext); + + const { color, count } = determineUnreadState(room); + const [notificationState] = useNotificationState(room); + + return ( + +
    +

    {_t("Room status")}

    +
      +
    • + {_t("Room unread status: ")} + {humanReadableNotificationColor(color)} + {count > 0 && ( + <> + {_t(", count:")} {count} + + )} +
    • +
    • + {_t("Notification state is")} {notificationState} +
    • +
    • + {_t("Room is ")} + + {cli.isRoomEncrypted(room.roomId!) ? _t("encrypted ✅") : _t("not encrypted 🚨")} + +
    • +
    +
    + +
    +

    {_t("Main timeline")}

    + +
      +
    • + {_t("Total: ")} {room.getRoomUnreadNotificationCount(NotificationCountType.Total)} +
    • +
    • + {_t("Highlight: ")} {room.getRoomUnreadNotificationCount(NotificationCountType.Highlight)} +
    • +
    • + {_t("Dot: ")} {doesRoomOrThreadHaveUnreadMessages(room) + ""} +
    • + {roomHasUnread(room) && ( + <> +
    • + {_t("User read up to: ")} + + {room.getReadReceiptForUserId(cli.getSafeUserId())?.eventId ?? + _t("No receipt found")} + +
    • +
    • + {_t("Last event:")} +
        +
      • + {_t("ID: ")} {room.timeline[room.timeline.length - 1].getId()} +
      • +
      • + {_t("Type: ")}{" "} + {room.timeline[room.timeline.length - 1].getType()} +
      • +
      • + {_t("Sender: ")}{" "} + {room.timeline[room.timeline.length - 1].getSender()} +
      • +
      +
    • + + )} +
    +
    + +
    +

    {_t("Threads timeline")}

    +
      + {room + .getThreads() + .filter((thread) => threadHasUnread(thread)) + .map((thread) => ( +
    • + {_t("Thread Id: ")} {thread.id} +
        +
      • + {_t("Total: ")} + + {room.getThreadUnreadNotificationCount( + thread.id, + NotificationCountType.Total, + )} + +
      • +
      • + {_t("Highlight: ")} + + {room.getThreadUnreadNotificationCount( + thread.id, + NotificationCountType.Highlight, + )} + +
      • +
      • + {_t("Dot: ")} {doesRoomOrThreadHaveUnreadMessages(thread) + ""} +
      • +
      • + {_t("User read up to: ")} + + {thread.getReadReceiptForUserId(cli.getSafeUserId())?.eventId ?? + _t("No receipt found")} + +
      • +
      • + {_t("Last event:")} +
          +
        • + {_t("ID: ")} {thread.lastReply()?.getId()} +
        • +
        • + {_t("Type: ")} {thread.lastReply()?.getType()} +
        • +
        • + {_t("Sender: ")} {thread.lastReply()?.getSender()} +
        • +
        +
      • +
      +
    • + ))} +
    +
    +
    + ); +} + +function threadHasUnread(thread: Thread): boolean { + const total = thread.room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Total); + const highlight = thread.room.getThreadUnreadNotificationCount(thread.id, NotificationCountType.Highlight); + const dot = doesRoomOrThreadHaveUnreadMessages(thread); + + return total > 0 || highlight > 0 || dot; +} + +function roomHasUnread(room: Room): boolean { + const total = room.getRoomUnreadNotificationCount(NotificationCountType.Total); + const highlight = room.getRoomUnreadNotificationCount(NotificationCountType.Highlight); + const dot = doesRoomOrThreadHaveUnreadMessages(room); + + return total > 0 || highlight > 0 || dot; +} diff --git a/src/components/views/dialogs/devtools/VerificationExplorer.tsx b/src/components/views/dialogs/devtools/VerificationExplorer.tsx index 7092d87f6459..c535d32b327c 100644 --- a/src/components/views/dialogs/devtools/VerificationExplorer.tsx +++ b/src/components/views/dialogs/devtools/VerificationExplorer.tsx @@ -25,7 +25,7 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto"; import { useTypedEventEmitter, useTypedEventEmitterState } from "../../../../hooks/useEventEmitter"; import { _t, _td } from "../../../../languageHandler"; import MatrixClientContext from "../../../../contexts/MatrixClientContext"; -import BaseTool, { DevtoolsContext } from "./BaseTool"; +import BaseTool, { DevtoolsContext, IDevtoolsProps } from "./BaseTool"; import { Tool } from "../DevtoolsDialog"; const PHASE_MAP: Record = { @@ -81,7 +81,7 @@ const VerificationRequestExplorer: React.FC<{ ); }; -const VerificationExplorer: Tool = ({ onBack }) => { +const VerificationExplorer: Tool = ({ onBack }: IDevtoolsProps) => { const cli = useContext(MatrixClientContext); const context = useContext(DevtoolsContext); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index f61c21b028f1..c1aeeab2de5a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -902,6 +902,12 @@ "Room information": "Room information", "Room members": "Room members", "Back to thread": "Back to thread", + "None": "None", + "Bold": "Bold", + "Grey": "Grey", + "Red": "Red", + "Unsent": "Unsent", + "unknown": "unknown", "Change notification settings": "Change notification settings", "Messaging": "Messaging", "Profile": "Profile", @@ -1582,7 +1588,6 @@ "Error removing ignored user/server": "Error removing ignored user/server", "Error unsubscribing from list": "Error unsubscribing from list", "Please try again or view your console for hints.": "Please try again or view your console for hints.", - "None": "None", "Ban list rules - %(roomName)s": "Ban list rules - %(roomName)s", "Server rules": "Server rules", "User rules": "User rules", @@ -1942,7 +1947,6 @@ "Poll": "Poll", "Hide formatting": "Hide formatting", "Show formatting": "Show formatting", - "Bold": "Bold", "Italics": "Italics", "Strikethrough": "Strikethrough", "Code block": "Code block", @@ -2773,6 +2777,7 @@ "Explore room state": "Explore room state", "Explore room account data": "Explore room account data", "View servers in room": "View servers in room", + "Notifications debug": "Notifications debug", "Verification explorer": "Verification explorer", "Active Widgets": "Active Widgets", "Explore account data": "Explore account data", @@ -3152,6 +3157,25 @@ "Event Content": "Event Content", "Filter results": "Filter results", "No results found": "No results found", + "Room status": "Room status", + "Room unread status: ": "Room unread status: ", + ", count:": ", count:", + "Notification state is": "Notification state is", + "Room is ": "Room is ", + "encrypted ✅": "encrypted ✅", + "not encrypted 🚨": "not encrypted 🚨", + "Main timeline": "Main timeline", + "Total: ": "Total: ", + "Highlight: ": "Highlight: ", + "Dot: ": "Dot: ", + "User read up to: ": "User read up to: ", + "No receipt found": "No receipt found", + "Last event:": "Last event:", + "ID: ": "ID: ", + "Type: ": "Type: ", + "Sender: ": "Sender: ", + "Threads timeline": "Threads timeline", + "Thread Id: ": "Thread Id: ", "<%(count)s spaces>|other": "<%(count)s spaces>", "<%(count)s spaces>|one": "", "<%(count)s spaces>|zero": "", @@ -3182,7 +3206,6 @@ "Value": "Value", "Value in this room": "Value in this room", "Edit setting": "Edit setting", - "Unsent": "Unsent", "Requested": "Requested", "Ready": "Ready", "Started": "Started", diff --git a/src/stores/notifications/NotificationColor.ts b/src/stores/notifications/NotificationColor.ts index 58737866df5c..f89bb1728d13 100644 --- a/src/stores/notifications/NotificationColor.ts +++ b/src/stores/notifications/NotificationColor.ts @@ -14,6 +14,8 @@ See the License for the specific language governing permissions and limitations under the License. */ +import { _t } from "../../languageHandler"; + export enum NotificationColor { // Inverted (None -> Red) because we do integer comparisons on this None, // nothing special @@ -23,3 +25,20 @@ export enum NotificationColor { Red, // unread pings Unsent, // some messages failed to send } + +export function humanReadableNotificationColor(color: NotificationColor): string { + switch (color) { + case NotificationColor.None: + return _t("None"); + case NotificationColor.Bold: + return _t("Bold"); + case NotificationColor.Grey: + return _t("Grey"); + case NotificationColor.Red: + return _t("Red"); + case NotificationColor.Unsent: + return _t("Unsent"); + default: + return _t("unknown"); + } +} diff --git a/test/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap index 62014e2d2b60..5e859a6c6305 100644 --- a/test/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap +++ b/test/components/views/dialogs/__snapshots__/DevtoolsDialog-test.tsx.snap @@ -75,6 +75,11 @@ exports[`DevtoolsDialog renders the devtools dialog 1`] = ` > View servers in room + + + +`; diff --git a/test/stores/notifications/NotificationColor-test.ts b/test/stores/notifications/NotificationColor-test.ts new file mode 100644 index 000000000000..1125c47bff87 --- /dev/null +++ b/test/stores/notifications/NotificationColor-test.ts @@ -0,0 +1,31 @@ +/* +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 { humanReadableNotificationColor, NotificationColor } from "../../../src/stores/notifications/NotificationColor"; + +describe("NotificationColor", () => { + describe("humanReadableNotificationColor", () => { + it.each([ + [NotificationColor.None, "None"], + [NotificationColor.Bold, "Bold"], + [NotificationColor.Grey, "Grey"], + [NotificationColor.Red, "Red"], + [NotificationColor.Unsent, "Unsent"], + ])("correctly maps the output", (color, output) => { + expect(humanReadableNotificationColor(color)).toBe(output); + }); + }); +}); From 3b5a50e26eb6ff172f9eef7f8ba171437bb8c96a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Thu, 2 Feb 2023 18:17:08 +0000 Subject: [PATCH 19/20] Enable Sign in with QR section in Settings where the Homeserver supports it --- .../views/settings/devices/LoginWithQRSection.tsx | 7 ++----- .../settings/tabs/user/SecurityUserSettingsTab.tsx | 7 ++----- .../views/settings/tabs/user/SessionManagerTab.tsx | 7 ++----- src/settings/Settings.tsx | 10 ---------- .../settings/devices/LoginWithQRSection-test.tsx | 14 ++------------ .../__snapshots__/LoginWithQRSection-test.tsx.snap | 6 ++---- .../tabs/user/SecurityUserSettingsTab-test.tsx | 13 +------------ .../settings/tabs/user/SessionManagerTab-test.tsx | 13 +------------ 8 files changed, 12 insertions(+), 65 deletions(-) diff --git a/src/components/views/settings/devices/LoginWithQRSection.tsx b/src/components/views/settings/devices/LoginWithQRSection.tsx index a07a7a9722cf..0faa3913c807 100644 --- a/src/components/views/settings/devices/LoginWithQRSection.tsx +++ b/src/components/views/settings/devices/LoginWithQRSection.tsx @@ -20,7 +20,6 @@ import type { IServerVersions } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../languageHandler"; import AccessibleButton from "../../elements/AccessibleButton"; import SettingsSubsection from "../shared/SettingsSubsection"; -import SettingsStore from "../../../../settings/SettingsStore"; interface IProps { onShowQr: () => void; @@ -33,12 +32,10 @@ export default class LoginWithQRSection extends React.Component { } public render(): JSX.Element | null { + // Needs server support for MSC3882 and MSC3886: const msc3882Supported = !!this.props.versions?.unstable_features?.["org.matrix.msc3882"]; const msc3886Supported = !!this.props.versions?.unstable_features?.["org.matrix.msc3886"]; - - // Needs to be enabled as a feature + server support MSC3886 or have a default rendezvous server configured: - const offerShowQr = - SettingsStore.getValue("feature_qr_signin_reciprocate_show") && msc3882Supported && msc3886Supported; // We don't support configuration of a fallback at the moment so we just check the MSCs + const offerShowQr = msc3882Supported && msc3886Supported; // don't show anything if no method is available if (!offerShowQr) { diff --git a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx index 550b395da5b7..db96b6598e7a 100644 --- a/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/SecurityUserSettingsTab.tsx @@ -381,7 +381,6 @@ export default class SecurityUserSettingsTab extends React.Component
    {_t("Where you're signed in")}
    @@ -394,15 +393,13 @@ export default class SecurityUserSettingsTab extends React.Component - {showQrCodeEnabled ? ( - - ) : null} + ); const client = MatrixClientPeg.get(); - if (showQrCodeEnabled && this.state.showLoginWithQR) { + if (this.state.showLoginWithQR) { return (
    { const [signInWithQrMode, setSignInWithQrMode] = useState(); - const showQrCodeEnabled = SettingsStore.getValue("feature_qr_signin_reciprocate_show"); - const onQrFinish = useCallback(() => { setSignInWithQrMode(null); }, [setSignInWithQrMode]); @@ -222,7 +219,7 @@ const SessionManagerTab: React.FC = () => { setSignInWithQrMode(Mode.Show); }, [setSignInWithQrMode]); - if (showQrCodeEnabled && signInWithQrMode) { + if (signInWithQrMode) { return ; } @@ -282,7 +279,7 @@ const SessionManagerTab: React.FC = () => { /> )} - {showQrCodeEnabled ? : null} + ); }; diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index fc4c772d69f7..a7f3a693f853 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -497,16 +497,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { ), }, }, - "feature_qr_signin_reciprocate_show": { - isFeature: true, - labsGroup: LabGroup.Experimental, - supportedLevels: LEVELS_FEATURE, - displayName: _td( - "Allow a QR code to be shown in session manager to sign in another device " + - "(requires compatible homeserver)", - ), - default: false, - }, "feature_rust_crypto": { // use the rust matrix-sdk-crypto-js for crypto. isFeature: true, diff --git a/test/components/views/settings/devices/LoginWithQRSection-test.tsx b/test/components/views/settings/devices/LoginWithQRSection-test.tsx index df71544b321b..2f272acdec56 100644 --- a/test/components/views/settings/devices/LoginWithQRSection-test.tsx +++ b/test/components/views/settings/devices/LoginWithQRSection-test.tsx @@ -21,8 +21,6 @@ import React from "react"; import LoginWithQRSection from "../../../../../src/components/views/settings/devices/LoginWithQRSection"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; -import { SettingLevel } from "../../../../../src/settings/SettingLevel"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; function makeClient() { return mocked({ @@ -67,22 +65,14 @@ describe("", () => { expect(container).toMatchSnapshot(); }); - it("feature enabled", async () => { - await SettingsStore.setValue("feature_qr_signin_reciprocate_show", null, SettingLevel.DEVICE, true); - const { container } = render(getComponent()); - expect(container).toMatchSnapshot(); - }); - - it("only feature + MSC3882 enabled", async () => { - await SettingsStore.setValue("feature_qr_signin_reciprocate_show", null, SettingLevel.DEVICE, true); + it("only MSC3882 enabled", async () => { const { container } = render(getComponent({ versions: makeVersions({ "org.matrix.msc3882": true }) })); expect(container).toMatchSnapshot(); }); }); describe("should render panel", () => { - it("enabled by feature + MSC3882 + MSC3886", async () => { - await SettingsStore.setValue("feature_qr_signin_reciprocate_show", null, SettingLevel.DEVICE, true); + it("MSC3882 + MSC3886", async () => { const { container } = render( getComponent({ versions: makeVersions({ diff --git a/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap b/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap index 2cf0d24cc6c9..ccf2e4ccb51a 100644 --- a/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap +++ b/test/components/views/settings/devices/__snapshots__/LoginWithQRSection-test.tsx.snap @@ -1,12 +1,10 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` should not render feature enabled 1`] = `
    `; - exports[` should not render no support at all 1`] = `
    `; -exports[` should not render only feature + MSC3882 enabled 1`] = `
    `; +exports[` should not render only MSC3882 enabled 1`] = `
    `; -exports[` should render panel enabled by feature + MSC3882 + MSC3886 1`] = ` +exports[` should render panel MSC3882 + MSC3886 1`] = `
    ", () => { expect(queryByTestId("devices-section")).toBeFalsy(); }); - it("does not render qr code login section when disabled", () => { - settingsValueSpy.mockReturnValue(false); - const { queryByText } = render(getComponent()); - - expect(settingsValueSpy).toHaveBeenCalledWith("feature_qr_signin_reciprocate_show"); - - expect(queryByText("Sign in with QR code")).toBeFalsy(); - }); - - it("renders qr code login section when enabled", async () => { - settingsValueSpy.mockImplementation((settingName) => settingName === "feature_qr_signin_reciprocate_show"); + it("renders qr code login section", async () => { const { getByText } = render(getComponent()); // wait for versions call to settle @@ -99,7 +89,6 @@ describe("", () => { }); it("enters qr code login section when show QR code button clicked", async () => { - settingsValueSpy.mockImplementation((settingName) => settingName === "feature_qr_signin_reciprocate_show"); const { getByText, getByTestId } = render(getComponent()); // wait for versions call to settle await flushPromises(); diff --git a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx index 95ec76129b7d..dde9c50799d5 100644 --- a/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx +++ b/test/components/views/settings/tabs/user/SessionManagerTab-test.tsx @@ -1351,17 +1351,7 @@ describe("", () => { }); }); - it("does not render qr code login section when disabled", () => { - settingsValueSpy.mockReturnValue(false); - const { queryByText } = render(getComponent()); - - expect(settingsValueSpy).toHaveBeenCalledWith("feature_qr_signin_reciprocate_show"); - - expect(queryByText("Sign in with QR code")).toBeFalsy(); - }); - - it("renders qr code login section when enabled", async () => { - settingsValueSpy.mockImplementation((settingName) => settingName === "feature_qr_signin_reciprocate_show"); + it("renders qr code login section", async () => { const { getByText } = render(getComponent()); // wait for versions call to settle @@ -1371,7 +1361,6 @@ describe("", () => { }); it("enters qr code login section when show QR code button clicked", async () => { - settingsValueSpy.mockImplementation((settingName) => settingName === "feature_qr_signin_reciprocate_show"); const { getByText, getByTestId } = render(getComponent()); // wait for versions call to settle await flushPromises(); From 9b12f9d5b795a0c0b494c0011370487ac486525a Mon Sep 17 00:00:00 2001 From: Hugh Nimmo-Smith Date: Fri, 3 Feb 2023 09:31:08 +0000 Subject: [PATCH 20/20] i18n fix --- src/i18n/strings/en_EN.json | 1 - 1 file changed, 1 deletion(-) diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index c1aeeab2de5a..f4fd3451e93c 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -973,7 +973,6 @@ "New session manager": "New session manager", "Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.", "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.", - "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)": "Allow a QR code to be shown in session manager to sign in another device (requires compatible homeserver)", "Rust cryptography implementation": "Rust cryptography implementation", "Under active development. Can currently only be enabled via config.json": "Under active development. Can currently only be enabled via config.json", "Font size": "Font size",