Prevent starting a voice message while recording a broadcast (#9844)

pull/28217/head
Michael Weimann 2023-01-10 15:51:20 +01:00 committed by GitHub
parent d103860a94
commit 4627456837
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
5 changed files with 296 additions and 116 deletions

View File

@ -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: (
<p>
{_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.",
)}
</p>
),
hasCloseButton: true,
});
};

View File

@ -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<IProps, IState> {
}
}
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<IProps, IState> {
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}

View File

@ -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",

View File

@ -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(() => <div data-testid="wysiwyg-composer" />),
}));
const openStickerPicker = async (): Promise<void> => {
await act(async () => {
await userEvent.click(screen.getByLabelText("More options"));
await userEvent.click(screen.getByLabelText("Sticker"));
});
};
const startVoiceMessage = async (): Promise<void> => {
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<void> => {
await flushPromises();
await flushPromises();
};
const shouldClearModal = async (): Promise<void> => {
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(
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomState}>
<MessageComposer {...defaultProps} {...props} />
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
return {
renderResult: render(
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomContext}>
<MessageComposer {...defaultProps} {...props} />
</RoomContext.Provider>
</MatrixClientContext.Provider>,
),
roomContext,
};
}

View File

@ -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<void> => {
await actRTL(async () => {
await userEvent.click(renderResult.getByLabelText("Send a message…"));
await userEvent.keyboard(text);
});
};