From 671fdc8665a85ec9361733b6b5af0a1f2da03749 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 10 Feb 2023 08:40:38 +0100 Subject: [PATCH 01/17] Migrate RoomView to support MSC3946 (#10088) --- src/components/structures/RoomView.tsx | 11 +++-- src/contexts/RoomContext.ts | 1 + test/components/structures/RoomView-test.tsx | 43 ++++++++++++++++++- .../views/rooms/SendMessageComposer-test.tsx | 1 + test/test-utils/room.ts | 1 + 5 files changed, 52 insertions(+), 5 deletions(-) diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 8d85b54df7..221d2151ef 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -223,6 +223,7 @@ export interface IRoomState { narrow: boolean; // List of undecryptable events currently visible on-screen visibleDecryptionFailures?: MatrixEvent[]; + msc3946ProcessDynamicPredecessor: boolean; } interface LocalRoomViewProps { @@ -416,6 +417,7 @@ export class RoomView extends React.Component { liveTimeline: undefined, narrow: false, visibleDecryptionFailures: [], + msc3946ProcessDynamicPredecessor: SettingsStore.getValue("feature_dynamic_room_predecessors"), }; this.dispatcherRef = dis.register(this.onAction); @@ -467,6 +469,9 @@ export class RoomView extends React.Component { ), SettingsStore.watchSetting("urlPreviewsEnabled", null, this.onUrlPreviewsEnabledChange), SettingsStore.watchSetting("urlPreviewsEnabled_e2ee", null, this.onUrlPreviewsEnabledChange), + SettingsStore.watchSetting("feature_dynamic_room_predecessors", null, (...[, , , value]) => + this.setState({ msc3946ProcessDynamicPredecessor: value as boolean }), + ), ]; } @@ -1798,10 +1803,8 @@ export class RoomView extends React.Component { }; private getOldRoom(): Room | null { - const createEvent = this.state.room.currentState.getStateEvents(EventType.RoomCreate, ""); - if (!createEvent || !createEvent.getContent()["predecessor"]) return null; - - return this.context.client.getRoom(createEvent.getContent()["predecessor"]["room_id"]); + const { roomId } = this.state.room?.findPredecessor(this.state.msc3946ProcessDynamicPredecessor) || {}; + return this.context.client?.getRoom(roomId) || null; } public getHiddenHighlightCount(): number { diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index f599020522..9d3eeff564 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -65,6 +65,7 @@ const RoomContext = createContext({ liveTimeline: undefined, narrow: false, activeCall: null, + msc3946ProcessDynamicPredecessor: false, }); RoomContext.displayName = "RoomContext"; export default RoomContext; diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index b740ffe5a3..e9860f5bba 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -55,6 +55,7 @@ const RoomView = wrapInMatrixClientContext(_RoomView); describe("RoomView", () => { let cli: MockedObject; let room: Room; + let rooms: Map; let roomCount = 0; let stores: SdkContextClass; @@ -64,8 +65,11 @@ describe("RoomView", () => { cli = mocked(MatrixClientPeg.get()); room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); + jest.spyOn(room, "findPredecessor"); room.getPendingEvents = () => []; - cli.getRoom.mockImplementation(() => room); + rooms = new Map(); + rooms.set(room.roomId, room); + cli.getRoom.mockImplementation((roomId: string | undefined) => rooms.get(roomId || "") || null); // Re-emit certain events on the mocked client room.on(RoomEvent.Timeline, (...args) => cli.emit(RoomEvent.Timeline, ...args)); room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); @@ -158,6 +162,42 @@ describe("RoomView", () => { const getRoomViewInstance = async (): Promise<_RoomView> => (await mountRoomView()).find(_RoomView).instance() as _RoomView; + it("when there is no room predecessor, getHiddenHighlightCount should return 0", async () => { + const instance = await getRoomViewInstance(); + expect(instance.getHiddenHighlightCount()).toBe(0); + }); + + describe("when there is an old room", () => { + let instance: _RoomView; + let oldRoom: Room; + + beforeEach(async () => { + instance = await getRoomViewInstance(); + oldRoom = new Room("!old:example.com", cli, cli.getSafeUserId()); + rooms.set(oldRoom.roomId, oldRoom); + jest.spyOn(room, "findPredecessor").mockReturnValue({ roomId: oldRoom.roomId, eventId: null }); + }); + + it("and it has 0 unreads, getHiddenHighlightCount should return 0", async () => { + jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(0); + expect(instance.getHiddenHighlightCount()).toBe(0); + // assert that msc3946ProcessDynamicPredecessor is false by default + expect(room.findPredecessor).toHaveBeenCalledWith(false); + }); + + it("and it has 23 unreads, getHiddenHighlightCount should return 23", async () => { + jest.spyOn(oldRoom, "getUnreadNotificationCount").mockReturnValue(23); + expect(instance.getHiddenHighlightCount()).toBe(23); + }); + + it("and feature_dynamic_room_predecessors is enabled it should pass the setting to findPredecessor", async () => { + SettingsStore.setValue("feature_dynamic_room_predecessors", null, SettingLevel.DEVICE, true); + expect(instance.getHiddenHighlightCount()).toBe(0); + expect(room.findPredecessor).toHaveBeenCalledWith(true); + SettingsStore.setValue("feature_dynamic_room_predecessors", null, SettingLevel.DEVICE, null); + }); + }); + it("updates url preview visibility on encryption state change", async () => { // we should be starting unencrypted expect(cli.isCryptoEnabled()).toEqual(false); @@ -248,6 +288,7 @@ describe("RoomView", () => { beforeEach(async () => { localRoom = room = await createDmLocalRoom(cli, [new DirectoryMember({ user_id: "@user:example.com" })]); + rooms.set(localRoom.roomId, localRoom); cli.store.storeRoom(room); }); diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index 1005758ae9..7dcc89be1e 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -78,6 +78,7 @@ describe("", () => { resizing: false, narrow: false, activeCall: null, + msc3946ProcessDynamicPredecessor: false, }; describe("createMessageContent", () => { const permalinkCreator = jest.fn() as any; diff --git a/test/test-utils/room.ts b/test/test-utils/room.ts index d20200bef1..af2487a604 100644 --- a/test/test-utils/room.ts +++ b/test/test-utils/room.ts @@ -86,6 +86,7 @@ export function getRoomContext(room: Room, override: Partial): IRoom resizing: false, narrow: false, activeCall: null, + msc3946ProcessDynamicPredecessor: false, ...override, }; From fc7d0eeaef6872e448149dd2d6955714297c19e1 Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Fri, 10 Feb 2023 08:38:22 +0000 Subject: [PATCH 02/17] Remove a redundant white space (#10129) --- src/components/views/settings/SecureBackupPanel.tsx | 2 +- src/i18n/strings/en_EN.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/views/settings/SecureBackupPanel.tsx b/src/components/views/settings/SecureBackupPanel.tsx index 0bde8bd1f8..6fcb41b315 100644 --- a/src/components/views/settings/SecureBackupPanel.tsx +++ b/src/components/views/settings/SecureBackupPanel.tsx @@ -242,7 +242,7 @@ export default class SecureBackupPanel extends React.PureComponent<{}, IState> { let restoreButtonCaption = _t("Restore from Backup"); if (MatrixClientPeg.get().getKeyBackupEnabled()) { - statusDescription =

✅ {_t("This session is backing up your keys. ")}

; + statusDescription =

✅ {_t("This session is backing up your keys.")}

; } else { statusDescription = ( <> diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index be577c72f0..4500663a9a 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -1453,7 +1453,7 @@ "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.": "Are you sure? You will lose your encrypted messages if your keys are not backed up properly.", "Unable to load key backup status": "Unable to load key backup status", "Restore from Backup": "Restore from Backup", - "This session is backing up your keys. ": "This session is backing up your keys. ", + "This session is backing up your keys.": "This session is backing up your keys.", "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.": "This session is not backing up your keys, but you do have an existing backup you can restore from and add to going forward.", "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.": "Connect this session to key backup before signing out to avoid losing any keys that may only be on this session.", "Connect this session to Key Backup": "Connect this session to Key Backup", From f83492235ef1bef847b1f8c86e37c22d80ced0f1 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Feb 2023 11:47:38 +0100 Subject: [PATCH 03/17] chore(deps): update babel monorepo (#10102) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- yarn.lock | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/yarn.lock b/yarn.lock index bbf10bb70c..0579f14b41 100644 --- a/yarn.lock +++ b/yarn.lock @@ -111,9 +111,9 @@ eslint-rule-composer "^0.3.0" "@babel/generator@^7.20.7": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.7.tgz#f8ef57c8242665c5929fe2e8d82ba75460187b4a" - integrity sha512-7wqMOJq8doJMZmP4ApXTzLxSr7+oO2jroJURrVEp6XShrQUObV8Tq/D0NCcoYg2uHqUrjzO0zwBjoYzelxK+sw== + version "7.20.14" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.20.14.tgz#9fa772c9f86a46c6ac9b321039400712b96f64ce" + integrity sha512-AEmuXHdcD3A52HHXxaTmYlb8q/xMEhoRP67B3T4Oq7lbmSoqroMZzjnGj3+i1io3pdnF8iBYVu4Ilj+c4hBxYg== dependencies: "@babel/types" "^7.20.7" "@jridgewell/gen-mapping" "^0.3.2" @@ -363,10 +363,10 @@ chalk "^2.0.0" js-tokens "^4.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.5", "@babel/parser@^7.20.7": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.7.tgz#66fe23b3c8569220817d5feb8b9dcdc95bb4f71b" - integrity sha512-T3Z9oHybU+0vZlY9CiDSJQTD5ZapcW18ZctFMi0MOAl/4BjFF4ul7NVSARLdbGO5vDqy9eQiGTV0LtKfvCYvcg== +"@babel/parser@^7.1.0", "@babel/parser@^7.12.11", "@babel/parser@^7.14.7", "@babel/parser@^7.18.10", "@babel/parser@^7.18.5", "@babel/parser@^7.20.13", "@babel/parser@^7.20.7": + version "7.20.15" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.20.15.tgz#eec9f36d8eaf0948bb88c87a46784b5ee9fd0c89" + integrity sha512-DI4a1oZuf8wC+oAJA9RW6ga3Zbe8RZFt7kD9i4qAspz3I/yHet1VvC3DiSy/fsUvv5pvJuNPh0LPOdCcqinDPg== "@babel/plugin-bugfix-safari-id-destructuring-collision-in-function-expression@^7.18.6": version "7.18.6" @@ -1105,9 +1105,9 @@ regenerator-runtime "^0.13.11" "@babel/runtime@^7.0.0", "@babel/runtime@^7.10.2", "@babel/runtime@^7.12.13", "@babel/runtime@^7.12.5", "@babel/runtime@^7.15.4", "@babel/runtime@^7.17.9", "@babel/runtime@^7.18.9", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.4", "@babel/runtime@^7.8.7", "@babel/runtime@^7.9.2": - version "7.20.7" - resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.7.tgz#fcb41a5a70550e04a7b708037c7c32f7f356d8fd" - integrity sha512-UF0tvkUtxwAgZ5W/KrkHf0Rn0fdnLDU9ScxBrEVNUprE/MzirjK4MJUX1/BVDv00Sv8cljtukVK1aky++X1SjQ== + version "7.20.13" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.20.13.tgz#7055ab8a7cff2b8f6058bf6ae45ff84ad2aded4b" + integrity sha512-gt3PKXs0DBoL9xCvOIIZ2NEqAGZqHjAnmVbfQtB620V0uReIQutpel14KcneZuer7UioY8ALKZ7iocavvzTNFA== dependencies: regenerator-runtime "^0.13.11" @@ -1130,9 +1130,9 @@ "@babel/types" "^7.18.10" "@babel/traverse@^7.12.12", "@babel/traverse@^7.18.5", "@babel/traverse@^7.19.1", "@babel/traverse@^7.20.1", "@babel/traverse@^7.20.10", "@babel/traverse@^7.20.12", "@babel/traverse@^7.20.5", "@babel/traverse@^7.20.7", "@babel/traverse@^7.7.2": - version "7.20.12" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.12.tgz#7f0f787b3a67ca4475adef1f56cb94f6abd4a4b5" - integrity sha512-MsIbFN0u+raeja38qboyF8TIT7K0BFzz/Yd/77ta4MsUsmP2RAnidIlwq7d5HFQrH/OZJecGV6B71C4zAgpoSQ== + version "7.20.13" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.20.13.tgz#817c1ba13d11accca89478bd5481b2d168d07473" + integrity sha512-kMJXfF0T6DIS9E8cgdLCSAL+cuCK+YEZHWiLK0SXpTo8YRj5lpJu3CDNKiIBCne4m9hhTIqUg6SYTAI39tAiVQ== dependencies: "@babel/code-frame" "^7.18.6" "@babel/generator" "^7.20.7" @@ -1140,7 +1140,7 @@ "@babel/helper-function-name" "^7.19.0" "@babel/helper-hoist-variables" "^7.18.6" "@babel/helper-split-export-declaration" "^7.18.6" - "@babel/parser" "^7.20.7" + "@babel/parser" "^7.20.13" "@babel/types" "^7.20.7" debug "^4.1.0" globals "^11.1.0" From e810a20551bf9a04b15b949b8d41f6c204f05c9e Mon Sep 17 00:00:00 2001 From: akshattchhabra <72541276+akshattchhabra@users.noreply.github.com> Date: Fri, 10 Feb 2023 16:51:31 +0530 Subject: [PATCH 04/17] #23232 Issue: Update _RoomStatusBar.pcss (#10128) Co-authored-by: Janne Mareike Koschinski --- res/css/structures/_RoomStatusBar.pcss | 1 + 1 file changed, 1 insertion(+) diff --git a/res/css/structures/_RoomStatusBar.pcss b/res/css/structures/_RoomStatusBar.pcss index e8393ac0ce..d3e08adfd6 100644 --- a/res/css/structures/_RoomStatusBar.pcss +++ b/res/css/structures/_RoomStatusBar.pcss @@ -115,6 +115,7 @@ limitations under the License. padding-left: 30px; /* 18px for the icon, 2px margin to text, 10px regular padding */ display: inline-block; position: relative; + user-select: none; &:nth-child(2) { border-left: 1px solid $resend-button-divider-color; From bb4b07fdc9a686185ef68f60494152c203c983f5 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 10 Feb 2023 14:00:02 +0100 Subject: [PATCH 05/17] Migrate InteractiveAuthDialog-test.tsx to react-testing-library (#10134) --- .../dialogs/InteractiveAuthDialog-test.tsx | 37 ++++++++----------- 1 file changed, 15 insertions(+), 22 deletions(-) diff --git a/test/components/views/dialogs/InteractiveAuthDialog-test.tsx b/test/components/views/dialogs/InteractiveAuthDialog-test.tsx index ff154a88f2..3ce6ca89ca 100644 --- a/test/components/views/dialogs/InteractiveAuthDialog-test.tsx +++ b/test/components/views/dialogs/InteractiveAuthDialog-test.tsx @@ -16,9 +16,8 @@ limitations under the License. */ import React from "react"; -import { act } from "react-dom/test-utils"; -// eslint-disable-next-line deprecate/import -import { mount, ReactWrapper } from "enzyme"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import InteractiveAuthDialog from "../../../../src/components/views/dialogs/InteractiveAuthDialog"; import { flushPromises, getMockClientWithEventEmitter, unmockClientPeg } from "../../../test-utils"; @@ -33,7 +32,10 @@ describe("InteractiveAuthDialog", function () { makeRequest: jest.fn().mockResolvedValue(undefined), onFinished: jest.fn(), }; - const getComponent = (props = {}) => mount(); + + const renderComponent = (props = {}) => render(); + const getPasswordField = () => screen.getByLabelText("Password"); + const getSubmitButton = () => screen.getByRole("button", { name: "Continue" }); beforeEach(function () { jest.clearAllMocks(); @@ -44,8 +46,6 @@ describe("InteractiveAuthDialog", function () { unmockClientPeg(); }); - const getSubmitButton = (wrapper: ReactWrapper) => wrapper.find('[type="submit"]').at(0); - it("Should successfully complete a password flow", async () => { const onFinished = jest.fn(); const makeRequest = jest.fn().mockResolvedValue({ a: 1 }); @@ -56,31 +56,24 @@ describe("InteractiveAuthDialog", function () { flows: [{ stages: ["m.login.password"] }], }; - const wrapper = getComponent({ makeRequest, onFinished, authData }); + renderComponent({ makeRequest, onFinished, authData }); - const passwordNode = wrapper.find('input[type="password"]').at(0); - const submitNode = getSubmitButton(wrapper); + const passwordField = getPasswordField(); + const submitButton = getSubmitButton(); - const formNode = wrapper.find("form").at(0); - expect(passwordNode).toBeTruthy(); - expect(submitNode).toBeTruthy(); + expect(passwordField).toBeTruthy(); + expect(submitButton).toBeTruthy(); // submit should be disabled - expect(submitNode.props().disabled).toBe(true); + expect(submitButton).toBeDisabled(); // put something in the password box - act(() => { - passwordNode.simulate("change", { target: { value: "s3kr3t" } }); - wrapper.setProps({}); - }); + await userEvent.type(passwordField, "s3kr3t"); - expect(wrapper.find('input[type="password"]').at(0).props().value).toEqual("s3kr3t"); - expect(getSubmitButton(wrapper).props().disabled).toBe(false); + expect(submitButton).not.toBeDisabled(); // hit enter; that should trigger a request - act(() => { - formNode.simulate("submit"); - }); + await userEvent.click(submitButton); // wait for auth request to resolve await flushPromises(); From f14414eacd0a8224d2cbb7d0cfa9787a0d8961a9 Mon Sep 17 00:00:00 2001 From: Michael Weimann Date: Fri, 10 Feb 2023 15:16:08 +0100 Subject: [PATCH 06/17] Migrate OwnBeaconStatus-test.tsx to react-testing-library (#10133) --- .../views/beacon/OwnBeaconStatus-test.tsx | 76 ++++++++----------- .../OwnBeaconStatus-test.tsx.snap | 28 +++---- 2 files changed, 42 insertions(+), 62 deletions(-) diff --git a/test/components/views/beacon/OwnBeaconStatus-test.tsx b/test/components/views/beacon/OwnBeaconStatus-test.tsx index 5f73e511a6..019af5d664 100644 --- a/test/components/views/beacon/OwnBeaconStatus-test.tsx +++ b/test/components/views/beacon/OwnBeaconStatus-test.tsx @@ -15,16 +15,15 @@ limitations under the License. */ import React from "react"; -// eslint-disable-next-line deprecate/import -import { mount } from "enzyme"; -import { act } from "react-dom/test-utils"; import { mocked } from "jest-mock"; import { Beacon } from "matrix-js-sdk/src/matrix"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import OwnBeaconStatus from "../../../../src/components/views/beacon/OwnBeaconStatus"; import { BeaconDisplayStatus } from "../../../../src/components/views/beacon/displayStatus"; import { useOwnLiveBeacons } from "../../../../src/utils/beacon"; -import { findByTestId, makeBeaconInfoEvent } from "../../../test-utils"; +import { makeBeaconInfoEvent } from "../../../test-utils"; jest.mock("../../../../src/utils/beacon/useOwnLiveBeacons", () => ({ useOwnLiveBeacons: jest.fn(), @@ -36,8 +35,11 @@ describe("", () => { }; const userId = "@user:server"; const roomId = "!room:server"; - let defaultBeacon; - const getComponent = (props = {}) => mount(); + let defaultBeacon: Beacon; + const renderComponent = (props: Partial> = {}) => + render(); + const getRetryButton = () => screen.getByRole("button", { name: "Retry" }); + const getStopButton = () => screen.getByRole("button", { name: "Stop" }); beforeEach(() => { jest.spyOn(global.Date, "now").mockReturnValue(123456789); @@ -47,13 +49,8 @@ describe("", () => { }); it("renders without a beacon instance", () => { - const component = getComponent(); - expect(component).toMatchSnapshot(); - }); - - it("renders loading state correctly", () => { - const component = getComponent(); - expect(component.find("BeaconStatus").props()).toBeTruthy(); + const { asFragment } = renderComponent(); + expect(asFragment()).toMatchSnapshot(); }); describe("Active state", () => { @@ -62,24 +59,19 @@ describe("", () => { mocked(useOwnLiveBeacons).mockReturnValue({ onStopSharing: jest.fn(), }); - const component = getComponent({ displayStatus, beacon: defaultBeacon }); - expect(component.text()).toContain("Live location enabled"); - - expect(findByTestId(component, "beacon-status-stop-beacon").length).toBeTruthy(); + renderComponent({ displayStatus, beacon: defaultBeacon }); + expect(screen.getByText("Live location enabled")).toBeInTheDocument(); + expect(getStopButton()).toBeInTheDocument(); }); - it("stops sharing on stop button click", () => { + it("stops sharing on stop button click", async () => { const displayStatus = BeaconDisplayStatus.Active; const onStopSharing = jest.fn(); mocked(useOwnLiveBeacons).mockReturnValue({ onStopSharing, }); - const component = getComponent({ displayStatus, beacon: defaultBeacon }); - - act(() => { - findByTestId(component, "beacon-status-stop-beacon").at(0).simulate("click"); - }); - + renderComponent({ displayStatus, beacon: defaultBeacon }); + await userEvent.click(getStopButton()); expect(onStopSharing).toHaveBeenCalled(); }); }); @@ -87,11 +79,11 @@ describe("", () => { describe("errors", () => { it("renders in error mode when displayStatus is error", () => { const displayStatus = BeaconDisplayStatus.Error; - const component = getComponent({ displayStatus }); - expect(component.text()).toEqual("Live location error"); + renderComponent({ displayStatus }); + expect(screen.getByText("Live location error")).toBeInTheDocument(); // no actions for plain error - expect(component.find("AccessibleButton").length).toBeFalsy(); + expect(screen.queryByRole("button")).not.toBeInTheDocument(); }); describe("with location publish error", () => { @@ -101,23 +93,21 @@ describe("", () => { hasLocationPublishError: true, onResetLocationPublishError: jest.fn(), }); - const component = getComponent({ displayStatus, beacon: defaultBeacon }); - expect(component.text()).toContain("Live location error"); + renderComponent({ displayStatus, beacon: defaultBeacon }); + expect(screen.getByText("Live location error")).toBeInTheDocument(); // retry button - expect(findByTestId(component, "beacon-status-reset-wire-error").length).toBeTruthy(); + expect(getRetryButton()).toBeInTheDocument(); }); - it("retry button resets location publish error", () => { + it("retry button resets location publish error", async () => { const displayStatus = BeaconDisplayStatus.Active; const onResetLocationPublishError = jest.fn(); mocked(useOwnLiveBeacons).mockReturnValue({ hasLocationPublishError: true, onResetLocationPublishError, }); - const component = getComponent({ displayStatus, beacon: defaultBeacon }); - act(() => { - findByTestId(component, "beacon-status-reset-wire-error").at(0).simulate("click"); - }); + renderComponent({ displayStatus, beacon: defaultBeacon }); + await userEvent.click(getRetryButton()); expect(onResetLocationPublishError).toHaveBeenCalled(); }); @@ -131,23 +121,21 @@ describe("", () => { hasStopSharingError: true, onStopSharing: jest.fn(), }); - const component = getComponent({ displayStatus, beacon: defaultBeacon }); - expect(component.text()).toContain("Live location error"); + renderComponent({ displayStatus, beacon: defaultBeacon }); + expect(screen.getByText("Live location error")).toBeInTheDocument(); // retry button - expect(findByTestId(component, "beacon-status-stop-beacon-retry").length).toBeTruthy(); + expect(getRetryButton()).toBeInTheDocument(); }); - it("retry button retries stop sharing", () => { + it("retry button retries stop sharing", async () => { const displayStatus = BeaconDisplayStatus.Active; const onStopSharing = jest.fn(); mocked(useOwnLiveBeacons).mockReturnValue({ hasStopSharingError: true, onStopSharing, }); - const component = getComponent({ displayStatus, beacon: defaultBeacon }); - act(() => { - findByTestId(component, "beacon-status-stop-beacon-retry").at(0).simulate("click"); - }); + renderComponent({ displayStatus, beacon: defaultBeacon }); + await userEvent.click(getRetryButton()); expect(onStopSharing).toHaveBeenCalled(); }); @@ -155,7 +143,7 @@ describe("", () => { }); it("renders loading state correctly", () => { - const component = getComponent(); + const component = renderComponent(); expect(component).toBeTruthy(); }); }); diff --git a/test/components/views/beacon/__snapshots__/OwnBeaconStatus-test.tsx.snap b/test/components/views/beacon/__snapshots__/OwnBeaconStatus-test.tsx.snap index fdaa80bdbe..246c59f456 100644 --- a/test/components/views/beacon/__snapshots__/OwnBeaconStatus-test.tsx.snap +++ b/test/components/views/beacon/__snapshots__/OwnBeaconStatus-test.tsx.snap @@ -1,27 +1,19 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[` renders without a beacon instance 1`] = ` - - +
-
- - Loading live location... - -
+ Loading live location... +
- - +
+ `; From e57f6f0257826c86d88b8a11296501d587053c7e Mon Sep 17 00:00:00 2001 From: Suguru Hirahara Date: Fri, 10 Feb 2023 17:42:39 +0000 Subject: [PATCH 07/17] Remove white space characters before the horizontal ellipsis (#10130) --- src/components/views/messages/MKeyVerificationRequest.tsx | 4 ++-- src/i18n/strings/en_EN.json | 3 +-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/components/views/messages/MKeyVerificationRequest.tsx b/src/components/views/messages/MKeyVerificationRequest.tsx index c431371ca5..2a11557214 100644 --- a/src/components/views/messages/MKeyVerificationRequest.tsx +++ b/src/components/views/messages/MKeyVerificationRequest.tsx @@ -138,9 +138,9 @@ export default class MKeyVerificationRequest extends React.Component { } else if (request.cancelled) { stateLabel = this.cancelledLabel(request.cancellingUserId); } else if (request.accepting) { - stateLabel = _t("Accepting …"); + stateLabel = _t("Accepting…"); } else if (request.declining) { - stateLabel = _t("Declining …"); + stateLabel = _t("Declining…"); } stateNode =
{stateLabel}
; } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 4500663a9a..d894261c74 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2396,8 +2396,7 @@ "You cancelled": "You cancelled", "%(name)s declined": "%(name)s declined", "%(name)s cancelled": "%(name)s cancelled", - "Accepting …": "Accepting …", - "Declining …": "Declining …", + "Declining…": "Declining…", "%(name)s wants to verify": "%(name)s wants to verify", "You sent a verification request": "You sent a verification request", "Expand map": "Expand map", From f0f50485d7dba6797ffc72550f773b26b05777a8 Mon Sep 17 00:00:00 2001 From: Germain Date: Fri, 10 Feb 2023 18:11:57 +0000 Subject: [PATCH 08/17] TypeScript strict fixes (#10138) --- src/UserActivity.ts | 8 ++++++-- src/VoipUserMapper.ts | 12 ++++++++---- src/WhoIsTyping.ts | 6 +++--- src/verification.ts | 2 +- 4 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/UserActivity.ts b/src/UserActivity.ts index 9217aca3c0..44c8abb2c7 100644 --- a/src/UserActivity.ts +++ b/src/UserActivity.ts @@ -182,11 +182,11 @@ export default class UserActivity { this.activeRecentlyTimeout.abort(); }; - private onUserActivity = (event: MouseEvent): void => { + private onUserActivity = (event: Event): void => { // ignore anything if the window isn't focused if (!this.document.hasFocus()) return; - if (event.screenX && event.type === "mousemove") { + if (event.type === "mousemove" && this.isMouseEvent(event)) { if (event.screenX === this.lastScreenX && event.screenY === this.lastScreenY) { // mouse hasn't actually moved return; @@ -223,4 +223,8 @@ export default class UserActivity { } attachedTimers.forEach((t) => t.abort()); } + + private isMouseEvent(event: Event): event is MouseEvent { + return event.type.startsWith("mouse"); + } } diff --git a/src/VoipUserMapper.ts b/src/VoipUserMapper.ts index 7a163350db..ae5170d6e4 100644 --- a/src/VoipUserMapper.ts +++ b/src/VoipUserMapper.ts @@ -38,7 +38,7 @@ export default class VoipUserMapper { return window.mxVoipUserMapper; } - private async userToVirtualUser(userId: string): Promise { + private async userToVirtualUser(userId: string): Promise { const results = await LegacyCallHandler.instance.sipVirtualLookup(userId); if (results.length === 0 || !results[0].fields.lookup_success) return null; return results[0].userid; @@ -59,11 +59,11 @@ export default class VoipUserMapper { if (!virtualUser) return null; const virtualRoomId = await ensureVirtualRoomExists(MatrixClientPeg.get(), virtualUser, roomId); - MatrixClientPeg.get().setRoomAccountData(virtualRoomId, VIRTUAL_ROOM_EVENT_TYPE, { + MatrixClientPeg.get().setRoomAccountData(virtualRoomId!, VIRTUAL_ROOM_EVENT_TYPE, { native_room: roomId, }); - this.virtualToNativeRoomIdCache.set(virtualRoomId, roomId); + this.virtualToNativeRoomIdCache.set(virtualRoomId!, roomId); return virtualRoomId; } @@ -121,8 +121,12 @@ export default class VoipUserMapper { if (!LegacyCallHandler.instance.getSupportsVirtualRooms()) return; const inviterId = invitedRoom.getDMInviter(); + if (!inviterId) { + logger.error("Could not find DM inviter for room id: " + invitedRoom.roomId); + } + logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`); - const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId); + const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId!); if (result.length === 0) { return; } diff --git a/src/WhoIsTyping.ts b/src/WhoIsTyping.ts index 01e7b2e4f7..d4a43636ce 100644 --- a/src/WhoIsTyping.ts +++ b/src/WhoIsTyping.ts @@ -21,11 +21,11 @@ import { MatrixClientPeg } from "./MatrixClientPeg"; import { _t } from "./languageHandler"; export function usersTypingApartFromMeAndIgnored(room: Room): RoomMember[] { - return usersTyping(room, [MatrixClientPeg.get().getUserId()].concat(MatrixClientPeg.get().getIgnoredUsers())); + return usersTyping(room, [MatrixClientPeg.get().getUserId()!].concat(MatrixClientPeg.get().getIgnoredUsers())); } export function usersTypingApartFromMe(room: Room): RoomMember[] { - return usersTyping(room, [MatrixClientPeg.get().getUserId()]); + return usersTyping(room, [MatrixClientPeg.get().getUserId()!]); } /** @@ -36,7 +36,7 @@ export function usersTypingApartFromMe(room: Room): RoomMember[] { * @returns {RoomMember[]} list of user objects who are typing. */ export function usersTyping(room: Room, exclude: string[] = []): RoomMember[] { - const whoIsTyping = []; + const whoIsTyping: RoomMember[] = []; const memberKeys = Object.keys(room.currentState.members); for (const userId of memberKeys) { diff --git a/src/verification.ts b/src/verification.ts index 11ece17e90..c7cdd8073a 100644 --- a/src/verification.ts +++ b/src/verification.ts @@ -120,7 +120,7 @@ function setRightPanel(state: IRightPanelCardState): void { } } -export function pendingVerificationRequestForUser(user: User | RoomMember): VerificationRequest { +export function pendingVerificationRequestForUser(user: User | RoomMember): VerificationRequest | undefined { const cli = MatrixClientPeg.get(); const dmRoom = findDMForUser(cli, user.userId); if (dmRoom) { From 18ab325eaffd8b3ac14ceba0f8abc517e56f10a2 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 13 Feb 2023 09:19:45 +1300 Subject: [PATCH 09/17] Poll history - filter by active or ended (#10098) * 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 * render a poll body that errors for poll end events * add fetching logic to pollend tile * extract poll testing utilities * test mpollend * strict fix * more strict fix * strict fix for forwardref * add filter component * update poll test utils * add unstyled filter tab group * filtertabgroup snapshot * lint * update test util setupRoomWithPollEvents to allow testing multiple polls in one room * style filter tabs * test error message for past polls * sort polls list by latest * move FilterTabGroup into generic components * comments * Update src/components/views/dialogs/polls/types.ts Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> * Apply suggestions from code review Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --------- Co-authored-by: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> --- res/css/_components.pcss | 1 + .../views/elements/_FilterTabGroup.pcss | 46 +++++++++++++ .../views/dialogs/polls/PollHistoryDialog.tsx | 29 +++++++-- .../views/dialogs/polls/PollHistoryList.tsx | 21 +++++- src/components/views/dialogs/polls/types.ts | 22 +++++++ .../views/dialogs/polls/usePollHistory.ts | 25 ++++--- .../views/elements/FilterTabGroup.tsx | 57 ++++++++++++++++ src/i18n/strings/en_EN.json | 3 +- .../dialogs/polls/PollHistoryDialog-test.tsx | 65 +++++++++++++++---- .../PollHistoryDialog-test.tsx.snap | 53 +++++++++------ .../views/elements/FilterTabGroup-test.tsx | 54 +++++++++++++++ .../FilterTabGroup-test.tsx.snap | 48 ++++++++++++++ .../views/messages/MPollBody-test.tsx | 10 +-- .../views/messages/MPollEndBody-test.tsx | 2 +- test/test-utils/poll.ts | 13 ++-- 15 files changed, 388 insertions(+), 61 deletions(-) create mode 100644 res/css/components/views/elements/_FilterTabGroup.pcss create mode 100644 src/components/views/dialogs/polls/types.ts create mode 100644 src/components/views/elements/FilterTabGroup.tsx create mode 100644 test/components/views/elements/FilterTabGroup-test.tsx create mode 100644 test/components/views/elements/__snapshots__/FilterTabGroup-test.tsx.snap diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 541178b926..fff2d7ebff 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -19,6 +19,7 @@ @import "./components/views/context_menus/_KebabContextMenu.pcss"; @import "./components/views/dialogs/polls/_PollListItem.pcss"; @import "./components/views/elements/_FilterDropdown.pcss"; +@import "./components/views/elements/_FilterTabGroup.pcss"; @import "./components/views/elements/_LearnMore.pcss"; @import "./components/views/location/_EnableLiveShare.pcss"; @import "./components/views/location/_LiveDurationDropdown.pcss"; diff --git a/res/css/components/views/elements/_FilterTabGroup.pcss b/res/css/components/views/elements/_FilterTabGroup.pcss new file mode 100644 index 0000000000..bbf1a279ad --- /dev/null +++ b/res/css/components/views/elements/_FilterTabGroup.pcss @@ -0,0 +1,46 @@ +/* +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_FilterTabGroup { + color: $primary-content; + label { + margin-right: $spacing-12; + cursor: pointer; + span { + display: inline-block; + line-height: $font-24px; + } + } + input[type="radio"] { + appearance: none; + margin: 0; + padding: 0; + + &:focus, + &:hover { + & + span { + color: $secondary-content; + } + } + + &:checked + span { + color: $accent; + font-weight: $font-semi-bold; + // underline + box-shadow: 0 1.5px 0 0 currentColor; + } + } +} diff --git a/src/components/views/dialogs/polls/PollHistoryDialog.tsx b/src/components/views/dialogs/polls/PollHistoryDialog.tsx index 4671da9246..e5525fbbaf 100644 --- a/src/components/views/dialogs/polls/PollHistoryDialog.tsx +++ b/src/components/views/dialogs/polls/PollHistoryDialog.tsx @@ -14,26 +14,47 @@ See the License for the specific language governing permissions and limitations under the License. */ -import React from "react"; +import React, { useEffect, useState } from "react"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { MatrixEvent, Poll } from "matrix-js-sdk/src/matrix"; import { _t } from "../../../../languageHandler"; import BaseDialog from "../BaseDialog"; import { IDialogProps } from "../IDialogProps"; import { PollHistoryList } from "./PollHistoryList"; -import { getPolls } from "./usePollHistory"; +import { PollHistoryFilter } from "./types"; +import { usePolls } from "./usePollHistory"; type PollHistoryDialogProps = Pick & { roomId: string; matrixClient: MatrixClient; }; + +const sortEventsByLatest = (left: MatrixEvent, right: MatrixEvent): number => right.getTs() - left.getTs(); +const filterPolls = + (filter: PollHistoryFilter) => + (poll: Poll): boolean => + (filter === "ACTIVE") !== poll.isEnded; +const filterAndSortPolls = (polls: Map, filter: PollHistoryFilter): MatrixEvent[] => { + return [...polls.values()] + .filter(filterPolls(filter)) + .map((poll) => poll.rootEvent) + .sort(sortEventsByLatest); +}; + export const PollHistoryDialog: React.FC = ({ roomId, matrixClient, onFinished }) => { - const pollStartEvents = getPolls(roomId, matrixClient); + const { polls } = usePolls(roomId, matrixClient); + const [filter, setFilter] = useState("ACTIVE"); + const [pollStartEvents, setPollStartEvents] = useState(filterAndSortPolls(polls, filter)); + + useEffect(() => { + setPollStartEvents(filterAndSortPolls(polls, filter)); + }, [filter, polls]); return (
- +
); diff --git a/src/components/views/dialogs/polls/PollHistoryList.tsx b/src/components/views/dialogs/polls/PollHistoryList.tsx index ff0ea3a7cf..7c8714aeac 100644 --- a/src/components/views/dialogs/polls/PollHistoryList.tsx +++ b/src/components/views/dialogs/polls/PollHistoryList.tsx @@ -19,13 +19,26 @@ import { MatrixEvent } from "matrix-js-sdk/src/matrix"; import PollListItem from "./PollListItem"; import { _t } from "../../../../languageHandler"; +import { FilterTabGroup } from "../../elements/FilterTabGroup"; +import { PollHistoryFilter } from "./types"; type PollHistoryListProps = { pollStartEvents: MatrixEvent[]; + filter: PollHistoryFilter; + onFilterChange: (filter: PollHistoryFilter) => void; }; -export const PollHistoryList: React.FC = ({ pollStartEvents }) => { +export const PollHistoryList: React.FC = ({ pollStartEvents, filter, onFilterChange }) => { return (
+ + name="PollHistoryDialog_filter" + value={filter} + onFilterChange={onFilterChange} + tabs={[ + { id: "ACTIVE", label: "Active polls" }, + { id: "ENDED", label: "Past polls" }, + ]} + /> {!!pollStartEvents.length ? (
    {pollStartEvents.map((pollStartEvent) => ( @@ -33,7 +46,11 @@ export const PollHistoryList: React.FC = ({ pollStartEvent ))}
) : ( - {_t("There are no polls in this room")} + + {filter === "ACTIVE" + ? _t("There are no active polls in this room") + : _t("There are no past polls in this room")} + )}
); diff --git a/src/components/views/dialogs/polls/types.ts b/src/components/views/dialogs/polls/types.ts new file mode 100644 index 0000000000..1664203475 --- /dev/null +++ b/src/components/views/dialogs/polls/types.ts @@ -0,0 +1,22 @@ +/* +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. +*/ + +/** + * Possible values for the "filter" setting in the poll history dialog + * + * Ended polls have a valid M_POLL_END event + */ +export type PollHistoryFilter = "ACTIVE" | "ENDED"; diff --git a/src/components/views/dialogs/polls/usePollHistory.ts b/src/components/views/dialogs/polls/usePollHistory.ts index aa730b84ee..1da2b4ee1d 100644 --- a/src/components/views/dialogs/polls/usePollHistory.ts +++ b/src/components/views/dialogs/polls/usePollHistory.ts @@ -14,27 +14,32 @@ 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 { Poll, PollEvent } from "matrix-js-sdk/src/matrix"; import { MatrixClient } from "matrix-js-sdk/src/client"; +import { useEventEmitterState } from "../../../../hooks/useEventEmitter"; + /** - * Get poll start events in a rooms live timeline + * Get poll instances from a room * @param roomId - id of room to retrieve polls for * @param matrixClient - client - * @returns {MatrixEvent[]} - array fo poll start events + * @returns {Map} - Map of Poll instances */ -export const getPolls = (roomId: string, matrixClient: MatrixClient): MatrixEvent[] => { +export const usePolls = ( + roomId: string, + matrixClient: MatrixClient, +): { + polls: Map; +} => { 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())); + const polls = useEventEmitterState(room, PollEvent.New, () => room.polls); - return pollStartEvents; + // @TODO(kerrya) watch polls for end events, trigger refiltering + + return { polls }; }; diff --git a/src/components/views/elements/FilterTabGroup.tsx b/src/components/views/elements/FilterTabGroup.tsx new file mode 100644 index 0000000000..91991fbd0e --- /dev/null +++ b/src/components/views/elements/FilterTabGroup.tsx @@ -0,0 +1,57 @@ +/* +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, { FieldsetHTMLAttributes, ReactNode } from "react"; + +export type FilterTab = { + label: string | ReactNode; + id: T; +}; +type FilterTabGroupProps = FieldsetHTMLAttributes & { + // group name used for radio buttons + name: string; + onFilterChange: (id: T) => void; + // active tab's id + value: T; + // tabs to display + tabs: FilterTab[]; +}; + +/** + * React component which styles a set of content filters as tabs + * + * This is used in displays which show a list of content items, and the user can select between one of several + * filters for those items. For example, in the Poll History dialog, the user can select between "Active" and "Ended" + * polls. + * + * Type `T` is used for the `value` attribute for the buttons in the radio group. + */ +export const FilterTabGroup = ({ + name, + value, + tabs, + onFilterChange, + ...rest +}: FilterTabGroupProps): JSX.Element => ( +
+ {tabs.map(({ label, id }) => ( + + ))} +
+); diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d894261c74..7cc713811f 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3144,7 +3144,8 @@ "Warning: You should only set up key backup from a trusted computer.": "Warning: You should only set up key backup from a trusted computer.", "Access your secure message history and set up secure messaging by entering your Security Key.": "Access your secure message history and set up secure messaging by entering your Security Key.", "If you've forgotten your Security Key you can ": "If you've forgotten your Security Key you can ", - "There are no polls in this room": "There are no polls in this room", + "There are no active polls in this room": "There are no active polls in this room", + "There are no past polls in this room": "There are no past 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 index 3c937bdc68..b02bbb409b 100644 --- a/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx +++ b/test/components/views/dialogs/polls/PollHistoryDialog-test.tsx @@ -15,15 +15,17 @@ limitations under the License. */ import React from "react"; -import { render } from "@testing-library/react"; -import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; +import { fireEvent, render } from "@testing-library/react"; +import { Room } from "matrix-js-sdk/src/matrix"; import { PollHistoryDialog } from "../../../../../src/components/views/dialogs/polls/PollHistoryDialog"; import { getMockClientWithEventEmitter, + makePollEndEvent, makePollStartEvent, mockClientMethodsUser, mockIntlDateTimeFormat, + setupRoomWithPollEvents, unmockIntlDateTimeFormat, } from "../../../../test-utils"; @@ -33,6 +35,8 @@ describe("", () => { const mockClient = getMockClientWithEventEmitter({ ...mockClientMethodsUser(userId), getRoom: jest.fn(), + relations: jest.fn(), + decryptEventIfNeeded: jest.fn(), }); const room = new Room(roomId, mockClient, userId); @@ -49,6 +53,7 @@ describe("", () => { beforeEach(() => { mockClient.getRoom.mockReturnValue(room); + mockClient.relations.mockResolvedValue({ events: [] }); const timeline = room.getLiveTimeline(); jest.spyOn(timeline, "getEvents").mockReturnValue([]); }); @@ -63,24 +68,58 @@ describe("", () => { expect(() => getComponent()).toThrow("Cannot find room"); }); - it("renders a no polls message when there are no polls in the timeline", () => { + it("renders a no polls message when there are no active polls in the timeline", () => { const { getByText } = getComponent(); - expect(getByText("There are no polls in this room")).toBeTruthy(); + expect(getByText("There are no active polls in this room")).toBeTruthy(); }); - it("renders a list of polls when there are polls in the timeline", async () => { + it("renders a no past polls message when there are no past polls in the timeline", () => { + const { getByText } = getComponent(); + + fireEvent.click(getByText("Past polls")); + + expect(getByText("There are no past polls in this room")).toBeTruthy(); + }); + + it("renders a list of active polls when there are polls in the timeline", async () => { + const timestamp = 1675300825090; + const pollStart1 = makePollStartEvent("Question?", userId, undefined, { ts: timestamp, id: "$1" }); + const pollStart2 = makePollStartEvent("Where?", userId, undefined, { ts: timestamp + 10000, id: "$2" }); + const pollStart3 = makePollStartEvent("What?", userId, undefined, { ts: timestamp + 70000, id: "$3" }); + const pollEnd3 = makePollEndEvent(pollStart3.getId()!, roomId, userId, timestamp + 1); + await setupRoomWithPollEvents([pollStart2, pollStart3, pollStart1], [], [pollEnd3], mockClient, room); + + const { container, queryByText, getByTestId } = getComponent(); + + expect(getByTestId("filter-tab-PollHistoryDialog_filter-ACTIVE").firstElementChild).toBeChecked(); + + expect(container).toMatchSnapshot(); + // this poll is ended, and default filter is ACTIVE + expect(queryByText("What?")).not.toBeInTheDocument(); + }); + + it("filters ended polls", async () => { const pollStart1 = makePollStartEvent("Question?", userId, undefined, { ts: 1675300825090, id: "$1" }); const pollStart2 = makePollStartEvent("Where?", userId, undefined, { ts: 1675300725090, id: "$2" }); const pollStart3 = makePollStartEvent("What?", userId, undefined, { ts: 1675200725090, id: "$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(); + const pollEnd3 = makePollEndEvent(pollStart3.getId()!, roomId, userId, 1675200725090 + 1); + await setupRoomWithPollEvents([pollStart1, pollStart2, pollStart3], [], [pollEnd3], mockClient, room); - expect(container).toMatchSnapshot(); + const { getByText, queryByText, getByTestId } = getComponent(); + + expect(getByText("Question?")).toBeInTheDocument(); + expect(getByText("Where?")).toBeInTheDocument(); + // this poll is ended, and default filter is ACTIVE + expect(queryByText("What?")).not.toBeInTheDocument(); + + fireEvent.click(getByText("Past polls")); + expect(getByTestId("filter-tab-PollHistoryDialog_filter-ENDED").firstElementChild).toBeChecked(); + + // active polls no longer shown + expect(queryByText("Question?")).not.toBeInTheDocument(); + expect(queryByText("Where?")).not.toBeInTheDocument(); + // this poll is ended + expect(getByText("What?")).toBeInTheDocument(); }); }); diff --git a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap index fd572bc2d1..1672f66fd6 100644 --- a/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap +++ b/test/components/views/dialogs/polls/__snapshots__/PollHistoryDialog-test.tsx.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[` renders a list of polls when there are polls in the timeline 1`] = ` +exports[` renders a list of active polls when there are polls in the timeline 1`] = `
renders a list of polls when there are polls in t
+
+ + +
    -
  1. - - 02/02/23 - -
    - - Question? - -
  2. renders a list of polls when there are polls in t
  3. - 31/01/23 + 02/02/23
    renders a list of polls when there are polls in t - What? + Question?
diff --git a/test/components/views/elements/FilterTabGroup-test.tsx b/test/components/views/elements/FilterTabGroup-test.tsx new file mode 100644 index 0000000000..0dc146fd37 --- /dev/null +++ b/test/components/views/elements/FilterTabGroup-test.tsx @@ -0,0 +1,54 @@ +/* +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 { fireEvent, render } from "@testing-library/react"; + +import { FilterTabGroup } from "../../../../src/components/views/elements/FilterTabGroup"; + +describe("", () => { + enum TestOption { + Apple = "Apple", + Banana = "Banana", + Orange = "Orange", + } + const defaultProps = { + "name": "test", + "value": TestOption.Apple, + "onFilterChange": jest.fn(), + "tabs": [ + { id: TestOption.Apple, label: `Label for ${TestOption.Apple}` }, + { id: TestOption.Banana, label: `Label for ${TestOption.Banana}` }, + { id: TestOption.Orange, label: `Label for ${TestOption.Orange}` }, + ], + "data-testid": "test", + }; + const getComponent = (props = {}) => {...defaultProps} {...props} />; + + it("renders options", () => { + const { container } = render(getComponent()); + expect(container).toMatchSnapshot(); + }); + + it("calls onChange handler on selection", () => { + const onFilterChange = jest.fn(); + const { getByText } = render(getComponent({ onFilterChange })); + + fireEvent.click(getByText("Label for Banana")); + + expect(onFilterChange).toHaveBeenCalledWith(TestOption.Banana); + }); +}); diff --git a/test/components/views/elements/__snapshots__/FilterTabGroup-test.tsx.snap b/test/components/views/elements/__snapshots__/FilterTabGroup-test.tsx.snap new file mode 100644 index 0000000000..e87979a8ad --- /dev/null +++ b/test/components/views/elements/__snapshots__/FilterTabGroup-test.tsx.snap @@ -0,0 +1,48 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` renders options 1`] = ` +
+
+ + + +
+
+`; diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index a24cc5de0c..0b1be75e3f 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -227,7 +227,7 @@ describe("MPollBody", () => { content: newPollStart(undefined, undefined, true), }); const props = getMPollBodyPropsFromEvent(mxEvent); - const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient); + const room = await setupRoomWithPollEvents([mxEvent], votes, [], mockClient); const renderResult = renderMPollBodyWithWrapper(props); // wait for /relations promise to resolve await flushPromises(); @@ -255,7 +255,7 @@ describe("MPollBody", () => { content: newPollStart(undefined, undefined, true), }); const props = getMPollBodyPropsFromEvent(mxEvent); - const room = await setupRoomWithPollEvents(mxEvent, votes, [], mockClient); + const room = await setupRoomWithPollEvents([mxEvent], votes, [], mockClient); const renderResult = renderMPollBodyWithWrapper(props); // wait for /relations promise to resolve await flushPromises(); @@ -700,7 +700,7 @@ describe("MPollBody", () => { }); const ends = [newPollEndEvent("@me:example.com", 25)]; - await setupRoomWithPollEvents(pollEvent, [], ends, mockClient); + await setupRoomWithPollEvents([pollEvent], [], ends, mockClient); const poll = mockClient.getRoom(pollEvent.getRoomId()!)!.polls.get(pollEvent.getId()!)!; // start fetching, dont await poll.getResponses(); @@ -920,7 +920,7 @@ async function newMPollBodyFromEvent( ): Promise { const props = getMPollBodyPropsFromEvent(mxEvent); - await setupRoomWithPollEvents(mxEvent, relationEvents, endEvents, mockClient); + await setupRoomWithPollEvents([mxEvent], relationEvents, endEvents, mockClient); return renderMPollBodyWithWrapper(props); } @@ -1036,7 +1036,7 @@ async function runIsPollEnded(ends: MatrixEvent[]) { content: newPollStart(), }); - await setupRoomWithPollEvents(pollEvent, [], ends, mockClient); + await setupRoomWithPollEvents([pollEvent], [], ends, mockClient); return isPollEnded(pollEvent, mockClient); } diff --git a/test/components/views/messages/MPollEndBody-test.tsx b/test/components/views/messages/MPollEndBody-test.tsx index f34d2003db..2e78646ceb 100644 --- a/test/components/views/messages/MPollEndBody-test.tsx +++ b/test/components/views/messages/MPollEndBody-test.tsx @@ -50,7 +50,7 @@ describe("", () => { const setupRoomWithEventsTimeline = async (pollEnd: MatrixEvent, pollStart?: MatrixEvent): Promise => { if (pollStart) { - await setupRoomWithPollEvents(pollStart, [], [pollEnd], mockClient); + await setupRoomWithPollEvents([pollStart], [], [pollEnd], mockClient); } const room = mockClient.getRoom(roomId) || new Room(roomId, mockClient, userId); diff --git a/test/test-utils/poll.ts b/test/test-utils/poll.ts index 9cb108b676..37f90cd4bc 100644 --- a/test/test-utils/poll.ts +++ b/test/test-utils/poll.ts @@ -89,13 +89,14 @@ export const makePollEndEvent = (pollStartEventId: string, roomId: string, sende * @returns */ export const setupRoomWithPollEvents = async ( - mxEvent: MatrixEvent, + pollStartEvents: MatrixEvent[], relationEvents: Array, endEvents: Array = [], mockClient: Mocked, + existingRoom?: Room, ): Promise => { - const room = new Room(mxEvent.getRoomId()!, mockClient, mockClient.getSafeUserId()); - room.processPollEvents([mxEvent, ...relationEvents, ...endEvents]); + const room = existingRoom || new Room(pollStartEvents[0].getRoomId()!, mockClient, mockClient.getSafeUserId()); + room.processPollEvents([...pollStartEvents, ...relationEvents, ...endEvents]); // set redaction allowed for current user only // poll end events are validated against this @@ -106,8 +107,10 @@ export const setupRoomWithPollEvents = async ( // wait for events to process on room await flushPromises(); mockClient.getRoom.mockReturnValue(room); - mockClient.relations.mockResolvedValue({ - events: [...relationEvents, ...endEvents], + mockClient.relations.mockImplementation(async (_roomId: string, eventId: string) => { + return { + events: [...relationEvents, ...endEvents].filter((event) => event.getRelation()?.event_id === eventId), + }; }); return room; }; From 1c6b06bb58c102903d73b1eba5d95b5e4055aed1 Mon Sep 17 00:00:00 2001 From: Kerry Date: Mon, 13 Feb 2023 15:55:39 +1300 Subject: [PATCH 10/17] Poll history - extract poll option display components (#10107) * 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 * render a poll body that errors for poll end events * add fetching logic to pollend tile * extract poll testing utilities * test mpollend * strict fix * more strict fix * strict fix for forwardref * add filter component * update poll test utils * add unstyled filter tab group * filtertabgroup snapshot * lint * update test util setupRoomWithPollEvents to allow testing multiple polls in one room * style filter tabs * test error message for past polls * sort polls list by latest * extract poll option display components * strict fixes --- cypress/e2e/polls/polls.spec.ts | 4 +- res/css/_components.pcss | 1 + .../components/views/polls/_PollOption.pcss | 109 ++++ res/css/views/messages/_MPollBody.pcss | 111 +--- res/img/element-icons/trophy.svg | 2 +- src/components/views/messages/MPollBody.tsx | 95 +-- src/components/views/polls/PollOption.tsx | 123 ++++ src/i18n/strings/en_EN.json | 4 +- .../views/messages/MPollBody-test.tsx | 34 +- .../__snapshots__/MPollBody-test.tsx.snap | 567 ++++++++++-------- .../__snapshots__/MPollEndBody-test.tsx.snap | 28 +- .../right_panel/PinnedMessagesCard-test.tsx | 6 +- .../devices/FilteredDeviceList-test.tsx | 7 + 13 files changed, 597 insertions(+), 494 deletions(-) create mode 100644 res/css/components/views/polls/_PollOption.pcss create mode 100644 src/components/views/polls/PollOption.tsx diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index 51d169d61b..f7e06e18f2 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -54,12 +54,12 @@ describe("Polls", () => { }; const getPollOption = (pollId: string, optionText: string): Chainable => { - return getPollTile(pollId).contains(".mx_MPollBody_option .mx_StyledRadioButton", optionText); + return getPollTile(pollId).contains(".mx_PollOption .mx_StyledRadioButton", optionText); }; const expectPollOptionVoteCount = (pollId: string, optionText: string, votes: number): void => { getPollOption(pollId, optionText).within(() => { - cy.get(".mx_MPollBody_optionVoteCount").should("contain", `${votes} vote`); + cy.get(".mx_PollOption_optionVoteCount").should("contain", `${votes} vote`); }); }; diff --git a/res/css/_components.pcss b/res/css/_components.pcss index fff2d7ebff..cda1278df9 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -33,6 +33,7 @@ @import "./components/views/messages/_MBeaconBody.pcss"; @import "./components/views/messages/shared/_MediaProcessingError.pcss"; @import "./components/views/pips/_WidgetPip.pcss"; +@import "./components/views/polls/_PollOption.pcss"; @import "./components/views/settings/devices/_CurrentDeviceSection.pcss"; @import "./components/views/settings/devices/_DeviceDetailHeading.pcss"; @import "./components/views/settings/devices/_DeviceDetails.pcss"; diff --git a/res/css/components/views/polls/_PollOption.pcss b/res/css/components/views/polls/_PollOption.pcss new file mode 100644 index 0000000000..e5a97b7f2b --- /dev/null +++ b/res/css/components/views/polls/_PollOption.pcss @@ -0,0 +1,109 @@ +/* +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_PollOption { + border: 1px solid $quinary-content; + border-radius: 8px; + padding: 6px 12px; + max-width: 550px; + background-color: $background; + + .mx_StyledRadioButton_content, + .mx_PollOption_endedOption { + padding-top: 2px; + margin-right: 0px; + } + + .mx_StyledRadioButton_spacer { + display: none; + } +} + +.mx_PollOption, +/* label has cursor: default in user-agent stylesheet */ +/* override */ +.mx_PollOption_live-option { + cursor: pointer; +} + +.mx_PollOption_content { + display: flex; + justify-content: space-between; +} + +.mx_PollOption_optionVoteCount { + color: $secondary-content; + font-size: $font-12px; + white-space: nowrap; +} + +.mx_PollOption_winnerIcon { + height: 12px; + width: 12px; + color: $accent; + margin-right: $spacing-4; + vertical-align: middle; +} + +.mx_PollOption_checked { + border-color: $accent; + + .mx_PollOption_popularityBackground { + .mx_PollOption_popularityAmount { + background-color: $accent; + } + } + + // override checked radio button styling + // to show checkmark instead + .mx_StyledRadioButton_checked { + input[type="radio"] + div { + border-width: 2px; + border-color: $accent; + background-color: $accent; + background-image: url("$(res)/img/element-icons/check-white.svg"); + background-size: 12px; + background-repeat: no-repeat; + background-position: center; + + div { + visibility: hidden; + } + } + } +} + +/* options not actionable in these states */ +.mx_PollOption_checked, +.mx_PollOption_ended { + pointer-events: none; +} + +.mx_PollOption_popularityBackground { + width: 100%; + height: 8px; + margin-right: 12px; + border-radius: 8px; + background-color: $system; + margin-top: $spacing-8; + + .mx_PollOption_popularityAmount { + width: 0%; + height: 8px; + border-radius: 8px; + background-color: $quaternary-content; + } +} diff --git a/res/css/views/messages/_MPollBody.pcss b/res/css/views/messages/_MPollBody.pcss index e9ea2bc3dc..193bd9382a 100644 --- a/res/css/views/messages/_MPollBody.pcss +++ b/res/css/views/messages/_MPollBody.pcss @@ -47,108 +47,6 @@ limitations under the License. mask-image: url("$(res)/img/element-icons/room/composer/poll.svg"); } - .mx_MPollBody_option { - border: 1px solid $quinary-content; - border-radius: 8px; - margin-bottom: 16px; - padding: 6px 12px; - max-width: 550px; - background-color: $background; - - .mx_StyledRadioButton, - .mx_MPollBody_endedOption { - margin-bottom: 8px; - } - - .mx_StyledRadioButton_content, - .mx_MPollBody_endedOption { - padding-top: 2px; - margin-right: 0px; - } - - .mx_StyledRadioButton_spacer { - display: none; - } - - .mx_MPollBody_optionDescription { - display: flex; - justify-content: space-between; - - .mx_MPollBody_optionVoteCount { - color: $secondary-content; - font-size: $font-12px; - white-space: nowrap; - } - } - - .mx_MPollBody_popularityBackground { - width: 100%; - height: 8px; - margin-right: 12px; - border-radius: 8px; - background-color: $system; - - .mx_MPollBody_popularityAmount { - width: 0%; - height: 8px; - border-radius: 8px; - background-color: $quaternary-content; - } - } - } - - .mx_MPollBody_option:last-child { - margin-bottom: 8px; - } - - .mx_MPollBody_option_checked { - border-color: $accent; - - .mx_MPollBody_popularityBackground { - .mx_MPollBody_popularityAmount { - background-color: $accent; - } - } - } - - /* options not actionable in these states */ - .mx_MPollBody_option_checked, - .mx_MPollBody_option_ended { - pointer-events: none; - } - - .mx_StyledRadioButton_checked, - .mx_MPollBody_endedOptionWinner { - input[type="radio"] + div { - border-width: 2px; - border-color: $accent; - background-color: $accent; - background-image: url("$(res)/img/element-icons/check-white.svg"); - background-size: 12px; - background-repeat: no-repeat; - background-position: center; - - div { - visibility: hidden; - } - } - } - - .mx_MPollBody_endedOptionWinner .mx_MPollBody_optionDescription .mx_MPollBody_optionVoteCount::before { - content: ""; - position: relative; - display: inline-block; - margin-right: 4px; - top: 2px; - height: 12px; - width: 12px; - background-color: $accent; - mask-repeat: no-repeat; - mask-size: contain; - mask-position: center; - mask-image: url("$(res)/img/element-icons/trophy.svg"); - } - .mx_MPollBody_totalVotes { display: flex; flex-direction: inline; @@ -168,9 +66,8 @@ limitations under the License. pointer-events: none; } -.mx_MPollBody_option, -/* label has cursor: default in user-agent stylesheet */ -/* override */ -.mx_MPollBody_live-option { - cursor: pointer; +.mx_MPollBody_allOptions { + display: grid; + grid-gap: $spacing-16; + margin-bottom: $spacing-8; } diff --git a/res/img/element-icons/trophy.svg b/res/img/element-icons/trophy.svg index 7caf61fd35..99f4831b57 100644 --- a/res/img/element-icons/trophy.svg +++ b/res/img/element-icons/trophy.svg @@ -1,3 +1,3 @@ - + diff --git a/src/components/views/messages/MPollBody.tsx b/src/components/views/messages/MPollBody.tsx index f5bb9e8114..c65a0de418 100644 --- a/src/components/views/messages/MPollBody.tsx +++ b/src/components/views/messages/MPollBody.tsx @@ -15,7 +15,6 @@ limitations under the License. */ import React, { ReactNode } from "react"; -import classNames from "classnames"; import { logger } from "matrix-js-sdk/src/logger"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { Relations } from "matrix-js-sdk/src/models/relations"; @@ -30,13 +29,13 @@ import { _t } from "../../../languageHandler"; import Modal from "../../../Modal"; import { IBodyProps } from "./IBodyProps"; import { formatCommaSeparatedList } from "../../../utils/FormattingUtils"; -import StyledRadioButton from "../elements/StyledRadioButton"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import ErrorDialog from "../dialogs/ErrorDialog"; import { GetRelationsForEvent } from "../rooms/EventTile"; import PollCreateDialog from "../elements/PollCreateDialog"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; import Spinner from "../elements/Spinner"; +import { PollOption } from "../polls/PollOption"; interface IState { poll?: Poll; @@ -230,10 +229,6 @@ export default class MPollBody extends React.Component { this.setState({ selected: answerId }); } - private onOptionSelected = (e: React.FormEvent): void => { - this.selectOption(e.currentTarget.value); - }; - /** * @returns userId -> UserVote */ @@ -329,47 +324,26 @@ export default class MPollBody extends React.Component {
{pollEvent.answers.map((answer: PollAnswerSubevent) => { let answerVotes = 0; - let votesText = ""; if (showResults) { answerVotes = votes.get(answer.id) ?? 0; - votesText = _t("%(count)s votes", { count: answerVotes }); } 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: poll.isEnded, - }); - const answerPercent = totalVotes === 0 ? 0 : Math.round((100.0 * answerVotes) / totalVotes); return ( -
this.selectOption(answer.id)} - > - {poll.isEnded ? ( - - ) : ( - - )} -
-
-
-
+ pollId={pollId} + answer={answer} + isChecked={checked} + isEnded={poll.isEnded} + voteCount={answerVotes} + totalVoteCount={totalVotes} + displayVoteCount={showResults} + onOptionSelected={this.selectOption.bind(this)} + /> ); })}
@@ -381,53 +355,6 @@ export default class MPollBody extends React.Component { ); } } - -interface IEndedPollOptionProps { - answer: PollAnswerSubevent; - checked: boolean; - votesText: string; -} - -function EndedPollOption(props: IEndedPollOptionProps): JSX.Element { - const cls = classNames({ - mx_MPollBody_endedOption: true, - mx_MPollBody_endedOptionWinner: props.checked, - }); - return ( -
-
-
{props.answer.text}
-
{props.votesText}
-
-
- ); -} - -interface ILivePollOptionProps { - pollId: string; - answer: PollAnswerSubevent; - checked: boolean; - votesText: string; - onOptionSelected: (e: React.FormEvent) => void; -} - -function LivePollOption(props: ILivePollOptionProps): JSX.Element { - return ( - -
-
{props.answer.text}
-
{props.votesText}
-
-
- ); -} - export class UserVote { public constructor(public readonly ts: number, public readonly sender: string, public readonly answers: string[]) {} } diff --git a/src/components/views/polls/PollOption.tsx b/src/components/views/polls/PollOption.tsx new file mode 100644 index 0000000000..7760cb6e15 --- /dev/null +++ b/src/components/views/polls/PollOption.tsx @@ -0,0 +1,123 @@ +/* +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 classNames from "classnames"; +import { PollAnswerSubevent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; + +import { _t } from "../../../languageHandler"; +import { Icon as TrophyIcon } from "../../../../res/img/element-icons/trophy.svg"; +import StyledRadioButton from "../elements/StyledRadioButton"; + +type PollOptionContentProps = { + answer: PollAnswerSubevent; + voteCount: number; + displayVoteCount?: boolean; + isWinner?: boolean; +}; +const PollOptionContent: React.FC = ({ isWinner, answer, voteCount, displayVoteCount }) => { + const votesText = displayVoteCount ? _t("%(count)s votes", { count: voteCount }) : ""; + return ( +
+
{answer.text}
+
+ {isWinner && } + {votesText} +
+
+ ); +}; + +interface PollOptionProps extends PollOptionContentProps { + pollId: string; + totalVoteCount: number; + isEnded?: boolean; + isChecked?: boolean; + onOptionSelected?: (id: string) => void; +} + +const EndedPollOption: React.FC> = ({ + isChecked, + children, + answer, +}) => ( +
+ {children} +
+); + +const ActivePollOption: React.FC> = ({ + pollId, + isChecked, + children, + answer, + onOptionSelected, +}) => ( + onOptionSelected?.(answer.id)} + > + {children} + +); + +export const PollOption: React.FC = ({ + pollId, + answer, + voteCount, + totalVoteCount, + displayVoteCount, + isEnded, + isChecked, + onOptionSelected, +}) => { + const cls = classNames({ + mx_PollOption: true, + mx_PollOption_checked: isChecked, + mx_PollOption_ended: isEnded, + }); + const isWinner = isEnded && isChecked; + const answerPercent = totalVoteCount === 0 ? 0 : Math.round((100.0 * voteCount) / totalVoteCount); + const PollOptionWrapper = isEnded ? EndedPollOption : ActivePollOption; + return ( +
onOptionSelected?.(answer.id)}> + + + +
+
+
+
+ ); +}; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 7cc713811f..46207ded9e 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2315,6 +2315,8 @@ "%(displayName)s cancelled verification.": "%(displayName)s cancelled verification.", "You cancelled verification.": "You cancelled verification.", "Verification cancelled": "Verification cancelled", + "%(count)s votes|other": "%(count)s votes", + "%(count)s votes|one": "%(count)s vote", "%(name)s started a video call": "%(name)s started a video call", "Video call ended": "Video call ended", "Sunday": "Sunday", @@ -2416,8 +2418,6 @@ "Based on %(count)s votes|other": "Based on %(count)s votes", "Based on %(count)s votes|one": "Based on %(count)s vote", "edited": "edited", - "%(count)s votes|other": "%(count)s votes", - "%(count)s votes|one": "%(count)s vote", "Error decrypting video": "Error decrypting video", "Error processing voice message": "Error processing voice message", "Add reaction": "Add reaction", diff --git a/test/components/views/messages/MPollBody-test.tsx b/test/components/views/messages/MPollBody-test.tsx index 0b1be75e3f..7b43459ce3 100644 --- a/test/components/views/messages/MPollBody-test.tsx +++ b/test/components/views/messages/MPollBody-test.tsx @@ -42,7 +42,7 @@ 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 CHECKED = "mx_PollOption_checked"; const userId = "@me:example.com"; const mockClient = getMockClientWithEventEmitter({ @@ -383,7 +383,7 @@ describe("MPollBody", () => { const votes: MatrixEvent[] = []; const ends: MatrixEvent[] = []; const { container } = await newMPollBody(votes, ends, answers); - expect(container.querySelectorAll(".mx_MPollBody_option").length).toBe(20); + expect(container.querySelectorAll(".mx_PollOption").length).toBe(20); }); it("hides scores if I voted but the poll is undisclosed", async () => { @@ -429,7 +429,7 @@ describe("MPollBody", () => { ]; const ends = [newPollEndEvent("@me:example.com", 12)]; const renderResult = await newMPollBody(votes, ends, undefined, false); - expect(endedVotesCount(renderResult, "pizza")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "pizza")).toBe('
3 votes'); expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe("1 vote"); @@ -531,9 +531,9 @@ describe("MPollBody", () => { const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); + expect(endedVotesCount(renderResult, "poutine")).toBe('
1 vote'); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("1 vote"); + expect(endedVotesCount(renderResult, "wings")).toBe('
1 vote'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 2 votes"); }); @@ -542,7 +542,7 @@ describe("MPollBody", () => { const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody(votes, ends); expect(endedVotesCount(renderResult, "pizza")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "poutine")).toBe("1 vote"); + expect(endedVotesCount(renderResult, "poutine")).toBe('
1 vote'); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); expect(endedVotesCount(renderResult, "wings")).toBe("0 votes"); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 1 vote"); @@ -564,7 +564,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -584,7 +584,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -607,7 +607,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -634,7 +634,7 @@ describe("MPollBody", () => { expect(endedVotesCount(renderResult, "pizza")).toBe("2 votes"); expect(endedVotesCount(renderResult, "poutine")).toBe("0 votes"); expect(endedVotesCount(renderResult, "italian")).toBe("0 votes"); - expect(endedVotesCount(renderResult, "wings")).toBe("3 votes"); + expect(endedVotesCount(renderResult, "wings")).toBe('
3 votes'); expect(renderResult.getByTestId("totalVotes").innerHTML).toBe("Final result based on 5 votes"); }); @@ -653,8 +653,8 @@ describe("MPollBody", () => { expect(endedVoteChecked(renderResult, "pizza")).toBe(false); // Double-check by looking for the endedOptionWinner class - expect(endedVoteDiv(renderResult, "wings").className.includes("mx_MPollBody_endedOptionWinner")).toBe(true); - expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_MPollBody_endedOptionWinner")).toBe(false); + expect(endedVoteDiv(renderResult, "wings").className.includes("mx_PollOption_endedOptionWinner")).toBe(true); + expect(endedVoteDiv(renderResult, "pizza").className.includes("mx_PollOption_endedOptionWinner")).toBe(false); }); it("highlights multiple winning votes", async () => { @@ -670,13 +670,13 @@ describe("MPollBody", () => { expect(endedVoteChecked(renderResult, "wings")).toBe(true); expect(endedVoteChecked(renderResult, "poutine")).toBe(true); expect(endedVoteChecked(renderResult, "italian")).toBe(false); - expect(renderResult.container.getElementsByClassName("mx_MPollBody_option_checked")).toHaveLength(3); + expect(renderResult.container.getElementsByClassName(CHECKED)).toHaveLength(3); }); it("highlights nothing if poll has no votes", async () => { const ends = [newPollEndEvent("@me:example.com", 25)]; const renderResult = await newMPollBody([], ends); - expect(renderResult.container.getElementsByClassName("mx_MPollBody_option_checked")).toHaveLength(0); + expect(renderResult.container.getElementsByClassName(CHECKED)).toHaveLength(0); }); it("says poll is not ended if there is no end event", async () => { @@ -745,7 +745,7 @@ describe("MPollBody", () => { expect(inputs[0].getAttribute("value")).toEqual("n1"); expect(inputs[1].getAttribute("value")).toEqual("n2"); expect(inputs[2].getAttribute("value")).toEqual("n3"); - const options = container.querySelectorAll(".mx_MPollBody_optionText"); + const options = container.querySelectorAll(".mx_PollOption_optionText"); expect(options).toHaveLength(3); expect(options[0].innerHTML).toEqual("new answer 1"); expect(options[1].innerHTML).toEqual("new answer 2"); @@ -934,11 +934,11 @@ function voteButton({ getByTestId }: RenderResult, value: string): Element { } function votesCount({ getByTestId }: RenderResult, value: string): string { - return getByTestId(`pollOption-${value}`).querySelector(".mx_MPollBody_optionVoteCount")!.innerHTML; + return getByTestId(`pollOption-${value}`).querySelector(".mx_PollOption_optionVoteCount")!.innerHTML; } function endedVoteChecked({ getByTestId }: RenderResult, value: string): boolean { - return getByTestId(`pollOption-${value}`).className.includes("mx_MPollBody_option_checked"); + return getByTestId(`pollOption-${value}`).className.includes(CHECKED); } function endedVoteDiv({ getByTestId }: RenderResult, value: string): Element { diff --git a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap index 7bc530048c..3bce56a540 100644 --- a/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap +++ b/test/components/views/messages/__snapshots__/MPollBody-test.tsx.snap @@ -14,129 +14,132 @@ exports[`MPollBody renders a finished poll 1`] = ` class="mx_MPollBody_allOptions" >
Pizza
0 votes
Poutine
0 votes
Italian
+
2 votes
Wings
1 vote
@@ -166,129 +169,135 @@ exports[`MPollBody renders a finished poll with multiple winners 1`] = ` class="mx_MPollBody_allOptions" >
Pizza
+
2 votes
Poutine
0 votes
Italian
0 votes
Wings
+
2 votes
@@ -318,129 +327,129 @@ exports[`MPollBody renders a finished poll with no votes 1`] = ` class="mx_MPollBody_allOptions" >
Pizza
0 votes
Poutine
0 votes
Italian
0 votes
Wings
0 votes
@@ -472,11 +481,11 @@ exports[`MPollBody renders a poll that I have not voted in 1`] = ` class="mx_MPollBody_allOptions" >
@@ -672,11 +689,11 @@ exports[`MPollBody renders a poll with local, non-local and invalid votes 1`] = class="mx_MPollBody_allOptions" >