diff --git a/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog.tsx b/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog.tsx new file mode 100644 index 0000000000..b55598a6b5 --- /dev/null +++ b/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog.tsx @@ -0,0 +1,36 @@ +/* +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 from "react"; + +import { _t } from "../../../languageHandler"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; + +export const createCantStartVoiceMessageBroadcastDialog = (): void => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start voice message"), + description: ( +

+ {_t( + "You can't start a voice message as you are currently recording a live broadcast. " + + "Please end your live broadcast in order to start recording a voice message.", + )} +

+ ), + hasCloseButton: true, + }); +}; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index d7e9a5f71a..799fc98d64 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -58,6 +58,8 @@ import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysi import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext"; import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { VoiceBroadcastInfoState } from "../../../voice-broadcast"; +import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStartVoiceMessageBroadcastDialog"; let instanceCount = 0; @@ -445,6 +447,20 @@ export class MessageComposer extends React.Component { } } + private onRecordStartEndClick = (): void => { + const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent(); + + if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) { + createCantStartVoiceMessageBroadcastDialog(); + } else { + this.voiceRecordingButton.current?.onRecordStartEndClick(); + } + + if (this.context.narrow) { + this.toggleButtonMenu(); + } + }; + public render() { const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus); const e2eIcon = hasE2EIcon && ( @@ -588,12 +604,7 @@ export class MessageComposer extends React.Component { isStickerPickerOpen={this.state.isStickerPickerOpen} menuPosition={menuPosition} relation={this.props.relation} - onRecordStartEndClick={() => { - this.voiceRecordingButton.current?.onRecordStartEndClick(); - if (this.context.narrow) { - this.toggleButtonMenu(); - } - }} + onRecordStartEndClick={this.onRecordStartEndClick} setStickerPickerOpen={this.setStickerPickerOpen} showLocationButton={!window.electron} showPollsButton={this.state.showPollsButton} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cbefb01830..d4a8b522b8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2688,6 +2688,8 @@ "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)", "Remove %(count)s messages|other": "Remove %(count)s messages", "Remove %(count)s messages|one": "Remove 1 message", + "Can't start voice message": "Can't start voice message", + "You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message.": "You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message.", "Unable to load commit detail: %(msg)s": "Unable to load commit detail: %(msg)s", "Unavailable": "Unavailable", "Changelog": "Changelog", diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index a20695320b..3f445b244d 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -15,15 +15,21 @@ limitations under the License. */ import * as React from "react"; -// eslint-disable-next-line deprecate/import -import { mount, ReactWrapper } from "enzyme"; -import { MatrixEvent, MsgType, RoomMember } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; -import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../test-utils"; -import MessageComposer, { - MessageComposer as MessageComposerClass, -} from "../../../../src/components/views/rooms/MessageComposer"; +import { + createTestClient, + filterConsole, + flushPromises, + mkEvent, + mkStubRoom, + mockPlatformPeg, + stubClient, +} from "../../../test-utils"; +import MessageComposer from "../../../../src/components/views/rooms/MessageComposer"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import RoomContext from "../../../../src/contexts/RoomContext"; @@ -31,42 +37,108 @@ import { IRoomState } from "../../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { LocalRoom } from "../../../../src/models/LocalRoom"; -import MessageComposerButtons from "../../../../src/components/views/rooms/MessageComposerButtons"; import { Features } from "../../../../src/settings/Settings"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; import dis from "../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../src/dispatcher/actions"; -import { SendMessageComposer } from "../../../../src/components/views/rooms/SendMessageComposer"; import { E2EStatus } from "../../../../src/utils/ShieldUtils"; -import { addTextToComposerEnzyme } from "../../../test-utils/composer"; +import { addTextToComposerRTL } from "../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore"; -import { SendWysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer"; +import { Action } from "../../../../src/dispatcher/actions"; +import { VoiceBroadcastInfoState, VoiceBroadcastRecording } from "../../../../src/voice-broadcast"; +import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; +import { SdkContextClass } from "../../../../src/contexts/SDKContext"; +import Modal from "../../../../src/Modal"; + +jest.mock("../../../../src/components/views/rooms/wysiwyg_composer", () => ({ + SendWysiwygComposer: jest.fn().mockImplementation(() =>
), +})); + +const openStickerPicker = async (): Promise => { + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + await userEvent.click(screen.getByLabelText("Sticker")); + }); +}; + +const startVoiceMessage = async (): Promise => { + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + await userEvent.click(screen.getByLabelText("Voice Message")); + }); +}; + +const setCurrentBroadcastRecording = (room: Room, state: VoiceBroadcastInfoState): void => { + const recording = new VoiceBroadcastRecording( + mkVoiceBroadcastInfoStateEvent(room.roomId, state, "@user:example.com", "ABC123"), + MatrixClientPeg.get(), + state, + ); + SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(recording); +}; + +const waitForModal = async (): Promise => { + await flushPromises(); + await flushPromises(); +}; + +const shouldClearModal = async (): Promise => { + afterEach(async () => { + Modal.closeCurrentModal("force"); + await waitForModal(); + }); +}; + +const expectVoiceMessageRecordingTriggered = (): void => { + // Checking for the voice message dialog text, if no mic can be found. + // By this we know at least that starting a voice message was triggered. + expect(screen.getByText("No microphone found")).toBeInTheDocument(); +}; describe("MessageComposer", () => { stubClient(); const cli = createTestClient(); + filterConsole("Starting load of AsyncWrapper for modal"); + + beforeEach(() => { + mockPlatformPeg(); + }); + + afterEach(() => { + jest.useRealTimers(); + + SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent(); + + // restore settings + act(() => { + [ + "MessageComposerInput.showStickersButton", + "MessageComposerInput.showPollsButton", + Features.VoiceBroadcast, + "feature_wysiwyg_composer", + ].forEach((setting: string): void => { + SettingsStore.setValue(setting, null, SettingLevel.DEVICE, SettingsStore.getDefaultValue(setting)); + }); + }); + }); + describe("for a Room", () => { const room = mkStubRoom("!roomId:server", "Room 1", cli); it("Renders a SendMessageComposer and MessageComposerButtons by default", () => { - const wrapper = wrapAndRender({ room }); - - expect(wrapper.find("SendMessageComposer")).toHaveLength(1); - expect(wrapper.find("MessageComposerButtons")).toHaveLength(1); + wrapAndRender({ room }); + expect(screen.getByLabelText("Send a message…")).toBeInTheDocument(); }); it("Does not render a SendMessageComposer or MessageComposerButtons when user has no permission", () => { - const wrapper = wrapAndRender({ room }, false); - - expect(wrapper.find("SendMessageComposer")).toHaveLength(0); - expect(wrapper.find("MessageComposerButtons")).toHaveLength(0); - expect(wrapper.find(".mx_MessageComposer_noperm_error")).toHaveLength(1); + wrapAndRender({ room }, false); + expect(screen.queryByLabelText("Send a message…")).not.toBeInTheDocument(); + expect(screen.getByText("You do not have permission to post to this room")).toBeInTheDocument(); }); it("Does not render a SendMessageComposer or MessageComposerButtons when room is tombstoned", () => { - const wrapper = wrapAndRender( + wrapAndRender( { room }, true, false, @@ -81,13 +153,12 @@ describe("MessageComposer", () => { }), ); - expect(wrapper.find("SendMessageComposer")).toHaveLength(0); - expect(wrapper.find("MessageComposerButtons")).toHaveLength(0); - expect(wrapper.find(".mx_MessageComposer_roomReplaced_header")).toHaveLength(1); + expect(screen.queryByLabelText("Send a message…")).not.toBeInTheDocument(); + expect(screen.getByText("This room has been replaced and is no longer active.")).toBeInTheDocument(); }); describe("when receiving a »reply_to_event«", () => { - let wrapper: ReactWrapper; + let roomContext: IRoomState; let resizeNotifier: ResizeNotifier; beforeEach(() => { @@ -95,18 +166,17 @@ describe("MessageComposer", () => { resizeNotifier = { notifyTimelineHeightChanged: jest.fn(), } as unknown as ResizeNotifier; - wrapper = wrapAndRender({ + roomContext = wrapAndRender({ room, resizeNotifier, - }); + }).roomContext; }); it("should call notifyTimelineHeightChanged() for the same context", () => { dis.dispatch({ action: "reply_to_event", - context: (wrapper.instance as unknown as MessageComposerClass).context, + context: roomContext.timelineRenderingType, }); - wrapper.update(); jest.advanceTimersByTime(150); expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalled(); @@ -117,7 +187,6 @@ describe("MessageComposer", () => { action: "reply_to_event", context: "test", }); - wrapper.update(); jest.advanceTimersByTime(150); expect(resizeNotifier.notifyTimelineHeightChanged).not.toHaveBeenCalled(); @@ -128,28 +197,33 @@ describe("MessageComposer", () => { [ { setting: "MessageComposerInput.showStickersButton", - prop: "showStickersButton", + buttonLabel: "Sticker", }, { setting: "MessageComposerInput.showPollsButton", - prop: "showPollsButton", + buttonLabel: "Poll", }, { setting: Features.VoiceBroadcast, - prop: "showVoiceBroadcastButton", + buttonLabel: "Voice broadcast", }, - ].forEach(({ setting, prop }) => { + ].forEach(({ setting, buttonLabel }) => { [true, false].forEach((value: boolean) => { describe(`when ${setting} = ${value}`, () => { - let wrapper: ReactWrapper; - - beforeEach(() => { + beforeEach(async () => { SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value); - wrapper = wrapAndRender({ room }); + wrapAndRender({ room }); + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + }); }); - it(`should pass the prop ${prop} = ${value}`, () => { - expect(wrapper.find(MessageComposerButtons).props()[prop]).toBe(value); + it(`should${value || "not"} display the button`, () => { + if (value) { + expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument(); + } else { + expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument(); + } }); describe(`and setting ${setting} to ${!value}`, () => { @@ -164,11 +238,14 @@ describe("MessageComposer", () => { }, true, ); - wrapper.update(); }); - it(`should pass the prop ${prop} = ${!value}`, () => { - expect(wrapper.find(MessageComposerButtons).props()[prop]).toBe(!value); + it(`should${!value || "not"} display the button`, () => { + if (!value) { + expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument(); + } else { + expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument(); + } }); }); }); @@ -176,26 +253,22 @@ describe("MessageComposer", () => { }); it("should not render the send button", () => { - const wrapper = wrapAndRender({ room }); - expect(wrapper.find("SendButton")).toHaveLength(0); + wrapAndRender({ room }); + expect(screen.queryByLabelText("Send message")).not.toBeInTheDocument(); }); describe("when a message has been entered", () => { - let wrapper: ReactWrapper; - - beforeEach(() => { - wrapper = wrapAndRender({ room }); - addTextToComposerEnzyme(wrapper, "Hello"); - wrapper.update(); + beforeEach(async () => { + const renderResult = wrapAndRender({ room }).renderResult; + await addTextToComposerRTL(renderResult, "Hello"); }); it("should render the send button", () => { - expect(wrapper.find("SendButton")).toHaveLength(1); + expect(screen.getByLabelText("Send message")).toBeInTheDocument(); }); }); describe("UIStore interactions", () => { - let wrapper: ReactWrapper; let resizeCallback: Function; beforeEach(() => { @@ -205,74 +278,74 @@ describe("MessageComposer", () => { }); describe("when a non-resize event occurred in UIStore", () => { - let stateBefore: any; - - beforeEach(() => { - wrapper = wrapAndRender({ room }).children(); - stateBefore = { ...wrapper.instance().state }; + beforeEach(async () => { + wrapAndRender({ room }); + await openStickerPicker(); resizeCallback("test", {}); - wrapper.update(); }); - it("should not change the state", () => { - expect(wrapper.instance().state).toEqual(stateBefore); + it("should still display the sticker picker", () => { + expect(screen.getByText("You don't currently have any stickerpacks enabled")).toBeInTheDocument(); }); }); describe("when a resize to narrow event occurred in UIStore", () => { - beforeEach(() => { - wrapper = wrapAndRender({ room }, true, true).children(); - - wrapper.setState({ - isMenuOpen: true, - isStickerPickerOpen: true, - }); + beforeEach(async () => { + wrapAndRender({ room }, true, true); + await openStickerPicker(); resizeCallback(UI_EVENTS.Resize, {}); - wrapper.update(); }); - it("isMenuOpen should be true", () => { - expect(wrapper.state("isMenuOpen")).toBe(true); + it("should close the menu", () => { + expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument(); }); - it("isStickerPickerOpen should be false", () => { - expect(wrapper.state("isStickerPickerOpen")).toBe(false); + it("should not show the attachment button", () => { + expect(screen.queryByLabelText("Attachment")).not.toBeInTheDocument(); + }); + + it("should close the sticker picker", () => { + expect( + screen.queryByText("You don't currently have any stickerpacks enabled"), + ).not.toBeInTheDocument(); }); }); describe("when a resize to non-narrow event occurred in UIStore", () => { - beforeEach(() => { - wrapper = wrapAndRender({ room }, true, false).children(); - wrapper.setState({ - isMenuOpen: true, - isStickerPickerOpen: true, - }); + beforeEach(async () => { + wrapAndRender({ room }, true, false); + await openStickerPicker(); resizeCallback(UI_EVENTS.Resize, {}); - wrapper.update(); }); - it("isMenuOpen should be false", () => { - expect(wrapper.state("isMenuOpen")).toBe(false); + it("should close the menu", () => { + expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument(); }); - it("isStickerPickerOpen should be false", () => { - expect(wrapper.state("isStickerPickerOpen")).toBe(false); + it("should show the attachment button", () => { + expect(screen.getByLabelText("Attachment")).toBeInTheDocument(); + }); + + it("should close the sticker picker", () => { + expect( + screen.queryByText("You don't currently have any stickerpacks enabled"), + ).not.toBeInTheDocument(); }); }); }); describe("when not replying to an event", () => { it("should pass the expected placeholder to SendMessageComposer", () => { - const wrapper = wrapAndRender({ room }); - expect(wrapper.find(SendMessageComposer).props().placeholder).toBe("Send a message…"); + wrapAndRender({ room }); + expect(screen.getByLabelText("Send a message…")).toBeInTheDocument(); }); it("and an e2e status it should pass the expected placeholder to SendMessageComposer", () => { - const wrapper = wrapAndRender({ + wrapAndRender({ room, e2eStatus: E2EStatus.Normal, }); - expect(wrapper.find(SendMessageComposer).props().placeholder).toBe("Send an encrypted message…"); + expect(screen.getByLabelText("Send an encrypted message…")).toBeInTheDocument(); }); }); @@ -282,8 +355,8 @@ describe("MessageComposer", () => { const checkPlaceholder = (expected: string) => { it("should pass the expected placeholder to SendMessageComposer", () => { - const wrapper = wrapAndRender(props); - expect(wrapper.find(SendMessageComposer).props().placeholder).toBe(expected); + wrapAndRender(props); + expect(screen.getByLabelText(expected)).toBeInTheDocument(); }); }; @@ -296,7 +369,7 @@ describe("MessageComposer", () => { beforeEach(() => { replyToEvent = mkEvent({ event: true, - type: MsgType.Text, + type: EventType.RoomMessage, user: cli.getUserId(), content: {}, }); @@ -337,25 +410,72 @@ describe("MessageComposer", () => { }); }); }); + + describe("when clicking start a voice message", () => { + beforeEach(async () => { + wrapAndRender({ room }); + await startVoiceMessage(); + await flushPromises(); + }); + + shouldClearModal(); + + it("should try to start a voice message", () => { + expectVoiceMessageRecordingTriggered(); + }); + }); + + describe("when recording a voice broadcast and trying to start a voice message", () => { + beforeEach(async () => { + setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Started); + wrapAndRender({ room }); + await startVoiceMessage(); + await waitForModal(); + }); + + shouldClearModal(); + + it("should not start a voice message and display the info dialog", async () => { + expect(screen.queryByLabelText("Stop recording")).not.toBeInTheDocument(); + expect(screen.getByText("Can't start voice message")).toBeInTheDocument(); + }); + }); + + describe("when there is a stopped voice broadcast recording and trying to start a voice message", () => { + beforeEach(async () => { + setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Stopped); + wrapAndRender({ room }); + await startVoiceMessage(); + await waitForModal(); + }); + + shouldClearModal(); + + it("should try to start a voice message and should not display the info dialog", async () => { + expect(screen.queryByText("Can't start voice message")).not.toBeInTheDocument(); + expectVoiceMessageRecordingTriggered(); + }); + }); }); describe("for a LocalRoom", () => { const localRoom = new LocalRoom("!room:example.com", cli, cli.getUserId()!); - it("should pass the sticker picker disabled prop", () => { - const wrapper = wrapAndRender({ room: localRoom }); - expect(wrapper.find(MessageComposerButtons).props().showStickersButton).toBe(false); + it("should not show the stickers button", async () => { + wrapAndRender({ room: localRoom }); + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + }); + expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument(); }); }); - it("should render SendWysiwygComposer", () => { + it("should render SendWysiwygComposer when enabled", () => { const room = mkStubRoom("!roomId:server", "Room 1", cli); - SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true); - const wrapper = wrapAndRender({ room }); - SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, false); - expect(wrapper.find(SendWysiwygComposer)).toBeTruthy(); + wrapAndRender({ room }); + expect(screen.getByTestId("wysiwyg-composer")).toBeInTheDocument(); }); }); @@ -364,7 +484,7 @@ function wrapAndRender( canSendMessages = true, narrow = false, tombstone?: MatrixEvent, -): ReactWrapper { +) { const mockClient = MatrixClientPeg.get(); const roomId = "myroomid"; const room: any = props.room || { @@ -376,7 +496,7 @@ function wrapAndRender( }, }; - const roomState = { + const roomContext = { room, canSendMessages, tombstone, @@ -389,11 +509,14 @@ function wrapAndRender( permalinkCreator: new RoomPermalinkCreator(room), }; - return mount( - - - - - , - ); + return { + renderResult: render( + + + + + , + ), + roomContext, + }; } diff --git a/test/test-utils/composer.ts b/test/test-utils/composer.ts index 443c7a934e..7a69be62a8 100644 --- a/test/test-utils/composer.ts +++ b/test/test-utils/composer.ts @@ -17,7 +17,8 @@ limitations under the License. // eslint-disable-next-line deprecate/import import { ReactWrapper } from "enzyme"; import { act } from "react-dom/test-utils"; -import { fireEvent } from "@testing-library/react"; +import { act as actRTL, fireEvent, RenderResult } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; export const addTextToComposer = (container: HTMLElement, text: string) => act(() => { @@ -47,3 +48,10 @@ export const addTextToComposerEnzyme = (wrapper: ReactWrapper, text: string) => wrapper.find('[role="textbox"]').simulate("paste", pasteEvent); wrapper.update(); }); + +export const addTextToComposerRTL = async (renderResult: RenderResult, text: string): Promise => { + await actRTL(async () => { + await userEvent.click(renderResult.getByLabelText("Send a message…")); + await userEvent.keyboard(text); + }); +};