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
This commit is contained in:
Robin 2022-06-17 16:57:40 -04:00 committed by GitHub
parent 162be6ca94
commit ef48443dc9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 432 additions and 124 deletions

View file

@ -493,7 +493,7 @@ class LoggedInView extends React.Component<IProps, IState> {
break; break;
case KeyBindingAction.ToggleRoomSidePanel: case KeyBindingAction.ToggleRoomSidePanel:
if (this.props.page_type === "room_view") { if (this.props.page_type === "room_view") {
RightPanelStore.instance.togglePanel(); RightPanelStore.instance.togglePanel(null);
handled = true; handled = true;
} }
break; break;

View file

@ -91,12 +91,6 @@ export default class RightPanel extends React.Component<IProps, IState> {
currentCard = RightPanelStore.instance.currentCardForRoom(props.room.roomId); 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 { return {
cardState: currentCard?.state, cardState: currentCard?.state,
phase: currentCard?.phase, phase: currentCard?.phase,
@ -142,7 +136,7 @@ export default class RightPanel extends React.Component<IProps, IState> {
// When the user clicks close on the encryption panel cancel the pending request first if any // When the user clicks close on the encryption panel cancel the pending request first if any
this.state.cardState.verificationRequest.cancel(); this.state.cardState.verificationRequest.cancel();
} else { } else {
RightPanelStore.instance.togglePanel(); RightPanelStore.instance.togglePanel(this.props.room?.roomId);
} }
}; };

View file

@ -361,7 +361,7 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
) { ) {
// hide chat in right panel when the widget is minimized // hide chat in right panel when the widget is minimized
RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary }); RightPanelStore.instance.setCard({ phase: RightPanelPhases.RoomSummary });
RightPanelStore.instance.togglePanel(); RightPanelStore.instance.togglePanel(this.state.roomId);
} }
this.checkWidgets(this.state.room); this.checkWidgets(this.state.room);
}; };
@ -1020,6 +1020,14 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
this.updatePermissions(room); this.updatePermissions(room);
this.checkWidgets(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({ this.setState({
tombstone: this.getRoomTombstone(room), tombstone: this.getRoomTombstone(room),
liveTimeline: room.getLiveTimeline(), liveTimeline: room.getLiveTimeline(),

View file

@ -122,7 +122,7 @@ const EncryptionPanel: React.FC<IProps> = (props: IProps) => {
state: { member, verificationRequest: verificationRequest_ }, state: { member, verificationRequest: verificationRequest_ },
}); });
} }
if (!RightPanelStore.instance.isOpen) RightPanelStore.instance.togglePanel(); if (!RightPanelStore.instance.isOpen) RightPanelStore.instance.togglePanel(null);
}, [member]); }, [member]);
const requested = const requested =

View file

@ -70,10 +70,10 @@ export default abstract class HeaderButtons<P = {}> extends React.Component<IPro
public setPhase(phase: RightPanelPhases, cardState?: Partial<IRightPanelCardState>) { public setPhase(phase: RightPanelPhases, cardState?: Partial<IRightPanelCardState>) {
const rps = RightPanelStore.instance; const rps = RightPanelStore.instance;
if (rps.currentCard.phase == phase && !cardState && rps.isOpen) { if (rps.currentCard.phase == phase && !cardState && rps.isOpen) {
rps.togglePanel(); rps.togglePanel(null);
} else { } else {
RightPanelStore.instance.setCard({ phase, state: cardState }); RightPanelStore.instance.setCard({ phase, state: cardState });
if (!rps.isOpen) rps.togglePanel(); if (!rps.isOpen) rps.togglePanel(null);
} }
} }

View file

