diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index 020ca08bd32..a829acbd565 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -57,7 +57,7 @@ const startDMWithBob = function(this: CryptoTestContext) { }; const testMessages = function(this: CryptoTestContext) { - cy.get(".mx_BasicMessageComposer_input").should("have.focus").type("Hey!{enter}"); + // check the invite message cy.contains(".mx_EventTile_body", "Hey!") .closest(".mx_EventTile") .should("not.have.descendants", ".mx_EventTile_e2eIcon_warning") @@ -150,6 +150,11 @@ describe("Cryptography", function() { it("creating a DM should work, being e2e-encrypted / user verification", function(this: CryptoTestContext) { cy.bootstrapCrossSigning(); startDMWithBob.call(this); + // send first message + cy.get(".mx_BasicMessageComposer_input") + .click() + .should("have.focus") + .type("Hey!{enter}"); checkDMRoom(); bobJoin.call(this); testMessages.call(this); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index d81cbc939b8..fee1e390713 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -118,10 +118,13 @@ Cypress.Commands.add("startDM", (name: string) => { cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", name); cy.spotlightResults().eq(0).click(); - }).then(() => { - cy.roomHeaderName().should("contain", name); - cy.get(".mx_RoomSublist[aria-label=People]").should("contain", name); }); + // send first message to start DM + cy.get(".mx_BasicMessageComposer_input") + .should("have.focus") + .type("Hey!{enter}"); + cy.contains(".mx_EventTile_body", "Hey!"); + cy.get(".mx_RoomSublist[aria-label=People]").should("contain", name); }); describe("Spotlight", () => { @@ -324,11 +327,19 @@ describe("Spotlight", () => { cy.spotlightResults().should("have.length", 1); cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).click(); - }).then(() => { - cy.roomHeaderName().should("contain", bot2Name); - cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); }); + // Send first message to actually start DM + cy.roomHeaderName().should("contain", bot2Name); + cy.get(".mx_BasicMessageComposer_input") + .click() + .should("have.focus") + .type("Hey!{enter}"); + + // Assert DM exists by checking for the first message and the room being in the room list + cy.contains(".mx_EventTile_body", "Hey!"); + cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); + // Invite BotBob into existing DM with ByteBot cy.getDmRooms(bot2.getUserId()).then(dmRooms => dmRooms[0]) .then(groupDmId => cy.inviteUser(groupDmId, bot1.getUserId())) diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 552e71675fb..5cbf5e6e083 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -38,6 +38,7 @@ @import "./structures/_GenericErrorPage.pcss"; @import "./structures/_HeaderButtons.pcss"; @import "./structures/_HomePage.pcss"; +@import "./structures/_LargeLoader.pcss"; @import "./structures/_LeftPanel.pcss"; @import "./structures/_MainSplit.pcss"; @import "./structures/_MatrixChat.pcss"; diff --git a/res/css/structures/_LargeLoader.pcss b/res/css/structures/_LargeLoader.pcss new file mode 100644 index 00000000000..ba95ea56b65 --- /dev/null +++ b/res/css/structures/_LargeLoader.pcss @@ -0,0 +1,37 @@ +/* +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. +*/ + +.mx_LargeLoader { + align-items: center; + display: flex; + flex-direction: column; + height: 100%; + + .mx_Spinner { + flex: unset; + height: auto; + margin-bottom: 32px; + margin-top: 33vh; + } + + .mx_LargeLoader_text { + font-size: 24px; + font-weight: 600; + padding: 0 16px; + position: relative; + text-align: center; + } +} diff --git a/res/css/structures/_RoomView.pcss b/res/css/structures/_RoomView.pcss index dae85b857e9..65f11ca2aac 100644 --- a/res/css/structures/_RoomView.pcss +++ b/res/css/structures/_RoomView.pcss @@ -32,6 +32,13 @@ limitations under the License. position: relative; } +.mx_MainSplit_timeline, +.mx_RoomView--local { + .mx_MessageComposer_wrapper { + margin: $spacing-8 $spacing-16; + } +} + .mx_RoomView_auxPanel { min-width: 0px; width: 100%; @@ -183,6 +190,10 @@ limitations under the License. box-sizing: border-box; } +.mx_RoomView--local .mx_ScrollPanel .mx_RoomView_MessageList { + justify-content: center; +} + .mx_RoomView_MessageList li { clear: both; } diff --git a/src/components/structures/LargeLoader.tsx b/src/components/structures/LargeLoader.tsx new file mode 100644 index 00000000000..a5116daa902 --- /dev/null +++ b/src/components/structures/LargeLoader.tsx @@ -0,0 +1,37 @@ +/* +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 Spinner from "../views/elements/Spinner"; + +interface LargeLoaderProps { + text: string; +} + +/** + * Loader component that displays a (almost centered) spinner and loading message. + */ +export const LargeLoader: React.FC = ({ text }) => { + return ( +
+ +
+ { text } +
+
+ ); +}; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 1b2f02d8971..83eb8ead8bc 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -20,7 +20,7 @@ limitations under the License. // TODO: This component is enormous! There's several things which could stand-alone: // - Search results component -import React, { createRef } from 'react'; +import React, { createRef, ReactElement, ReactNode, RefObject, useContext } from 'react'; import classNames from 'classnames'; import { IRecommendedVersion, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IThreadBundledRelationship, MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; @@ -46,7 +46,7 @@ import ResizeNotifier from '../../utils/ResizeNotifier'; import ContentMessages from '../../ContentMessages'; import Modal from '../../Modal'; import CallHandler, { CallHandlerEvent } from '../../CallHandler'; -import dis from '../../dispatcher/dispatcher'; +import dis, { defaultDispatcher } from '../../dispatcher/dispatcher'; import * as Rooms from '../../Rooms'; import eventSearch, { searchPagination } from '../../Searching'; import MainSplit from './MainSplit'; @@ -110,7 +110,15 @@ import FileDropTarget from './FileDropTarget'; import Measured from '../views/elements/Measured'; import { FocusComposerPayload } from '../../dispatcher/payloads/FocusComposerPayload'; import { haveRendererForEvent } from "../../events/EventTileFactory"; +import { LocalRoom, LocalRoomState } from '../../models/LocalRoom'; +import { createRoomFromLocalRoom } from '../../utils/direct-messages'; +import NewRoomIntro from '../views/rooms/NewRoomIntro'; +import EncryptionEvent from '../views/messages/EncryptionEvent'; +import { StaticNotificationState } from '../../stores/notifications/StaticNotificationState'; +import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; import { ShowThreadPayload } from "../../dispatcher/payloads/ShowThreadPayload"; +import { RoomStatusBarUnsentMessages } from './RoomStatusBarUnsentMessages'; +import { LargeLoader } from './LargeLoader'; const DEBUG = false; let debuglog = function(msg: string) {}; @@ -222,6 +230,137 @@ export interface IRoomState { narrow: boolean; } +interface LocalRoomViewProps { + resizeNotifier: ResizeNotifier; + permalinkCreator: RoomPermalinkCreator; + roomView: RefObject; + onFileDrop: (dataTransfer: DataTransfer) => Promise; +} + +/** + * Local room view. Uses only the bits necessary to display a local room view like room header or composer. + * + * @param {LocalRoomViewProps} props Room view props + * @returns {ReactElement} + */ +function LocalRoomView(props: LocalRoomViewProps): ReactElement { + const context = useContext(RoomContext); + const room = context.room as LocalRoom; + const encryptionEvent = context.room.currentState.getStateEvents(EventType.RoomEncryption)[0]; + let encryptionTile: ReactNode; + + if (encryptionEvent) { + encryptionTile = ; + } + + const onRetryClicked = () => { + room.state = LocalRoomState.NEW; + defaultDispatcher.dispatch({ + action: "local_room_event", + roomId: room.roomId, + }); + }; + + let statusBar: ReactElement; + let composer: ReactElement; + + if (room.isError) { + const buttons = ( + + { _t("Retry") } + + ); + + statusBar = ; + } else { + composer = ; + } + + return ( +
+ + +
+ +
+ + { encryptionTile } + + +
+ { statusBar } + { composer } +
+
+
+ ); +} + +interface ILocalRoomCreateLoaderProps { + names: string; + resizeNotifier: ResizeNotifier; +} + +/** + * Room create loader view displaying a message and a spinner. + * + * @param {ILocalRoomCreateLoaderProps} props Room view props + * @return {ReactElement} + */ +function LocalRoomCreateLoader(props: ILocalRoomCreateLoaderProps): ReactElement { + const context = useContext(RoomContext); + const text = _t("We're creating a room with %(names)s", { names: props.names }); + return ( +
+ + +
+ +
+
+
+ ); +} + export class RoomView extends React.Component { private readonly dispatcherRef: string; private readonly roomStoreToken: EventSubscription; @@ -765,6 +904,11 @@ export class RoomView extends React.Component { for (const watcher of this.settingWatchers) { SettingsStore.unwatchSetting(watcher); } + + if (this.viewsLocalRoom) { + // clean up if this was a local room + this.props.mxClient.store.removeRoom(this.state.room.roomId); + } } private onRightPanelStoreUpdate = () => { @@ -873,6 +1017,10 @@ export class RoomView extends React.Component { this.onSearchClick(); break; + case 'local_room_event': + this.onLocalRoomEvent(payload.roomId); + break; + case Action.EditEvent: { // Quit early if we're trying to edit events in wrong rendering context if (payload.timelineRenderingType !== this.state.timelineRenderingType) return; @@ -925,6 +1073,11 @@ export class RoomView extends React.Component { } }; + private onLocalRoomEvent(roomId: string) { + if (roomId !== this.state.room.roomId) return; + createRoomFromLocalRoom(this.props.mxClient, this.state.room as LocalRoom); + } + private onRoomTimeline = (ev: MatrixEvent, room: Room | null, toStartOfTimeline: boolean, removed, data) => { if (this.unmounted) return; @@ -1494,7 +1647,7 @@ export class RoomView extends React.Component { searchResult={result} searchHighlights={this.state.searchHighlights} resultLink={resultLink} - permalinkCreator={this.getPermalinkCreatorForRoom(room)} + permalinkCreator={this.permalinkCreator} onHeightChanged={onHeightChanged} />); } @@ -1769,7 +1922,44 @@ export class RoomView extends React.Component { this.setState({ narrow }); }; + private get viewsLocalRoom(): boolean { + return isLocalRoom(this.state.room); + } + + private get permalinkCreator(): RoomPermalinkCreator { + return this.getPermalinkCreatorForRoom(this.state.room); + } + + private renderLocalRoomCreateLoader(): ReactElement { + const names = this.state.room.getDefaultRoomName(this.props.mxClient.getUserId()); + return + + ; + } + + private renderLocalRoomView(): ReactElement { + return + + ; + } + render() { + if (this.state.room instanceof LocalRoom) { + if (this.state.room.state === LocalRoomState.CREATING) { + return this.renderLocalRoomCreateLoader(); + } + + return this.renderLocalRoomView(); + } + if (!this.state.room) { const loading = !this.state.matrixClientIsReady || this.state.roomLoading || this.state.peekLoading; if (loading) { @@ -2027,7 +2217,7 @@ export class RoomView extends React.Component { e2eStatus={this.state.e2eStatus} resizeNotifier={this.props.resizeNotifier} replyToEvent={this.state.replyToEvent} - permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} + permalinkCreator={this.permalinkCreator} />; } @@ -2093,7 +2283,7 @@ export class RoomView extends React.Component { showUrlPreview={this.state.showUrlPreview} className={this.messagePanelClassNames} membersLoaded={this.state.membersLoaded} - permalinkCreator={this.getPermalinkCreatorForRoom(this.state.room)} + permalinkCreator={this.permalinkCreator} resizeNotifier={this.props.resizeNotifier} showReactions={true} layout={this.state.layout} @@ -2123,7 +2313,7 @@ export class RoomView extends React.Component { ? : null; @@ -2237,6 +2427,8 @@ export class RoomView extends React.Component { appsShown={this.state.showApps} onCallPlaced={onCallPlaced} excludedRightPanelPhaseButtons={excludedRightPanelPhaseButtons} + showButtons={!this.viewsLocalRoom} + enableRoomOptionsMenu={!this.viewsLocalRoom} />
diff --git a/src/components/views/dialogs/InviteDialog.tsx b/src/components/views/dialogs/InviteDialog.tsx index b6c442da1a8..386301f0ead 100644 --- a/src/components/views/dialogs/InviteDialog.tsx +++ b/src/components/views/dialogs/InviteDialog.tsx @@ -62,11 +62,16 @@ import CopyableText from "../elements/CopyableText"; import { ScreenName } from '../../../PosthogTrackers'; import { KeyBindingAction } from "../../../accessibility/KeyboardShortcuts"; import { getKeyBindingsManager } from "../../../KeyBindingsManager"; -import { DirectoryMember, IDMUserTileProps, Member, ThreepidMember } from "../../../utils/direct-messages"; +import { + DirectoryMember, + IDMUserTileProps, + Member, + startDmOnFirstMessage, + ThreepidMember, +} from "../../../utils/direct-messages"; import { AnyInviteKind, KIND_CALL_TRANSFER, KIND_DM, KIND_INVITE } from './InviteDialogTypes'; import Modal from '../../../Modal'; import dis from "../../../dispatcher/dispatcher"; -import { startDm } from '../../../utils/dm/startDm'; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -446,11 +451,10 @@ export default class InviteDialog extends React.PureComponent { - this.setState({ busy: true }); try { const cli = MatrixClientPeg.get(); const targets = this.convertFilter(); - await startDm(cli, targets); + startDmOnFirstMessage(cli, targets); this.props.onFinished(true); } catch (err) { logger.error(err); @@ -458,8 +462,6 @@ export default class InviteDialog extends React.PureComponent = ({ initialText = "", initialFilter = n id={`mx_SpotlightDialog_button_result_${result.member.userId}`} key={`${Section[result.section]}-${result.member.userId}`} onClick={() => { - startDm(cli, [result.member]); + startDmOnFirstMessage(cli, [result.member]); onFinished(); }} aria-label={result.member instanceof RoomMember diff --git a/src/components/views/right_panel/UserInfo.tsx b/src/components/views/right_panel/UserInfo.tsx index 53dff79721f..45489603ba8 100644 --- a/src/components/views/right_panel/UserInfo.tsx +++ b/src/components/views/right_panel/UserInfo.tsx @@ -33,7 +33,6 @@ import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import dis from '../../../dispatcher/dispatcher'; import Modal from '../../../Modal'; import { _t } from '../../../languageHandler'; -import createRoom from '../../../createRoom'; import DMRoomMap from '../../../utils/DMRoomMap'; import AccessibleButton, { ButtonEvent } from '../elements/AccessibleButton'; import SdkConfig from '../../../SdkConfig'; @@ -78,8 +77,7 @@ import { IRightPanelCardState } from '../../../stores/right-panel/RightPanelStor import UserIdentifierCustomisations from '../../../customisations/UserIdentifier'; import PosthogTrackers from "../../../PosthogTrackers"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; -import { privateShouldBeEncrypted } from "../../../utils/rooms"; -import { findDMForUser } from '../../../utils/dm/findDMForUser'; +import { DirectoryMember, startDmOnFirstMessage } from '../../../utils/direct-messages'; export interface IDevice { deviceId: string; @@ -124,38 +122,13 @@ export const getE2EStatus = (cli: MatrixClient, userId: string, devices: IDevice return anyDeviceUnverified ? E2EStatus.Warning : E2EStatus.Verified; }; -async function openDMForUser(matrixClient: MatrixClient, userId: string, viaKeyboard = false): Promise { - const lastActiveRoom = findDMForUser(matrixClient, userId); - - if (lastActiveRoom) { - dis.dispatch({ - action: Action.ViewRoom, - room_id: lastActiveRoom.roomId, - metricsTrigger: "MessageUser", - metricsViaKeyboard: viaKeyboard, - }); - return; - } - - const createRoomOptions = { - dmUserId: userId, - encryption: undefined, - }; - - if (privateShouldBeEncrypted()) { - // Check whether all users have uploaded device keys before. - // If so, enable encryption in the new room. - const usersToDevicesMap = await matrixClient.downloadKeys([userId]); - const allHaveDeviceKeys = Object.values(usersToDevicesMap).every(devices => { - // `devices` is an object of the form { deviceId: deviceInfo, ... }. - return Object.keys(devices).length > 0; - }); - if (allHaveDeviceKeys) { - createRoomOptions.encryption = true; - } - } - - await createRoom(createRoomOptions); +async function openDMForUser(matrixClient: MatrixClient, user: RoomMember): Promise { + const startDMUser = new DirectoryMember({ + user_id: user.userId, + display_name: user.rawDisplayName, + avatar_url: user.getMxcAvatarUrl(), + }); + startDmOnFirstMessage(matrixClient, [startDMUser]); } type SetUpdating = (updating: boolean) => void; @@ -328,17 +301,17 @@ function DevicesSection({ devices, userId, loading }: { devices: IDevice[], user ); } -const MessageButton = ({ userId }: { userId: string }) => { +const MessageButton = ({ member }: { member: RoomMember }) => { const cli = useContext(MatrixClientContext); const [busy, setBusy] = useState(false); return ( { + onClick={async () => { if (busy) return; setBusy(true); - await openDMForUser(cli, userId, ev.type !== "click"); + await openDMForUser(cli, member); setBusy(false); }} className="mx_UserInfo_field" @@ -484,7 +457,7 @@ const UserOptionsSection: React.FC<{ let directMessageButton: JSX.Element; if (!isMe) { - directMessageButton = ; + directMessageButton = ; } return ( diff --git a/src/components/views/rooms/EventTile.tsx b/src/components/views/rooms/EventTile.tsx index 95477a6e7f7..82c1a50a23c 100644 --- a/src/components/views/rooms/EventTile.tsx +++ b/src/components/views/rooms/EventTile.tsx @@ -948,7 +948,6 @@ export class UnwrappedEventTile extends React.Component { isSeeingThroughMessageHiddenForModeration, } = getEventDisplayInfo(this.props.mxEvent, this.context.showHiddenEvents, this.shouldHideEvent()); const { isQuoteExpanded } = this.state; - // This shouldn't happen: the caller should check we support this type // before trying to instantiate us if (!hasRenderer) { diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 0c2ea59d5ce..9a691c11263 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -3147,6 +3147,7 @@ "You can select all or individual messages to retry or delete": "You can select all or individual messages to retry or delete", "Connectivity to the server has been lost.": "Connectivity to the server has been lost.", "Sent messages will be stored until your connection has returned.": "Sent messages will be stored until your connection has returned.", + "We're creating a room with %(names)s": "We're creating a room with %(names)s", "You seem to be uploading files, are you sure you want to quit?": "You seem to be uploading files, are you sure you want to quit?", "You seem to be in a call, are you sure you want to quit?": "You seem to be in a call, are you sure you want to quit?", "Search failed": "Search failed", diff --git a/test/components/structures/LargeLoader-test.tsx b/test/components/structures/LargeLoader-test.tsx new file mode 100644 index 00000000000..539c8282ad1 --- /dev/null +++ b/test/components/structures/LargeLoader-test.tsx @@ -0,0 +1,32 @@ +/* +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 { render, screen } from "@testing-library/react"; + +import { LargeLoader } from "../../../src/components/structures/LargeLoader"; + +describe("LargeLoader", () => { + const text = "test loading text"; + + beforeEach(() => { + render(); + }); + + it("should render the text", () => { + screen.getByText(text); + }); +}); diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index 3b529ec7008..9648b560ace 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -21,11 +21,13 @@ import { mocked, MockedObject } from "jest-mock"; import { MatrixClient } from "matrix-js-sdk/src/client"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; +import { EventType } from "matrix-js-sdk/src/matrix"; +import { MEGOLM_ALGORITHM } from "matrix-js-sdk/src/crypto/olmlib"; import { stubClient, mockPlatformPeg, unmockPlatformPeg, wrapInMatrixClientContext } from "../../test-utils"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import { Action } from "../../../src/dispatcher/actions"; -import dis from "../../../src/dispatcher/dispatcher"; +import { defaultDispatcher } from "../../../src/dispatcher/dispatcher"; import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayload"; import { RoomView as _RoomView } from "../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; @@ -36,6 +38,9 @@ import DMRoomMap from "../../../src/utils/DMRoomMap"; import { NotificationState } from "../../../src/stores/notifications/NotificationState"; import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; +import { LocalRoom, LocalRoomState } from "../../../src/models/LocalRoom"; +import { DirectoryMember } from "../../../src/utils/direct-messages"; +import { createDmLocalRoom } from "../../../src/utils/dm/createDmLocalRoom"; const RoomView = wrapInMatrixClientContext(_RoomView); @@ -43,6 +48,7 @@ describe("RoomView", () => { let cli: MockedObject; let room: Room; let roomCount = 0; + beforeEach(async () => { mockPlatformPeg({ reload: () => {} }); stubClient(); @@ -50,7 +56,7 @@ describe("RoomView", () => { room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); room.getPendingEvents = () => []; - cli.getRoom.mockReturnValue(room); + cli.getRoom.mockImplementation(() => room); // 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)); @@ -75,7 +81,7 @@ describe("RoomView", () => { }); }); - dis.dispatch({ + defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: room.roomId, metricsTrigger: null, @@ -163,4 +169,82 @@ describe("RoomView", () => { expect(RightPanelStore.instance.currentCard.phase).toEqual(RightPanelPhases.Timeline); }); }); + + describe("for a local room", () => { + let localRoom: LocalRoom; + let roomView: ReactWrapper; + + beforeEach(async () => { + localRoom = room = await createDmLocalRoom(cli, [new DirectoryMember({ user_id: "@user:example.com" })]); + cli.store.storeRoom(room); + }); + + it("should remove the room from the store on unmount", async () => { + roomView = await mountRoomView(); + roomView.unmount(); + expect(cli.store.removeRoom).toHaveBeenCalledWith(room.roomId); + }); + + describe("in state NEW", () => { + it("should match the snapshot", async () => { + roomView = await mountRoomView(); + expect(roomView.html()).toMatchSnapshot(); + }); + + describe("that is encrypted", () => { + beforeEach(() => { + mocked(cli.isRoomEncrypted).mockReturnValue(true); + localRoom.encrypted = true; + localRoom.currentState.setStateEvents([ + new MatrixEvent({ + event_id: `~${localRoom.roomId}:${cli.makeTxnId()}`, + type: EventType.RoomEncryption, + content: { + algorithm: MEGOLM_ALGORITHM, + }, + user_id: cli.getUserId(), + sender: cli.getUserId(), + state_key: "", + room_id: localRoom.roomId, + origin_server_ts: Date.now(), + }), + ]); + }); + + it("should match the snapshot", async () => { + const roomView = await mountRoomView(); + expect(roomView.html()).toMatchSnapshot(); + }); + }); + }); + + it("in state CREATING should match the snapshot", async () => { + localRoom.state = LocalRoomState.CREATING; + roomView = await mountRoomView(); + expect(roomView.html()).toMatchSnapshot(); + }); + + describe("in state ERROR", () => { + beforeEach(async () => { + localRoom.state = LocalRoomState.ERROR; + roomView = await mountRoomView(); + }); + + it("should match the snapshot", async () => { + expect(roomView.html()).toMatchSnapshot(); + }); + + it("clicking retry should set the room state to new dispatch a local room event", () => { + jest.spyOn(defaultDispatcher, "dispatch"); + roomView.findWhere((w: ReactWrapper) => { + return w.hasClass("mx_RoomStatusBar_unsentRetry") && w.text() === "Retry"; + }).first().simulate("click"); + expect(localRoom.state).toBe(LocalRoomState.NEW); + expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ + action: "local_room_event", + roomId: room.roomId, + }); + }); + }); + }); }); diff --git a/test/components/structures/__snapshots__/RoomView-test.tsx.snap b/test/components/structures/__snapshots__/RoomView-test.tsx.snap new file mode 100644 index 00000000000..a0c3a277c90 --- /dev/null +++ b/test/components/structures/__snapshots__/RoomView-test.tsx.snap @@ -0,0 +1,9 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RoomView for a local room in state CREATING should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
We're creating a room with @user:example.com
"`; + +exports[`RoomView for a local room in state ERROR should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat

!
Some of your messages have not been sent
Retry
"`; + +exports[`RoomView for a local room in state NEW should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
  1. End-to-end encryption isn't enabled
    Your private messages are normally encrypted, but this room isn't. Usually this is due to an unsupported device or method being used, like email invites.
    U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; + +exports[`RoomView for a local room in state NEW that is encrypted should match the snapshot 1`] = `"
U\\"\\"
@user:example.com
    Encryption enabled
    Messages in this chat will be end-to-end encrypted.
  1. U\\"\\"

    @user:example.com

    Send your first message to invite @user:example.com to chat


"`; diff --git a/test/components/views/dialogs/SpotlightDialog-test.tsx b/test/components/views/dialogs/SpotlightDialog-test.tsx index c2445139a78..61c50181e9c 100644 --- a/test/components/views/dialogs/SpotlightDialog-test.tsx +++ b/test/components/views/dialogs/SpotlightDialog-test.tsx @@ -25,6 +25,7 @@ import sanitizeHtml from "sanitize-html"; import SpotlightDialog, { Filter } from "../../../../src/components/views/dialogs/spotlight/SpotlightDialog"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import { LocalRoom, LOCAL_ROOM_ID_PREFIX } from "../../../../src/models/LocalRoom"; +import { DirectoryMember, startDmOnFirstMessage } from "../../../../src/utils/direct-messages"; import DMRoomMap from "../../../../src/utils/DMRoomMap"; import { mkRoom, stubClient } from "../../../test-utils"; @@ -345,4 +346,27 @@ describe("Spotlight Dialog", () => { expect(options.first().text()).not.toContain(testLocalRoom.name); }); }); + + it("should start a DM when clicking a person", async () => { + const wrapper = mount( + null} />, + ); + + await act(async () => { + await sleep(200); + }); + wrapper.update(); + + const options = wrapper.find("div.mx_SpotlightDialog_option"); + expect(options.length).toBeGreaterThanOrEqual(1); + expect(options.first().text()).toContain(testPerson.display_name); + + options.first().simulate("click"); + expect(startDmOnFirstMessage).toHaveBeenCalledWith(mockedClient, [new DirectoryMember(testPerson)]); + + wrapper.unmount(); + }); });