Hide composer and call buttons when the room is tombstoned (#7975)

This commit is contained in:
Michael Telatynski 2022-03-04 15:53:22 +00:00 committed by GitHub
parent 675b4271e9
commit 0e60ad98c1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 196 additions and 93 deletions

View file

@ -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<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
: MainSplitContentType.Timeline;
};
private onReadReceiptsChange = () => {
this.setState({
showReadReceipts: SettingsStore.getValue("showReadReceipts", this.state.roomId),
});
};
private onRoomViewStoreUpdate = async (initial?: boolean): Promise<void> => {
if (this.unmounted) {
return;
@ -1033,10 +1028,15 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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<IRoomProps, IRoomState> {
// 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<ViewRoomPayload>({
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<ViewRoomPayload>({
action: Action.ViewRoom,
room_id: this.state.room.roomId,
metricsTrigger: undefined, // room doesn't change
});
break;
this.updatePermissions(this.state.room);
case EventType.RoomTombstone:
this.setState({ tombstone: this.getRoomTombstone() });
break;
default:
this.updatePermissions(this.state.room);
}
};
private onRoomStateUpdate = (state: RoomState) => {
@ -1201,9 +1207,9 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
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 });
}
}

View file

@ -334,7 +334,7 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
// Like the resend button, the react and reply buttons need to appear before the edit.
// The only catch is we do the reply button first so that we can make sure the react
// button is the very first button without having to do length checks for `splice()`.
if (this.context.canReply) {
if (this.context.canSendMessages) {
if (this.showReplyInThreadAction) {
toolbarOpts.splice(0, 0, threadTooltipButton);
}

View file

@ -20,7 +20,6 @@ import { IEventRelation, MatrixEvent } from "matrix-js-sdk/src/models/event";
import { Room } from "matrix-js-sdk/src/models/room";
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
import { EventType, RelationType } from 'matrix-js-sdk/src/@types/event';
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { Optional } from "matrix-events-sdk";
import { _t } from '../../../languageHandler';
@ -81,8 +80,6 @@ interface IProps {
}
interface IState {
tombstone: MatrixEvent;
canSendMessages: boolean;
isComposerEmpty: boolean;
haveRecording: boolean;
recordingTimeLeftSeconds?: number;
@ -114,8 +111,6 @@ export default class MessageComposer extends React.Component<IProps, IState> {
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<IProps, IState> {
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<IProps, IState> {
}
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<IProps, IState> {
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<IProps, IState> {
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<ViewRoomPayload>({
action: Action.ViewRoom,
highlighted: true,
@ -374,7 +350,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
menuPosition = aboveLeftOf(contentRect);
}
if (!this.state.tombstone && this.state.canSendMessages) {
const canSendMessages = this.context.canSendMessages && !this.context.tombstone;
if (canSendMessages) {
controls.push(
<SendMessageComposer
ref={this.messageComposerInput}
@ -393,8 +370,8 @@ export default class MessageComposer extends React.Component<IProps, IState> {
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 ? (
<a href={makeRoomPermalink(replacementRoomId)}
@ -467,7 +444,7 @@ export default class MessageComposer extends React.Component<IProps, IState> {
permalinkCreator={this.props.permalinkCreator} />
<div className="mx_MessageComposer_row">
{ controls }
{ this.state.canSendMessages && <MessageComposerButtons
{ canSendMessages && <MessageComposerButtons
addEmoji={this.addEmoji}
haveRecording={this.state.haveRecording}
isMenuOpen={this.state.isMenuOpen}

View file

@ -40,6 +40,7 @@ import { contextMenuBelow } from './RoomTile';
import { RoomNotificationStateStore } from '../../../stores/notifications/RoomNotificationStateStore';
import { RightPanelPhases } from '../../../stores/right-panel/RightPanelStorePhases';
import { NotificationStateEvents } from '../../../stores/notifications/NotificationState';
import RoomContext from "../../../contexts/RoomContext";
export interface ISearchInfo {
searchTerm: string;
@ -73,6 +74,9 @@ export default class RoomHeader extends React.Component<IProps, IState> {
excludedRightPanelPhaseButtons: [],
};
static contextType = RoomContext;
public context!: React.ContextType<typeof RoomContext>;
constructor(props, context) {
super(props, context);
const notiStore = RoomNotificationStateStore.instance.getRoomState(props.room);
@ -200,7 +204,10 @@ export default class RoomHeader extends React.Component<IProps, IState> {
const buttons: JSX.Element[] = [];
if (this.props.inRoom && SettingsStore.getValue("showCallButtonsInComposer")) {
if (this.props.inRoom &&
!this.context.tombstone &&
SettingsStore.getValue("showCallButtonsInComposer")
) {
const voiceCallButton = <AccessibleTooltipButton
className="mx_RoomHeader_button mx_RoomHeader_voiceCallButton"
onClick={() => this.props.onCallPlaced(CallType.Voice)}

View file

@ -46,7 +46,7 @@ const RoomContext = createContext<IRoomState>({
showTopUnreadMessagesBar: false,
statusBarVisible: false,
canReact: false,
canReply: false,
canSendMessages: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,

View file

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

View file

@ -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((
<MessageComposer room={room} resizeNotifier={jest.fn()} permalinkCreator={jest.fn()} />
));
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((
<MessageComposer room={room} resizeNotifier={jest.fn()} permalinkCreator={jest.fn()} />
), 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((
<MessageComposer room={room} resizeNotifier={jest.fn()} permalinkCreator={jest.fn()} />
), 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(
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={{ room, canSendMessages, tombstone }}>
{ component }
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
}

View file

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

View file

@ -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<IRoomState>): ReactWrapper {
return mount((
<RoomContext.Provider value={{ ...roomContext, room }}>
<RoomHeader
room={room}
inRoom={true}
@ -167,10 +189,8 @@ function render(room: Room): HTMLDivElement {
searchCount: 0,
}}
/>
),
parentDiv,
);
return parentDiv;
</RoomContext.Provider>
));
}
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);
}

View file

@ -58,7 +58,7 @@ describe('<SendMessageComposer/>', () => {
showTopUnreadMessagesBar: false,
statusBarVisible: false,
canReact: false,
canReply: false,
canSendMessages: false,
layout: Layout.Group,
lowBandwidth: false,
alwaysShowTimestamps: false,

View file

@ -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 = "";
}