@ -209,7 +209,7 @@ export default class RoomHeaderButtons extends HeaderButtons<IProps> {
private onThreadsPanelClicked = (ev: ButtonEvent) => { private onThreadsPanelClicked = (ev: ButtonEvent) => {
if (RoomHeaderButtons.THREAD_PHASES.includes(this.state.phase)) { if (RoomHeaderButtons.THREAD_PHASES.includes(this.state.phase)) {
RightPanelStore.instance.togglePanel(); RightPanelStore.instance.togglePanel(this.props.room?.roomId);
} else { } else {
showThreadPanel(); showThreadPanel();
PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", ev); PosthogTrackers.trackInteraction("WebRoomHeaderButtonsThreadsButton", ev);

View file

@ -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) // 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 // and we intentionally don't set the client before this point to avoid stores
// updating for every event emitted during the cached sync. // updating for every event emitted during the cached sync.
if (!(payload.prevState === SyncState.Prepared && payload.state !== SyncState.Prepared)) { if (
return; payload.prevState !== SyncState.Prepared
} && payload.state === SyncState.Prepared
&& this.matrixClient !== payload.matrixClient
if (this.matrixClient !== payload.matrixClient) { ) {
if (this.matrixClient) { if (this.matrixClient) {
await this.onNotReady(); await this.onNotReady();
} }

View file

@ -45,14 +45,22 @@ import { RoomViewStore } from "../RoomViewStore";
export default class RightPanelStore extends ReadyWatchingStore { export default class RightPanelStore extends ReadyWatchingStore {
private static internalInstance: RightPanelStore; private static internalInstance: RightPanelStore;
private global?: IRightPanelForRoom = null; private global?: IRightPanelForRoom;
private byRoom: { private byRoom: { [roomId: string]: IRightPanelForRoom };
[roomId: string]: IRightPanelForRoom;
} = {};
private viewedRoomId: Optional<string>; private viewedRoomId: Optional<string>;
private constructor() { private constructor() {
super(defaultDispatcher); 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<any> { protected async onReady(): Promise<any> {
@ -134,19 +142,20 @@ export default class RightPanelStore extends ReadyWatchingStore {
const cardState = redirect?.state ?? (Object.keys(card.state ?? {}).length === 0 ? null : card.state); const cardState = redirect?.state ?? (Object.keys(card.state ?? {}).length === 0 ? null : card.state);
// Checks for wrong SetRightPanelPhase requests // Checks for wrong SetRightPanelPhase requests
if (!this.isPhaseValid(targetPhase)) return; if (!this.isPhaseValid(targetPhase, Boolean(rId))) return;
if ((targetPhase === this.currentCardForRoom(rId)?.phase && !!cardState)) { 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...) // 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 ?? []; const hist = this.byRoom[rId]?.history ?? [];
hist[hist.length - 1].state = cardState; hist[hist.length - 1].state = cardState;
this.emitAndUpdateSettings(); this.emitAndUpdateSettings();
} else if (targetPhase !== this.currentCard?.phase) { } else if (targetPhase !== this.currentCardForRoom(rId)?.phase || !this.byRoom[rId]) {
// Set right panel and erase history. // Set right panel and initialize/erase history
this.show(); const history = [{ phase: targetPhase, state: cardState ?? {} }];
this.setRightPanelCache({ phase: targetPhase, state: cardState ?? {} }, rId); this.byRoom[rId] = { history, isOpen: true };
this.emitAndUpdateSettings();
} else { } else {
this.show(); this.show(rId);
this.emitAndUpdateSettings(); this.emitAndUpdateSettings();
} }
} }
@ -156,23 +165,23 @@ export default class RightPanelStore extends ReadyWatchingStore {
const rId = roomId ?? this.viewedRoomId; const rId = roomId ?? this.viewedRoomId;
const history = cards.map(c => ({ phase: c.phase, state: c.state ?? {} })); const history = cards.map(c => ({ phase: c.phase, state: c.state ?? {} }));
this.byRoom[rId] = { history, isOpen: true }; this.byRoom[rId] = { history, isOpen: true };
this.show(); this.show(rId);
this.emitAndUpdateSettings(); this.emitAndUpdateSettings();
} }
// Appends a card to the history and shows the right panel if not already visible
public pushCard( public pushCard(
card: IRightPanelCard, card: IRightPanelCard,
allowClose = true, allowClose = true,
roomId: string = null, 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 rId = roomId ?? this.viewedRoomId;
const redirect = this.getVerificationRedirect(card); const redirect = this.getVerificationRedirect(card);
const targetPhase = redirect?.phase ?? card.phase; 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 // Checks for wrong SetRightPanelPhase requests
if (!this.isPhaseValid(targetPhase)) return; if (!this.isPhaseValid(targetPhase, Boolean(rId))) return;
const roomCache = this.byRoom[rId]; const roomCache = this.byRoom[rId];
if (!!roomCache) { if (!!roomCache) {
@ -182,12 +191,12 @@ export default class RightPanelStore extends ReadyWatchingStore {
} else { } else {
// setup room panel cache with the new card // setup room panel cache with the new card
this.byRoom[rId] = { 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 // if there was no right panel store object the the panel was closed -> keep it closed, except if allowClose==false
isOpen: !allowClose, isOpen: !allowClose,
}; };
} }
this.show(); this.show(rId);
this.emitAndUpdateSettings(); this.emitAndUpdateSettings();
} }
@ -200,7 +209,7 @@ export default class RightPanelStore extends ReadyWatchingStore {
return removedCard; return removedCard;
} }
public togglePanel(roomId: string = null) { public togglePanel(roomId: string | null) {
const rId = roomId ?? this.viewedRoomId; const rId = roomId ?? this.viewedRoomId;
if (!this.byRoom[rId]) return; if (!this.byRoom[rId]) return;
@ -208,27 +217,31 @@ export default class RightPanelStore extends ReadyWatchingStore {
this.emitAndUpdateSettings(); this.emitAndUpdateSettings();
} }
public show() { public show(roomId: string | null) {
if (!this.isOpen) { if (!this.isOpenForRoom(roomId ?? this.viewedRoomId)) {
this.togglePanel(); this.togglePanel(roomId);
} }
} }
public hide() { public hide(roomId: string | null) {
if (this.isOpen) { if (this.isOpenForRoom(roomId ?? this.viewedRoomId)) {
this.togglePanel(); this.togglePanel(roomId);
} }
} }
private loadCacheFromSettings() { private loadCacheFromSettings() {
const room = this.viewedRoomId && this.mxClient?.getRoom(this.viewedRoomId); if (this.viewedRoomId) {
if (!!room) { const room = this.mxClient?.getRoom(this.viewedRoomId);
this.global = this.global ?? if (!!room) {
convertToStatePanel(SettingsStore.getValue("RightPanel.phasesGlobal"), room); this.global = this.global ??
this.byRoom[this.viewedRoomId] = this.byRoom[this.viewedRoomId] ?? convertToStatePanel(SettingsStore.getValue("RightPanel.phasesGlobal"), room);
convertToStatePanel(SettingsStore.getValue("RightPanel.phases", this.viewedRoomId), room); this.byRoom[this.viewedRoomId] = this.byRoom[this.viewedRoomId] ??
} else { convertToStatePanel(SettingsStore.getValue("RightPanel.phases", this.viewedRoomId), room);
console.warn("Could not restore the right panel after load because there was no associated room object."); } 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: case RightPanelPhases.ThreadView:
if (!SettingsStore.getValue("feature_thread")) return false; if (!SettingsStore.getValue("feature_thread")) return false;
if (!card.state.threadHeadEvent) { 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; return !!card.state.threadHeadEvent;
case RightPanelPhases.RoomMemberInfo: case RightPanelPhases.RoomMemberInfo:
case RightPanelPhases.SpaceMemberInfo: case RightPanelPhases.SpaceMemberInfo:
case RightPanelPhases.EncryptionPanel: case RightPanelPhases.EncryptionPanel:
if (!card.state.member) { 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; return !!card.state.member;
case RightPanelPhases.Room3pidMemberInfo: case RightPanelPhases.Room3pidMemberInfo:
case RightPanelPhases.Space3pidMemberInfo: case RightPanelPhases.Space3pidMemberInfo:
if (!card.state.memberInfoEvent) { 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; return !!card.state.memberInfoEvent;
case RightPanelPhases.Widget: case RightPanelPhases.Widget:
if (!card.state.widgetId) { 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 !!card.state.widgetId;
} }
return true; 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 { private getVerificationRedirect(card: IRightPanelCard): IRightPanelCard {
if (card.phase === RightPanelPhases.RoomMemberInfo && card.state) { if (card.phase === RightPanelPhases.RoomMemberInfo && card.state) {
// RightPanelPhases.RoomMemberInfo -> needs to be changed to RightPanelPhases.EncryptionPanel if there is a pending verification request // 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; return null;
} }
public isPhaseValid(targetPhase: RightPanelPhases, isViewingRoom = this.isViewingRoom): boolean { private isPhaseValid(targetPhase: RightPanelPhases, isViewingRoom: boolean): boolean {
if (!RightPanelPhases[targetPhase]) { if (!RightPanelPhases[targetPhase]) {
logger.warn(`Tried to switch right panel to unknown phase: ${targetPhase}`); logger.warn(`Tried to switch right panel to unknown phase: ${targetPhase}`);
return false; return false;
@ -386,10 +393,6 @@ export default class RightPanelStore extends ReadyWatchingStore {
this.emitAndUpdateSettings(); this.emitAndUpdateSettings();
} }
private get isViewingRoom(): boolean {
return !!this.viewedRoomId;
}
public static get instance(): RightPanelStore { public static get instance(): RightPanelStore {
if (!RightPanelStore.internalInstance) { if (!RightPanelStore.internalInstance) {
RightPanelStore.internalInstance = new RightPanelStore(); RightPanelStore.internalInstance = new RightPanelStore();

View file

@ -15,37 +15,112 @@ limitations under the License.
*/ */
import React from "react"; import React from "react";
import TestRenderer from "react-test-renderer"; import { mount } from "enzyme";
import { jest } from "@jest/globals"; 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 { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import ResizeNotifier from "../../../src/utils/ResizeNotifier"; 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 { Action } from "../../../src/dispatcher/actions";
import dis from "../../../src/dispatcher/dispatcher"; import dis from "../../../src/dispatcher/dispatcher";
import DMRoomMap from "../../../src/utils/DMRoomMap"; import DMRoomMap from "../../../src/utils/DMRoomMap";
import MatrixClientContext from "../../../src/contexts/MatrixClientContext";
import SettingsStore from "../../../src/settings/SettingsStore"; import SettingsStore from "../../../src/settings/SettingsStore";
import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases"; import { RightPanelPhases } from "../../../src/stores/right-panel/RightPanelStorePhases";
import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore"; import RightPanelStore from "../../../src/stores/right-panel/RightPanelStore";
import { UPDATE_EVENT } from "../../../src/stores/AsyncStore"; import { UPDATE_EVENT } from "../../../src/stores/AsyncStore";
import { WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore"; 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", () => { describe("RightPanel", () => {
it("renders info from only one room during room changes", async () => { const resizeNotifier = new ResizeNotifier();
let cli: MockedObject<MatrixClient>;
beforeEach(() => {
stubClient(); stubClient();
const cli = MatrixClientPeg.get(); cli = mocked(MatrixClientPeg.get());
cli.hasLazyLoadMembersEnabled = () => false;
// Init misc. startup deps
DMRoomMap.makeShared(); DMRoomMap.makeShared();
});
const r1 = new Room("r1", cli, "@name:example.com"); afterEach(async () => {
const r2 = new Room("r2", cli, "@name:example.com"); const roomChanged = new Promise<void>(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<void>(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(<RightPanel room={r1} resizeNotifier={resizeNotifier} />);
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 === "r1") return r1;
if (roomId === "r2") return r2; if (roomId === "r2") return r2;
return null; return null;
@ -70,35 +145,12 @@ describe("RightPanel", () => {
return null; return null;
}); });
// Wake up various stores we rely on await spinUpStores();
WidgetLayoutStore.instance.useUnitTestClient(cli);
// @ts-ignore
await WidgetLayoutStore.instance.onReady();
RightPanelStore.instance.useUnitTestClient(cli);
// @ts-ignore
await RightPanelStore.instance.onReady();
const resizeNotifier = new ResizeNotifier();
// Run initial render with room 1, and also running lifecycle methods // Run initial render with room 1, and also running lifecycle methods
const renderer = TestRenderer.create(<MatrixClientContext.Provider value={cli}> const wrapper = mount(<RightPanel room={r1} resizeNotifier={resizeNotifier} />);
<RightPanel
room={r1}
resizeNotifier={resizeNotifier}
/>
</MatrixClientContext.Provider>);
// Wait for RPS room 1 updates to fire // Wait for RPS room 1 updates to fire
const rpsUpdated = new Promise<void>(resolve => { const rpsUpdated = waitForRpsUpdate();
const update = () => {
if (
RightPanelStore.instance.currentCardForRoom("r1").phase !==
RightPanelPhases.RoomMemberList
) return;
RightPanelStore.instance.off(UPDATE_EVENT, update);
resolve();
};
RightPanelStore.instance.on(UPDATE_EVENT, update);
});
dis.dispatch({ dis.dispatch({
action: Action.ViewRoom, action: Action.ViewRoom,
room_id: "r1", room_id: "r1",
@ -108,7 +160,7 @@ describe("RightPanel", () => {
// After all that setup, now to the interesting part... // After all that setup, now to the interesting part...
// We want to verify that as we change to room 2, we should always have // 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. // 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<void>(resolve => { const rendered = new Promise<void>(resolve => {
jest.spyOn(instance, "render").mockImplementation(() => { jest.spyOn(instance, "render").mockImplementation(() => {
const { props, state } = instance; const { props, state } = instance;
@ -127,21 +179,8 @@ describe("RightPanel", () => {
action: Action.ViewRoom, action: Action.ViewRoom,
room_id: "r2", room_id: "r2",
}); });
renderer.update(<MatrixClientContext.Provider value={cli}> wrapper.setProps({ room: r2 });
<RightPanel
room={r2}
resizeNotifier={resizeNotifier}
/>
</MatrixClientContext.Provider>);
await rendered; await rendered;
}); });
afterAll(async () => {
// @ts-ignore
await WidgetLayoutStore.instance.onNotReady();
// @ts-ignore
await RightPanelStore.instance.onNotReady();
jest.restoreAllMocks();
});
}); });

View file

@ -16,12 +16,13 @@ limitations under the License.
import React from "react"; import React from "react";
import { mount, ReactWrapper } from "enzyme"; import { mount, ReactWrapper } from "enzyme";
import { act } from "react-dom/test-utils";
import { mocked, MockedObject } from "jest-mock"; import { mocked, MockedObject } from "jest-mock";
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room";
import { MatrixEvent } from "matrix-js-sdk/src/models/event"; 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 { MatrixClientPeg } from "../../../src/MatrixClientPeg";
import { Action } from "../../../src/dispatcher/actions"; import { Action } from "../../../src/dispatcher/actions";
import dis from "../../../src/dispatcher/dispatcher"; 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 { RoomView as _RoomView } from "../../../src/components/structures/RoomView";
import ResizeNotifier from "../../../src/utils/ResizeNotifier"; import ResizeNotifier from "../../../src/utils/ResizeNotifier";
import { RoomViewStore } from "../../../src/stores/RoomViewStore"; 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 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); const RoomView = wrapInMatrixClientContext(_RoomView);
describe("RoomView", () => { describe("RoomView", () => {
let cli: MockedObject<MatrixClient>; let cli: MockedObject<MatrixClient>;
let room: Room; let room: Room;
beforeEach(() => { let roomCount = 0;
beforeEach(async () => {
mockPlatformPeg({ reload: () => {} });
stubClient(); stubClient();
cli = mocked(MatrixClientPeg.get()); cli = mocked(MatrixClientPeg.get());
room = new Room("r1", cli, "@alice:example.com"); room = new Room(`!${roomCount++}:example.org`, cli, "@alice:example.org");
room.getPendingEvents = () => []; room.getPendingEvents = () => [];
cli.getRoom.mockReturnValue(room); cli.getRoom.mockReturnValue(room);
// Re-emit certain events on the mocked client // Re-emit certain events on the mocked client
@ -48,11 +56,17 @@ describe("RoomView", () => {
room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args)); room.on(RoomEvent.TimelineReset, (...args) => cli.emit(RoomEvent.TimelineReset, ...args));
DMRoomMap.makeShared(); DMRoomMap.makeShared();
RightPanelStore.instance.useUnitTestClient(cli);
});
afterEach(async () => {
unmockPlatformPeg();
jest.restoreAllMocks();
}); });
const mountRoomView = async (): Promise<ReactWrapper> => { const mountRoomView = async (): Promise<ReactWrapper> => {
if (RoomViewStore.instance.getRoomId() !== room.roomId) { if (RoomViewStore.instance.getRoomId() !== room.roomId) {
const switchRoomPromise = new Promise<void>(resolve => { const switchedRoom = new Promise<void>(resolve => {
const subscription = RoomViewStore.instance.addListener(() => { const subscription = RoomViewStore.instance.addListener(() => {
if (RoomViewStore.instance.getRoomId()) { if (RoomViewStore.instance.getRoomId()) {
subscription.remove(); subscription.remove();
@ -67,10 +81,10 @@ describe("RoomView", () => {
metricsTrigger: null, metricsTrigger: null,
}); });
await switchRoomPromise; await switchedRoom;
} }
return mount( const roomView = mount(
<RoomView <RoomView
mxClient={cli} mxClient={cli}
threepidInvite={null} threepidInvite={null}
@ -81,6 +95,8 @@ describe("RoomView", () => {
onRegistered={null} onRegistered={null}
/>, />,
); );
await act(() => Promise.resolve()); // Allow state to settle
return roomView;
}; };
const getRoomViewInstance = async (): Promise<_RoomView> => const getRoomViewInstance = async (): Promise<_RoomView> =>
(await mountRoomView()).find(_RoomView).instance() as _RoomView; (await mountRoomView()).find(_RoomView).instance() as _RoomView;
@ -126,4 +142,25 @@ describe("RoomView", () => {
room.getUnfilteredTimelineSet().resetLiveTimeline(); room.getUnfilteredTimelineSet().resetLiveTimeline();
expect(roomViewInstance.state.liveTimeline).not.toEqual(oldTimeline); 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);
});
});
}); });

View file

@ -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<MatrixClient>;
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<void>(resolve => {
const ref = defaultDispatcher.register(payload => {
if (payload.action === Action.ActiveRoomChanged && payload.newRoomId === roomId) {
defaultDispatcher.unregister(ref);
resolve();
}
});
});
defaultDispatcher.dispatch<ActiveRoomChangedPayload>({
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);
});
});