diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 8c2e8860770..a2805690aa2 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -187,7 +187,8 @@ export interface IRoomState { upgradeRecommendation?: IRecommendedVersion; canReact: boolean; - canReply: boolean; + canSendMessages: boolean; + tombstone?: MatrixEvent; layout: Layout; lowBandwidth: boolean; alwaysShowTimestamps: boolean; @@ -259,7 +260,7 @@ export class RoomView extends React.Component { showTopUnreadMessagesBar: false, statusBarVisible: false, canReact: false, - canReply: false, + canSendMessages: false, layout: SettingsStore.getValue("layout"), lowBandwidth: SettingsStore.getValue("lowBandwidth"), alwaysShowTimestamps: SettingsStore.getValue("alwaysShowTimestamps"), @@ -371,12 +372,6 @@ export class RoomView extends React.Component { : MainSplitContentType.Timeline; }; - private onReadReceiptsChange = () => { - this.setState({ - showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId), - }); - }; - private onRoomViewStoreUpdate = async (initial?: boolean): Promise => { if (this.unmounted) { return; @@ -1033,10 +1028,15 @@ export class RoomView extends React.Component { this.checkWidgets(room); this.setState({ + tombstone: this.getRoomTombstone(), liveTimeline: room.getLiveTimeline(), }); }; + private getRoomTombstone() { + return this.state.room?.currentState.getStateEvents(EventType.RoomTombstone, ""); + } + private async calculateRecommendedVersion(room: Room) { const upgradeRecommendation = await room.getRecommendedVersion(); if (this.unmounted) return; @@ -1167,17 +1167,23 @@ export class RoomView extends React.Component { // ignore if we don't have a room yet if (!this.state.room || this.state.room.roomId !== state.roomId) return; - if (ev.getType() === EventType.RoomCanonicalAlias) { - // re-view the room so MatrixChat can manage the alias in the URL properly - dis.dispatch({ - action: Action.ViewRoom, - room_id: this.state.room.roomId, - metricsTrigger: undefined, // room doesn't change - }); - return; // this event cannot affect permissions so bail - } + switch (ev.getType()) { + case EventType.RoomCanonicalAlias: + // re-view the room so MatrixChat can manage the alias in the URL properly + dis.dispatch({ + action: Action.ViewRoom, + room_id: this.state.room.roomId, + metricsTrigger: undefined, // room doesn't change + }); + break; + + case EventType.RoomTombstone: + this.setState({ tombstone: this.getRoomTombstone() }); + break; - this.updatePermissions(this.state.room); + default: + this.updatePermissions(this.state.room); + } }; private onRoomStateUpdate = (state: RoomState) => { @@ -1201,9 +1207,9 @@ export class RoomView extends React.Component { if (room) { const me = this.context.getUserId(); const canReact = room.getMyMembership() === "join" && room.currentState.maySendEvent("m.reaction", me); - const canReply = room.maySendMessage(); + const canSendMessages = room.maySendMessage(); - this.setState({ canReact, canReply }); + this.setState({ canReact, canSendMessages }); } } diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index d0e259f8879..cb906fc926c 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -334,7 +334,7 @@ export default class MessageActionBar extends React.PureComponent { VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate); this.state = { - tombstone: this.getRoomTombstone(), - canSendMessages: this.props.room.maySendMessage(), isComposerEmpty: true, haveRecording: false, recordingTimeLeftSeconds: null, // when set to a number, shows a toast @@ -154,7 +149,6 @@ export default class MessageComposer extends React.Component { public componentDidMount() { this.dispatcherRef = dis.register(this.onAction); - MatrixClientPeg.get().on(RoomStateEvent.Events, this.onRoomStateEvents); this.waitForOwnMember(); UIStore.instance.trackElementDimensions(`MessageComposer${this.instanceId}`, this.ref.current); UIStore.instance.on(`MessageComposer${this.instanceId}`, this.onResize); @@ -217,9 +211,6 @@ export default class MessageComposer extends React.Component { } public componentWillUnmount() { - if (MatrixClientPeg.get()) { - MatrixClientPeg.get().removeListener(RoomStateEvent.Events, this.onRoomStateEvents); - } VoiceRecordingStore.instance.off(UPDATE_EVENT, this.onVoiceStoreUpdate); dis.unregister(this.dispatcherRef); UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`); @@ -229,25 +220,10 @@ export default class MessageComposer extends React.Component { this.voiceRecording = null; } - private onRoomStateEvents = (ev: MatrixEvent) => { - if (ev.getRoomId() !== this.props.room.roomId) return; - - if (ev.getType() === EventType.RoomTombstone) { - this.setState({ tombstone: this.getRoomTombstone() }); - } - if (ev.getType() === EventType.RoomPowerLevels) { - this.setState({ canSendMessages: this.props.room.maySendMessage() }); - } - }; - - private getRoomTombstone() { - return this.props.room.currentState.getStateEvents(EventType.RoomTombstone, ''); - } - private onTombstoneClick = (ev) => { ev.preventDefault(); - const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; + const replacementRoomId = this.context.tombstone.getContent()['replacement_room']; const replacementRoom = MatrixClientPeg.get().getRoom(replacementRoomId); let createEventId = null; if (replacementRoom) { @@ -255,7 +231,7 @@ export default class MessageComposer extends React.Component { if (createEvent && createEvent.getId()) createEventId = createEvent.getId(); } - const viaServers = [this.state.tombstone.getSender().split(':').slice(1).join(':')]; + const viaServers = [this.context.tombstone.getSender().split(':').slice(1).join(':')]; dis.dispatch({ action: Action.ViewRoom, highlighted: true, @@ -374,7 +350,8 @@ export default class MessageComposer extends React.Component { menuPosition = aboveLeftOf(contentRect); } - if (!this.state.tombstone && this.state.canSendMessages) { + const canSendMessages = this.context.canSendMessages && !this.context.tombstone; + if (canSendMessages) { controls.push( { key="controls_voice_record" ref={this.voiceRecordingButton} room={this.props.room} />); - } else if (this.state.tombstone) { - const replacementRoomId = this.state.tombstone.getContent()['replacement_room']; + } else if (this.context.tombstone) { + const replacementRoomId = this.context.tombstone.getContent()['replacement_room']; const continuesLink = replacementRoomId ? ( { permalinkCreator={this.props.permalinkCreator} />
{ controls } - { this.state.canSendMessages && { excludedRightPanelPhaseButtons: [], }; + static contextType = RoomContext; + public context!: React.ContextType; + constructor(props, context) { super(props, context); const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room); @@ -200,7 +204,10 @@ export default class RoomHeader extends React.Component { const buttons: JSX.Element[] = []; - if (this.props.inRoom && SettingsStore.getValue("showCallButtonsInComposer")) { + if (this.props.inRoom && + !this.context.tombstone && + SettingsStore.getValue("showCallButtonsInComposer") + ) { const voiceCallButton = this.props.onCallPlaced(CallType.Voice)} diff --git a/src/contexts/RoomContext.ts b/src/contexts/RoomContext.ts index bcc7517a7b7..0c8f20686c7 100644 --- a/src/contexts/RoomContext.ts +++ b/src/contexts/RoomContext.ts @@ -46,7 +46,7 @@ const RoomContext = createContext({ showTopUnreadMessagesBar: false, statusBarVisible: false, canReact: false, - canReply: false, + canSendMessages: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/test/components/structures/MessagePanel-test.js b/test/components/structures/MessagePanel-test.js index aaf24e136d3..14ff34e3f97 100644 --- a/test/components/structures/MessagePanel-test.js +++ b/test/components/structures/MessagePanel-test.js @@ -50,7 +50,7 @@ class WrappedMessagePanel extends React.Component { room, roomId: room.roomId, canReact: true, - canReply: true, + canSendMessages: true, showReadReceipts: true, showRedactions: false, showJoinLeaves: false, diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx new file mode 100644 index 00000000000..7b4b0f0b785 --- /dev/null +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -0,0 +1,90 @@ +/* +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 * as React from "react"; +import { mount, ReactWrapper } from "enzyme"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; +import { MatrixEvent } from "matrix-js-sdk/src/models/event"; + +import '../../../skinned-sdk'; // Must be first for skinning to work +import { createTestClient, mkEvent, mkStubRoom, 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"; + +describe("MessageComposer", () => { + stubClient(); + const cli = createTestClient(); + const room = mkStubRoom("!roomId:server", "Room 1", cli); + + it("Renders a SendMessageComposer and MessageComposerButtons by default", () => { + const wrapper = wrapAndRender(( + + )); + + expect(wrapper.find("SendMessageComposer")).toHaveLength(1); + expect(wrapper.find("MessageComposerButtons")).toHaveLength(1); + }); + + it("Does not render a SendMessageComposer or MessageComposerButtons when user has no permission", () => { + const wrapper = wrapAndRender(( + + ), false); + + expect(wrapper.find("SendMessageComposer")).toHaveLength(0); + expect(wrapper.find("MessageComposerButtons")).toHaveLength(0); + expect(wrapper.find(".mx_MessageComposer_noperm_error")).toHaveLength(1); + }); + + it("Does not render a SendMessageComposer or MessageComposerButtons when room is tombstoned", () => { + const wrapper = wrapAndRender(( + + ), true, mkEvent({ + event: true, + type: "m.room.tombstone", + room: room.roomId, + user: "@user1:server", + skey: "", + content: {}, + ts: Date.now(), + })); + + expect(wrapper.find("SendMessageComposer")).toHaveLength(0); + expect(wrapper.find("MessageComposerButtons")).toHaveLength(0); + expect(wrapper.find(".mx_MessageComposer_roomReplaced_header")).toHaveLength(1); + }); +}); + +function wrapAndRender(component: React.ReactElement, canSendMessages = true, tombstone?: MatrixEvent): ReactWrapper { + const mockClient = MatrixClientPeg.get(); + const roomId = "myroomid"; + const room: any = { + currentState: undefined, + roomId, + client: mockClient, + getMember: function(userId: string): RoomMember { + return new RoomMember(roomId, userId); + }, + }; + return mount( + + + { component } + + , + ); +} diff --git a/test/components/views/rooms/MessageComposerButtons-test.tsx b/test/components/views/rooms/MessageComposerButtons-test.tsx index b6fb8bbdab6..eaab180a5d4 100644 --- a/test/components/views/rooms/MessageComposerButtons-test.tsx +++ b/test/components/views/rooms/MessageComposerButtons-test.tsx @@ -160,7 +160,7 @@ function createRoomState(room: Room, narrow: boolean): IRoomState { showTopUnreadMessagesBar: false, statusBarVisible: false, canReact: false, - canReply: false, + canSendMessages: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/test/components/views/rooms/RoomHeader-test.tsx b/test/components/views/rooms/RoomHeader-test.tsx index e27376e0c17..9cff224f480 100644 --- a/test/components/views/rooms/RoomHeader-test.tsx +++ b/test/components/views/rooms/RoomHeader-test.tsx @@ -1,5 +1,5 @@ import React from 'react'; -import ReactDOM from 'react-dom'; +import { mount, ReactWrapper } from 'enzyme'; import { Room, PendingEventOrdering, MatrixEvent, MatrixClient } from 'matrix-js-sdk/src/matrix'; import "../../../skinned-sdk"; @@ -11,6 +11,8 @@ import { SearchScope } from '../../../../src/components/views/rooms/SearchBar'; import { E2EStatus } from '../../../../src/utils/ShieldUtils'; import { PlaceCallType } from '../../../../src/CallHandler'; import { mkEvent } from '../../../test-utils'; +import { IRoomState } from "../../../../src/components/structures/RoomView"; +import RoomContext from '../../../../src/contexts/RoomContext'; describe('RoomHeader', () => { it('shows the room avatar in a room with only ourselves', () => { @@ -20,11 +22,11 @@ describe('RoomHeader', () => { // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); - expect(initial.innerHTML).toEqual("X"); + expect(initial.text()).toEqual("X"); // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.src).toEqual("data:image/png;base64,00"); + expect(image.prop("src")).toEqual("data:image/png;base64,00"); }); it('shows the room avatar in a room with 2 people', () => { @@ -35,26 +37,25 @@ describe('RoomHeader', () => { // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); - expect(initial.innerHTML).toEqual("Y"); + expect(initial.text()).toEqual("Y"); // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.src).toEqual("data:image/png;base64,00"); + expect(image.prop("src")).toEqual("data:image/png;base64,00"); }); it('shows the room avatar in a room with >2 people', () => { // When we render a non-DM room with 3 people in it - const room = createRoom( - { name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); + const room = createRoom({ name: "Z Room", isDm: false, userIds: ["other1", "other2"] }); const rendered = render(room); // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); - expect(initial.innerHTML).toEqual("Z"); + expect(initial.text()).toEqual("Z"); // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.src).toEqual("data:image/png;base64,00"); + expect(image.prop("src")).toEqual("data:image/png;base64,00"); }); it('shows the room avatar in a DM with only ourselves', () => { @@ -64,11 +65,11 @@ describe('RoomHeader', () => { // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); - expect(initial.innerHTML).toEqual("Z"); + expect(initial.text()).toEqual("Z"); // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.src).toEqual("data:image/png;base64,00"); + expect(image.prop("src")).toEqual("data:image/png;base64,00"); }); it('shows the user avatar in a DM with 2 people', () => { @@ -81,13 +82,10 @@ describe('RoomHeader', () => { // Then we use the other user's avatar as our room's image avatar const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.src).toEqual( - "http://this.is.a.url/example.org/other"); + expect(image.prop("src")).toEqual("http://this.is.a.url/example.org/other"); // And there is no initial avatar - expect( - rendered.querySelectorAll(".mx_BaseAvatar_initial"), - ).toHaveLength(0); + expect(rendered.find(".mx_BaseAvatar_initial")).toHaveLength(0); }); it('shows the room avatar in a DM with >2 people', () => { @@ -98,11 +96,37 @@ describe('RoomHeader', () => { // Then the room's avatar is the initial of its name const initial = findSpan(rendered, ".mx_BaseAvatar_initial"); - expect(initial.innerHTML).toEqual("Z"); + expect(initial.text()).toEqual("Z"); // And there is no image avatar (because it's not set on this room) const image = findImg(rendered, ".mx_BaseAvatar_image"); - expect(image.src).toEqual("data:image/png;base64,00"); + expect(image.prop("src")).toEqual("data:image/png;base64,00"); + }); + + it("renders call buttons normally", () => { + const room = createRoom({ name: "Room", isDm: false, userIds: [] }); + const wrapper = render(room); + + expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(1); + expect(wrapper.find('[aria-label="Video call"]').hostNodes()).toHaveLength(1); + }); + + it("hides call buttons when the room is tombstoned", () => { + const room = createRoom({ name: "Room", isDm: false, userIds: [] }); + const wrapper = render(room, { + tombstone: mkEvent({ + event: true, + type: "m.room.tombstone", + room: room.roomId, + user: "@user1:server", + skey: "", + content: {}, + ts: Date.now(), + }), + }); + + expect(wrapper.find('[aria-label="Voice call"]').hostNodes()).toHaveLength(0); + expect(wrapper.find('[aria-label="Video call"]').hostNodes()).toHaveLength(0); }); }); @@ -147,11 +171,9 @@ function createRoom(info: IRoomCreationInfo) { return room; } -function render(room: Room): HTMLDivElement { - const parentDiv = document.createElement('div'); - document.body.appendChild(parentDiv); - ReactDOM.render( - ( +function render(room: Room, roomContext?: Partial): ReactWrapper { + return mount(( + - ), - parentDiv, - ); - return parentDiv; + + )); } function mkCreationEvent(roomId: string, userId: string): MatrixEvent { @@ -233,14 +253,14 @@ function mkDirectEvent( }); } -function findSpan(parent: HTMLElement, selector: string): HTMLSpanElement { - const els = parent.querySelectorAll(selector); - expect(els.length).toEqual(1); - return els[0] as HTMLSpanElement; +function findSpan(wrapper: ReactWrapper, selector: string): ReactWrapper { + const els = wrapper.find(selector).hostNodes(); + expect(els).toHaveLength(1); + return els.at(0); } -function findImg(parent: HTMLElement, selector: string): HTMLImageElement { - const els = parent.querySelectorAll(selector); - expect(els.length).toEqual(1); - return els[0] as HTMLImageElement; +function findImg(wrapper: ReactWrapper, selector: string): ReactWrapper { + const els = wrapper.find(selector).hostNodes(); + expect(els).toHaveLength(1); + return els.at(0); } diff --git a/test/components/views/rooms/SendMessageComposer-test.tsx b/test/components/views/rooms/SendMessageComposer-test.tsx index dfd58b9e696..cd985933854 100644 --- a/test/components/views/rooms/SendMessageComposer-test.tsx +++ b/test/components/views/rooms/SendMessageComposer-test.tsx @@ -58,7 +58,7 @@ describe('', () => { showTopUnreadMessagesBar: false, statusBarVisible: false, canReact: false, - canReply: false, + canSendMessages: false, layout: Layout.Group, lowBandwidth: false, alwaysShowTimestamps: false, diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index ae493302c4c..c5cfd88f4ac 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -13,6 +13,7 @@ import { RoomState, EventType, IEventRelation, + IUnsigned, } from 'matrix-js-sdk/src/matrix'; import { MatrixClientPeg as peg } from '../../src/MatrixClientPeg'; @@ -132,6 +133,7 @@ export function createTestClient() { setPusher: jest.fn().mockResolvedValue(), setPushRuleEnabled: jest.fn().mockResolvedValue(), setPushRuleActions: jest.fn().mockResolvedValue(), + isCryptoEnabled: jest.fn().mockReturnValue(false), }; } @@ -147,6 +149,7 @@ type MakeEventProps = MakeEventPassThruProps & { room: Room["roomId"]; // eslint-disable-next-line camelcase prev_content?: IContent; + unsigned?: IUnsigned; }; /** @@ -176,13 +179,13 @@ export function mkEvent(opts: MakeEventProps): MatrixEvent { origin_server_ts: opts.ts ?? 0, unsigned: opts.unsigned, }; - if (opts.skey) { + if (opts.skey !== undefined) { event.state_key = opts.skey; } else if ([ "m.room.name", "m.room.topic", "m.room.create", "m.room.join_rules", "m.room.power_levels", "m.room.topic", "m.room.history_visibility", "m.room.encryption", "m.room.member", "com.example.state", - "m.room.guest_access", + "m.room.guest_access", "m.room.tombstone", ].indexOf(opts.type) !== -1) { event.state_key = ""; }