From ef48443dc95392c73ca948a62ea7e5157ade5baa Mon Sep 17 00:00:00 2001 From: Robin Date: Fri, 17 Jun 2022 16:57:40 -0400 Subject: [PATCH] Show chat panel when opening a video room with unread messages (#8812) * Show chat panel when opening a video room with unread messages * Remove unnecessary calls to private methods in tests * Make room ID mandatory when toggling the right panel * Restore the isViewingRoom check * Test RightPanelStore * Make the constructor private again * Add even more tests * Fix onReady --- src/components/structures/LoggedInView.tsx | 2 +- src/components/structures/RightPanel.tsx | 8 +- src/components/structures/RoomView.tsx | 10 +- .../views/right_panel/EncryptionPanel.tsx | 2 +- .../views/right_panel/HeaderButtons.tsx | 4 +- .../views/right_panel/RoomHeaderButtons.tsx | 2 +- src/stores/ReadyWatchingStore.ts | 10 +- src/stores/right-panel/RightPanelStore.ts | 95 ++++---- .../components/structures/RightPanel-test.tsx | 147 +++++++----- test/components/structures/RoomView-test.tsx | 49 +++- .../right-panel/RightPanelStore-test.ts | 227 ++++++++++++++++++ 11 files changed, 432 insertions(+), 124 deletions(-) create mode 100644 test/stores/right-panel/RightPanelStore-test.ts diff --git a/src/components/structures/LoggedInView.tsx b/src/components/structures/LoggedInView.tsx index de60ca71fa..87869b6551 100644 --- a/src/components/structures/LoggedInView.tsx +++ b/src/components/structures/LoggedInView.tsx @@ -493,7 +493,7 @@ class LoggedInView extends React.Component { break; case KeyBindingAction.ToggleRoomSidePanel: if (this.props.page_type === "room_view") { - RightPanelStore.instance.togglePanel(); + RightPanelStore.instance.togglePanel(null); handled = true; } break; diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 5c2eaaf9a4..599c294989 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -91,12 +91,6 @@ export default class RightPanel extends React.Component { currentCard = RightPanelStore.instance.currentCardForRoom(props.room.roomId); } - if (currentCard?.phase && !RightPanelStore.instance.isPhaseValid(currentCard.phase, !!props.room)) { - // XXX: We can probably get rid of this workaround once GroupView is dead, it's unmounting happens weirdly - // late causing the app to soft-crash due to lack of a room object being passed to a RightPanel - return null; // skip this update, we're about to be unmounted and don't have the appropriate props - } - return { cardState: currentCard?.state, phase: currentCard?.phase, @@ -142,7 +136,7 @@ export default class RightPanel extends React.Component { // When the user clicks close on the encryption panel cancel the pending request first if any this.state.cardState.verificationRequest.cancel(); } else { - RightPanelStore.instance.togglePanel(); + RightPanelStore.instance.togglePanel(this.props.room?.roomId); } }; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index dbab94e8e5..4cdfc8b930 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -361,7 +361,7 @@ export class RoomView extends React.Component { ) { // hide chat in right panel when the widget is minimized RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); - RightPanelStore.instance.togglePanel(); + RightPanelStore.instance.togglePanel(this.state.roomId); } this.checkWidgets(this.state.room); }; @@ -1020,6 +1020,14 @@ export class RoomView extends React.Component { this.updatePermissions(room); this.checkWidgets(room); + if ( + this.getMainSplitContentType(room) !== MainSplitContentType.Timeline + && RoomNotificationStateStore.instance.getRoomState(room).isUnread + ) { + // Automatically open the chat panel to make unread messages easier to discover + RightPanelStore.instance.setCard({ phase: RightPanelPhases.Timeline }, true, room.roomId); + } + this.setState({ tombstone: this.getRoomTombstone(room), liveTimeline: room.getLiveTimeline(), diff --git a/src/components/views/right_panel/EncryptionPanel.tsx b/src/components/views/right_panel/EncryptionPanel.tsx index 4aa3c8049b..a9ab3e6ab3 100644 --- a/src/components/views/right_panel/EncryptionPanel.tsx +++ b/src/components/views/right_panel/EncryptionPanel.tsx @@ -122,7 +122,7 @@ const EncryptionPanel: React.FC = (props: IProps) => { state: { member, verificationRequest: verificationRequest_ }, }); } - if (!RightPanelStore.instance.isOpen) RightPanelStore.instance.togglePanel(); + if (!RightPanelStore.instance.isOpen) RightPanelStore.instance.togglePanel(null); }, [member]); const requested = diff --git a/src/components/views/right_panel/HeaderButtons.tsx b/src/components/views/right_panel/HeaderButtons.tsx index a152a8871f..9af2c2e01c 100644 --- a/src/components/views/right_panel/HeaderButtons.tsx +++ b/src/components/views/right_panel/HeaderButtons.tsx @@ -70,10 +70,10 @@ export default abstract class HeaderButtons

extends React.Component) { const rps = RightPanelStore.instance; if (rps.currentCard.phase == phase && !cardState && rps.isOpen) { - rps.togglePanel(); + rps.togglePanel(null); } else { RightPanelStore.instance.setCard({ phase, state: cardState }); - if (!rps.isOpen) rps.togglePanel(); + if (!rps.isOpen) rps.togglePanel(null); } } diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index 5a17680522..4b5889bc82 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -209,7 +209,7 @@ export default class RoomHeaderButtons extends HeaderButtons { private onThreadsPanelClicked = (ev: ButtonEvent) => { if (RoomHeaderButtons.THREAD_PHASES.includes(this.state.phase)) { - RightPanelStore.instance.togglePanel(); + RightPanelStore.instance.togglePanel(this.props.room?.roomId); } else { showThreadPanel(); PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", ev); diff --git a/src/stores/ReadyWatchingStore.ts b/src/stores/ReadyWatchingStore.ts index a142693e62..4060abfe5d 100644 --- a/src/stores/ReadyWatchingStore.ts +++ b/src/stores/ReadyWatchingStore.ts @@ -73,11 +73,11 @@ export abstract class ReadyWatchingStore extends EventEmitter implements IDestro // Everything after this is unnecessary (we only need to know once we have a client) // and we intentionally don't set the client before this point to avoid stores // updating for every event emitted during the cached sync. - if (!(payload.prevState === SyncState.Prepared && payload.state !== SyncState.Prepared)) { - return; - } - - if (this.matrixClient !== payload.matrixClient) { + if ( + payload.prevState !== SyncState.Prepared + && payload.state === SyncState.Prepared + && this.matrixClient !== payload.matrixClient + ) { if (this.matrixClient) { await this.onNotReady(); } diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index 341474293e..b37691a5a9 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -45,14 +45,22 @@ import { RoomViewStore } from "../RoomViewStore"; export default class RightPanelStore extends ReadyWatchingStore { private static internalInstance: RightPanelStore; - private global?: IRightPanelForRoom = null; - private byRoom: { - [roomId: string]: IRightPanelForRoom; - } = {}; + private global?: IRightPanelForRoom; + private byRoom: { [roomId: string]: IRightPanelForRoom }; private viewedRoomId: Optional; private constructor() { super(defaultDispatcher); + this.reset(); + } + + /** + * Resets the store. Intended for test usage only. + */ + public reset() { + this.global = null; + this.byRoom = {}; + this.viewedRoomId = null; } protected async onReady(): Promise { @@ -134,19 +142,20 @@ export default class RightPanelStore extends ReadyWatchingStore { const cardState = redirect?.state ?? (Object.keys(card.state ?? {}).length === 0 ? null : card.state); // Checks for wrong SetRightPanelPhase requests - if (!this.isPhaseValid(targetPhase)) return; + if (!this.isPhaseValid(targetPhase, Boolean(rId))) return; if ((targetPhase === this.currentCardForRoom(rId)?.phase && !!cardState)) { // Update state: set right panel with a new state but keep the phase (don't know it this is ever needed...) const hist = this.byRoom[rId]?.history ?? []; hist[hist.length - 1].state = cardState; this.emitAndUpdateSettings(); - } else if (targetPhase !== this.currentCard?.phase) { - // Set right panel and erase history. - this.show(); - this.setRightPanelCache({ phase: targetPhase, state: cardState ?? {} }, rId); + } else if (targetPhase !== this.currentCardForRoom(rId)?.phase || !this.byRoom[rId]) { + // Set right panel and initialize/erase history + const history = [{ phase: targetPhase, state: cardState ?? {} }]; + this.byRoom[rId] = { history, isOpen: true }; + this.emitAndUpdateSettings(); } else { - this.show(); + this.show(rId); this.emitAndUpdateSettings(); } } @@ -156,23 +165,23 @@ export default class RightPanelStore extends ReadyWatchingStore { const rId = roomId ?? this.viewedRoomId; const history = cards.map(c => ({ phase: c.phase, state: c.state ?? {} })); this.byRoom[rId] = { history, isOpen: true }; - this.show(); + this.show(rId); this.emitAndUpdateSettings(); } + // Appends a card to the history and shows the right panel if not already visible public pushCard( card: IRightPanelCard, allowClose = true, roomId: string = null, ) { - // This function appends a card to the history and shows the right panel if now already visible. const rId = roomId ?? this.viewedRoomId; const redirect = this.getVerificationRedirect(card); const targetPhase = redirect?.phase ?? card.phase; - const pState = redirect?.state ?? (Object.keys(card.state ?? {}).length === 0 ? null : card.state); + const pState = redirect?.state ?? card.state ?? {}; // Checks for wrong SetRightPanelPhase requests - if (!this.isPhaseValid(targetPhase)) return; + if (!this.isPhaseValid(targetPhase, Boolean(rId))) return; const roomCache = this.byRoom[rId]; if (!!roomCache) { @@ -182,12 +191,12 @@ export default class RightPanelStore extends ReadyWatchingStore { } else { // setup room panel cache with the new card this.byRoom[rId] = { - history: [{ phase: targetPhase, state: pState ?? {} }], + history: [{ phase: targetPhase, state: pState }], // if there was no right panel store object the the panel was closed -> keep it closed, except if allowClose==false isOpen: !allowClose, }; } - this.show(); + this.show(rId); this.emitAndUpdateSettings(); } @@ -200,7 +209,7 @@ export default class RightPanelStore extends ReadyWatchingStore { return removedCard; } - public togglePanel(roomId: string = null) { + public togglePanel(roomId: string | null) { const rId = roomId ?? this.viewedRoomId; if (!this.byRoom[rId]) return; @@ -208,27 +217,31 @@ export default class RightPanelStore extends ReadyWatchingStore { this.emitAndUpdateSettings(); } - public show() { - if (!this.isOpen) { - this.togglePanel(); + public show(roomId: string | null) { + if (!this.isOpenForRoom(roomId ?? this.viewedRoomId)) { + this.togglePanel(roomId); } } - public hide() { - if (this.isOpen) { - this.togglePanel(); + public hide(roomId: string | null) { + if (this.isOpenForRoom(roomId ?? this.viewedRoomId)) { + this.togglePanel(roomId); } } private loadCacheFromSettings() { - const room = this.viewedRoomId && this.mxClient?.getRoom(this.viewedRoomId); - if (!!room) { - this.global = this.global ?? - convertToStatePanel(SettingsStore.getValue("RightPanel.phasesGlobal"), room); - this.byRoom[this.viewedRoomId] = this.byRoom[this.viewedRoomId] ?? - convertToStatePanel(SettingsStore.getValue("RightPanel.phases", this.viewedRoomId), room); - } else { - console.warn("Could not restore the right panel after load because there was no associated room object."); + if (this.viewedRoomId) { + const room = this.mxClient?.getRoom(this.viewedRoomId); + if (!!room) { + this.global = this.global ?? + convertToStatePanel(SettingsStore.getValue("RightPanel.phasesGlobal"), room); + this.byRoom[this.viewedRoomId] = this.byRoom[this.viewedRoomId] ?? + convertToStatePanel(SettingsStore.getValue("RightPanel.phases", this.viewedRoomId), room); + } else { + logger.warn( + "Could not restore the right panel after load because there was no associated room object.", + ); + } } } @@ -273,37 +286,31 @@ export default class RightPanelStore extends ReadyWatchingStore { case RightPanelPhases.ThreadView: if (!SettingsStore.getValue("feature_thread")) return false; if (!card.state.threadHeadEvent) { - console.warn("removed card from right panel because of missing threadHeadEvent in card state"); + logger.warn("removed card from right panel because of missing threadHeadEvent in card state"); } return !!card.state.threadHeadEvent; case RightPanelPhases.RoomMemberInfo: case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.EncryptionPanel: if (!card.state.member) { - console.warn("removed card from right panel because of missing member in card state"); + logger.warn("removed card from right panel because of missing member in card state"); } return !!card.state.member; case RightPanelPhases.Room3pidMemberInfo: case RightPanelPhases.Space3pidMemberInfo: if (!card.state.memberInfoEvent) { - console.warn("removed card from right panel because of missing memberInfoEvent in card state"); + logger.warn("removed card from right panel because of missing memberInfoEvent in card state"); } return !!card.state.memberInfoEvent; case RightPanelPhases.Widget: if (!card.state.widgetId) { - console.warn("removed card from right panel because of missing widgetId in card state"); + logger.warn("removed card from right panel because of missing widgetId in card state"); } return !!card.state.widgetId; } return true; } - private setRightPanelCache(card: IRightPanelCard, roomId?: string) { - const history = [{ phase: card.phase, state: card.state ?? {} }]; - this.byRoom[roomId ?? this.viewedRoomId] = { history, isOpen: true }; - this.emitAndUpdateSettings(); - } - private getVerificationRedirect(card: IRightPanelCard): IRightPanelCard { if (card.phase === RightPanelPhases.RoomMemberInfo && card.state) { // RightPanelPhases.RoomMemberInfo -> needs to be changed to RightPanelPhases.EncryptionPanel if there is a pending verification request @@ -322,7 +329,7 @@ export default class RightPanelStore extends ReadyWatchingStore { return null; } - public isPhaseValid(targetPhase: RightPanelPhases, isViewingRoom = this.isViewingRoom): boolean { + private isPhaseValid(targetPhase: RightPanelPhases, isViewingRoom: boolean): boolean { if (!RightPanelPhases[targetPhase]) { logger.warn(`Tried to switch right panel to unknown phase: ${targetPhase}`); return false; @@ -386,10 +393,6 @@ export default class RightPanelStore extends ReadyWatchingStore { this.emitAndUpdateSettings(); } - private get isViewingRoom(): boolean { - return !!this.viewedRoomId; - } - public static get instance(): RightPanelStore { if (!RightPanelStore.internalInstance) { RightPanelStore.internalInstance = new RightPanelStore(); diff --git a/test/components/structures/RightPanel-test.tsx b/test/components/structures/RightPanel-test.tsx index 7d68d1753d..17e952e7fb 100644 --- a/test/components/structures/RightPanel-test.tsx +++ b/test/components/structures/RightPanel-test.tsx @@ -15,37 +15,112 @@ limitations under the License. */ import React from "react"; -import TestRenderer from "react-test-renderer"; +import { mount } from "enzyme"; import { jest } from "@jest/globals"; -import { Room } from "matrix-js-sdk/src/models/room"; +import { mocked, MockedObject } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/client"; -import RightPanel from "../../../src/components/structures/RightPanel"; +import _RightPanel from "../../../src/components/structures/RightPanel"; import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; -import { stubClient } from "../../test-utils"; +import { stubClient, wrapInMatrixClientContext, mkRoom } from "../../test-utils"; import { Action } from "../../../src/dispatcher/actions"; import dis from "../../../src/dispatcher/dispatcher"; import DMRoomMap from "../../../src/utils/DMRoomMap"; -import MatrixClientContext from "../../../src/contexts/MatrixClientContext"; import SettingsStore from "../../../src/settings/SettingsStore"; import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore"; +import RoomSummaryCard from "../../../src/components/views/right_panel/RoomSummaryCard"; +import MemberList from "../../../src/components/views/rooms/MemberList"; + +const RightPanel = wrapInMatrixClientContext(_RightPanel); describe("RightPanel", () => { - it("renders info from only one room during room changes", async () => { + const resizeNotifier = new ResizeNotifier(); + + let cli: MockedObject; + beforeEach(() => { stubClient(); - const cli = MatrixClientPeg.get(); - cli.hasLazyLoadMembersEnabled = () => false; - - // Init misc. startup deps + cli = mocked(MatrixClientPeg.get()); DMRoomMap.makeShared(); + }); - const r1 = new Room("r1", cli, "@name:example.com"); - const r2 = new Room("r2", cli, "@name:example.com"); + afterEach(async () => { + const roomChanged = new Promise(resolve => { + const ref = dis.register(payload => { + if (payload.action === Action.ActiveRoomChanged) { + dis.unregister(ref); + resolve(); + } + }); + }); + dis.fire(Action.ViewHomePage); // Stop viewing any rooms + await roomChanged; - jest.spyOn(cli, "getRoom").mockImplementation(roomId => { + dis.fire(Action.OnLoggedOut, true); // Shut down the stores + jest.restoreAllMocks(); + }); + + const spinUpStores = async () => { + // Selectively spin up the stores we need + WidgetLayoutStore.instance.useUnitTestClient(cli); + // @ts-ignore + // This is private but it's the only way to selectively enable stores + await WidgetLayoutStore.instance.onReady(); + + // Make sure we start with a clean store + RightPanelStore.instance.reset(); + RightPanelStore.instance.useUnitTestClient(cli); + // @ts-ignore + await RightPanelStore.instance.onReady(); + }; + + const waitForRpsUpdate = () => + new Promise(resolve => RightPanelStore.instance.once(UPDATE_EVENT, resolve)); + + it("navigates from room summary to member list", async () => { + const r1 = mkRoom(cli, "r1"); + cli.getRoom.mockImplementation(roomId => roomId === "r1" ? r1 : null); + + // Set up right panel state + const realGetValue = SettingsStore.getValue; + jest.spyOn(SettingsStore, "getValue").mockImplementation((name, roomId) => { + if (name !== "RightPanel.phases") return realGetValue(name, roomId); + if (roomId === "r1") { + return { + history: [{ phase: RightPanelPhases.RoomSummary }], + isOpen: true, + }; + } + return null; + }); + + await spinUpStores(); + const viewedRoom = waitForRpsUpdate(); + dis.dispatch({ + action: Action.ViewRoom, + room_id: "r1", + }); + await viewedRoom; + + const wrapper = mount(); + expect(wrapper.find(RoomSummaryCard).exists()).toEqual(true); + + const switchedPhases = waitForRpsUpdate(); + wrapper.find("AccessibleButton.mx_RoomSummaryCard_icon_people").simulate("click"); + await switchedPhases; + wrapper.update(); + + expect(wrapper.find(MemberList).exists()).toEqual(true); + }); + + it("renders info from only one room during room changes", async () => { + const r1 = mkRoom(cli, "r1"); + const r2 = mkRoom(cli, "r2"); + + cli.getRoom.mockImplementation(roomId => { if (roomId === "r1") return r1; if (roomId === "r2") return r2; return null; @@ -70,35 +145,12 @@ describe("RightPanel", () => { return null; }); - // Wake up various stores we rely on - WidgetLayoutStore.instance.useUnitTestClient(cli); - // @ts-ignore - await WidgetLayoutStore.instance.onReady(); - RightPanelStore.instance.useUnitTestClient(cli); - // @ts-ignore - await RightPanelStore.instance.onReady(); - - const resizeNotifier = new ResizeNotifier(); + await spinUpStores(); // Run initial render with room 1, and also running lifecycle methods - const renderer = TestRenderer.create( - - ); + const wrapper = mount(); // Wait for RPS room 1 updates to fire - const rpsUpdated = new Promise(resolve => { - const update = () => { - if ( - RightPanelStore.instance.currentCardForRoom("r1").phase !== - RightPanelPhases.RoomMemberList - ) return; - RightPanelStore.instance.off(UPDATE_EVENT, update); - resolve(); - }; - RightPanelStore.instance.on(UPDATE_EVENT, update); - }); + const rpsUpdated = waitForRpsUpdate(); dis.dispatch({ action: Action.ViewRoom, room_id: "r1", @@ -108,7 +160,7 @@ describe("RightPanel", () => { // After all that setup, now to the interesting part... // We want to verify that as we change to room 2, we should always have // the correct right panel state for whichever room we are showing. - const instance = renderer.root.instance; + const instance = wrapper.find(_RightPanel).instance() as _RightPanel; const rendered = new Promise(resolve => { jest.spyOn(instance, "render").mockImplementation(() => { const { props, state } = instance; @@ -127,21 +179,8 @@ describe("RightPanel", () => { action: Action.ViewRoom, room_id: "r2", }); - renderer.update( - - ); + wrapper.setProps({ room: r2 }); await rendered; }); - - afterAll(async () => { - // @ts-ignore - await WidgetLayoutStore.instance.onNotReady(); - // @ts-ignore - await RightPanelStore.instance.onNotReady(); - jest.restoreAllMocks(); - }); }); diff --git a/test/components/structures/RoomView-test.tsx b/test/components/structures/RoomView-test.tsx index ec318bfca2..3b529ec700 100644 --- a/test/components/structures/RoomView-test.tsx +++ b/test/components/structures/RoomView-test.tsx @@ -16,12 +16,13 @@ limitations under the License. import React from "react"; import { mount, ReactWrapper } from "enzyme"; +import { act } from "react-dom/test-utils"; 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 { stubClient, wrapInMatrixClientContext } from "../../test-utils"; +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"; @@ -29,18 +30,25 @@ import { ViewRoomPayload } from "../../../src/dispatcher/payloads/ViewRoomPayloa import { RoomView as _RoomView } from "../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../src/utils/ResizeNotifier"; import { RoomViewStore } from "../../../src/stores/RoomViewStore"; +import SettingsStore from "../../../src/settings/SettingsStore"; +import { SettingLevel } from "../../../src/settings/SettingLevel"; 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"; const RoomView = wrapInMatrixClientContext(_RoomView); describe("RoomView", () => { let cli: MockedObject; let room: Room; - beforeEach(() => { + let roomCount = 0; + beforeEach(async () => { + mockPlatformPeg({ reload: () => {} }); stubClient(); cli = mocked(MatrixClientPeg.get()); - room = new Room("r1", cli, "@alice:example.com"); + room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org"); room.getPendingEvents = () => []; cli.getRoom.mockReturnValue(room); // Re-emit certain events on the mocked client @@ -48,11 +56,17 @@ describe("RoomView", () => { room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); DMRoomMap.makeShared(); + RightPanelStore.instance.useUnitTestClient(cli); + }); + + afterEach(async () => { + unmockPlatformPeg(); + jest.restoreAllMocks(); }); const mountRoomView = async (): Promise => { if (RoomViewStore.instance.getRoomId() !== room.roomId) { - const switchRoomPromise = new Promise(resolve => { + const switchedRoom = new Promise(resolve => { const subscription = RoomViewStore.instance.addListener(() => { if (RoomViewStore.instance.getRoomId()) { subscription.remove(); @@ -67,10 +81,10 @@ describe("RoomView", () => { metricsTrigger: null, }); - await switchRoomPromise; + await switchedRoom; } - return mount( + const roomView = mount( { onRegistered={null} />, ); + await act(() => Promise.resolve()); // Allow state to settle + return roomView; }; const getRoomViewInstance = async (): Promise<_RoomView> => (await mountRoomView()).find(_RoomView).instance() as _RoomView; @@ -126,4 +142,25 @@ describe("RoomView", () => { room.getUnfilteredTimelineSet().resetLiveTimeline(); expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline); }); + + describe("video rooms", () => { + beforeEach(async () => { + // Make it a video room + room.isElementVideoRoom = () => true; + await SettingsStore.setValue("feature_video_rooms", null, SettingLevel.DEVICE, true); + }); + + it("normally doesn't open the chat panel", async () => { + jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(false); + await mountRoomView(); + expect(RightPanelStore.instance.isOpen).toEqual(false); + }); + + it("opens the chat panel if there are unread messages", async () => { + jest.spyOn(NotificationState.prototype, "isUnread", "get").mockReturnValue(true); + await mountRoomView(); + expect(RightPanelStore.instance.isOpen).toEqual(true); + expect(RightPanelStore.instance.currentCard.phase).toEqual(RightPanelPhases.Timeline); + }); + }); }); diff --git a/test/stores/right-panel/RightPanelStore-test.ts b/test/stores/right-panel/RightPanelStore-test.ts new file mode 100644 index 0000000000..e7168dd010 --- /dev/null +++ b/test/stores/right-panel/RightPanelStore-test.ts @@ -0,0 +1,227 @@ +/* +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 { mocked, MockedObject } from "jest-mock"; +import { MatrixClient } from "matrix-js-sdk/src/client"; +import { RoomMember } from "matrix-js-sdk/src/models/room-member"; + +import { stubClient } from "../../test-utils"; +import { MatrixClientPeg } from "../../../src/MatrixClientPeg"; +import DMRoomMap from "../../../src/utils/DMRoomMap"; +import { Action } from "../../../src/dispatcher/actions"; +import defaultDispatcher from "../../../src/dispatcher/dispatcher"; +import { ActiveRoomChangedPayload } from "../../../src/dispatcher/payloads/ActiveRoomChangedPayload"; +import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; +import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; +import SettingsStore from "../../../src/settings/SettingsStore"; + +describe("RightPanelStore", () => { + // Mock out the settings store so the right panel store can't persist values between tests + jest.spyOn(SettingsStore, "setValue").mockImplementation(async () => {}); + + const store = RightPanelStore.instance; + let cli: MockedObject; + beforeEach(() => { + stubClient(); + cli = mocked(MatrixClientPeg.get()); + DMRoomMap.makeShared(); + + // Make sure we start with a clean store + store.reset(); + store.useUnitTestClient(cli); + }); + + const viewRoom = async (roomId: string) => { + const roomChanged = new Promise(resolve => { + const ref = defaultDispatcher.register(payload => { + if (payload.action === Action.ActiveRoomChanged && payload.newRoomId === roomId) { + defaultDispatcher.unregister(ref); + resolve(); + } + }); + }); + + defaultDispatcher.dispatch({ + action: Action.ActiveRoomChanged, + oldRoomId: null, + newRoomId: roomId, + }); + + await roomChanged; + }; + + const setCard = (roomId: string, phase: RightPanelPhases) => store.setCard({ phase }, true, roomId); + + describe("isOpen", () => { + it("is false if no rooms are open", () => { + expect(store.isOpen).toEqual(false); + }); + it("is false if a room other than the current room is open", async () => { + await viewRoom("!1:example.org"); + setCard("!2:example.org", RightPanelPhases.RoomSummary); + expect(store.isOpen).toEqual(false); + }); + it("is true if the current room is open", async () => { + await viewRoom("!1:example.org"); + setCard("!1:example.org", RightPanelPhases.RoomSummary); + expect(store.isOpen).toEqual(true); + }); + }); + + describe("currentCard", () => { + it("has a phase of null if nothing is open", () => { + expect(store.currentCard.phase).toEqual(null); + }); + it("has a phase of null if the panel is open but in another room", async () => { + await viewRoom("!1:example.org"); + setCard("!2:example.org", RightPanelPhases.RoomSummary); + expect(store.currentCard.phase).toEqual(null); + }); + it("reflects the phase of the current room", async () => { + await viewRoom("!1:example.org"); + setCard("!1:example.org", RightPanelPhases.RoomSummary); + expect(store.currentCard.phase).toEqual(RightPanelPhases.RoomSummary); + }); + }); + + describe("setCard", () => { + it("does nothing if given no room ID and not viewing a room", () => { + store.setCard({ phase: RightPanelPhases.RoomSummary }, true); + expect(store.isOpen).toEqual(false); + expect(store.currentCard.phase).toEqual(null); + }); + it("does nothing if given an invalid state", async () => { + await viewRoom("!1:example.org"); + // Needs a member specified to be valid + store.setCard({ phase: RightPanelPhases.RoomMemberInfo }, true, "!1:example.org"); + expect(store.roomPhaseHistory).toEqual([]); + }); + it("only creates a single history entry if given the same card twice", async () => { + await viewRoom("!1:example.org"); + store.setCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); + store.setCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); + expect(store.roomPhaseHistory).toEqual([ + { phase: RightPanelPhases.RoomSummary, state: {} }, + ]); + }); + it("opens the panel in the given room with the correct phase", () => { + store.setCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); + expect(store.isOpenForRoom("!1:example.org")).toEqual(true); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.RoomSummary); + }); + it("overwrites history if changing the phase", async () => { + await viewRoom("!1:example.org"); + store.setCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); + store.setCard({ phase: RightPanelPhases.RoomMemberList }, true, "!1:example.org"); + expect(store.roomPhaseHistory).toEqual([ + { phase: RightPanelPhases.RoomMemberList, state: {} }, + ]); + }); + }); + + describe("setCards", () => { + it("overwrites history", async () => { + await viewRoom("!1:example.org"); + store.setCard({ phase: RightPanelPhases.RoomMemberList }, true, "!1:example.org"); + store.setCards([ + { phase: RightPanelPhases.RoomSummary }, + { phase: RightPanelPhases.PinnedMessages }, + ], true, "!1:example.org"); + expect(store.roomPhaseHistory).toEqual([ + { phase: RightPanelPhases.RoomSummary, state: {} }, + { phase: RightPanelPhases.PinnedMessages, state: {} }, + ]); + }); + }); + + describe("pushCard", () => { + it("does nothing if given no room ID and not viewing a room", () => { + store.pushCard({ phase: RightPanelPhases.RoomSummary }, true); + expect(store.isOpen).toEqual(false); + expect(store.currentCard.phase).toEqual(null); + }); + it("opens the panel in the given room with the correct phase", () => { + store.pushCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); + expect(store.isOpenForRoom("!1:example.org")).toEqual(true); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.RoomSummary); + }); + it("appends the phase to any phases that were there before", async () => { + await viewRoom("!1:example.org"); + store.setCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); + store.pushCard({ phase: RightPanelPhases.PinnedMessages }, true, "!1:example.org"); + expect(store.roomPhaseHistory).toEqual([ + { phase: RightPanelPhases.RoomSummary, state: {} }, + { phase: RightPanelPhases.PinnedMessages, state: {} }, + ]); + }); + }); + + describe("popCard", () => { + it("removes the most recent card", () => { + store.setCards([ + { phase: RightPanelPhases.RoomSummary }, + { phase: RightPanelPhases.PinnedMessages }, + ], true, "!1:example.org"); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.PinnedMessages); + store.popCard("!1:example.org"); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.RoomSummary); + }); + }); + + describe("togglePanel", () => { + it("does nothing if the room has no phase to open to", () => { + expect(store.isOpenForRoom("!1:example.org")).toEqual(false); + store.togglePanel("!1:example.org"); + expect(store.isOpenForRoom("!1:example.org")).toEqual(false); + }); + it("works if a room is specified", () => { + store.setCard({ phase: RightPanelPhases.RoomSummary }, true, "!1:example.org"); + expect(store.isOpenForRoom("!1:example.org")).toEqual(true); + store.togglePanel("!1:example.org"); + expect(store.isOpenForRoom("!1:example.org")).toEqual(false); + store.togglePanel("!1:example.org"); + expect(store.isOpenForRoom("!1:example.org")).toEqual(true); + }); + it("operates on the current room if no room is specified", async () => { + await viewRoom("!1:example.org"); + store.setCard({ phase: RightPanelPhases.RoomSummary }, true); + expect(store.isOpen).toEqual(true); + store.togglePanel(null); + expect(store.isOpen).toEqual(false); + store.togglePanel(null); + expect(store.isOpen).toEqual(true); + }); + }); + + it("doesn't restore member info cards when switching back to a room", async () => { + await viewRoom("!1:example.org"); + store.setCards([ + { + phase: RightPanelPhases.RoomMemberList, + }, + { + phase: RightPanelPhases.RoomMemberInfo, + state: { member: new RoomMember("!1:example.org", "@alice:example.org") }, + }, + ], true, "!1:example.org"); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.RoomMemberInfo); + + // Switch away and back + await viewRoom("!2:example.org"); + await viewRoom("!1:example.org"); + expect(store.currentCardForRoom("!1:example.org").phase).toEqual(RightPanelPhases.RoomMemberList); + }); +});