From fc6b83c786c41fb9fd49a5dcb24b283e7f28c7c0 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 6 Jan 2023 17:24:13 +0100 Subject: [PATCH 01/29] Make create poll dialog scale better Fixes: vector-im/element-web#21855 --- res/css/views/dialogs/_CompoundDialog.pcss | 27 +++++++++++++------ .../views/dialogs/ScrollableBaseModal.tsx | 2 +- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/res/css/views/dialogs/_CompoundDialog.pcss b/res/css/views/dialogs/_CompoundDialog.pcss index b20c17cd2a..dc0e61b65e 100644 --- a/res/css/views/dialogs/_CompoundDialog.pcss +++ b/res/css/views/dialogs/_CompoundDialog.pcss @@ -27,6 +27,11 @@ limitations under the License. } .mx_CompoundDialog { + .mx_Dialog { + display: flex; + flex-direction: column; + } + .mx_CompoundDialog_header { padding: 32px 32px 16px 32px; @@ -49,6 +54,13 @@ limitations under the License. } } + .mx_CompoundDialog_form { + display: flex; + flex-direction: column; + min-height: 0; + max-height: 100%; + } + .mx_CompoundDialog_content { overflow: auto; padding: 8px 32px; @@ -57,10 +69,6 @@ limitations under the License. .mx_CompoundDialog_footer { padding: 20px 32px; text-align: right; - position: absolute; - bottom: 0; - left: 0; - right: 0; .mx_AccessibleButton { margin-left: 24px; @@ -69,14 +77,17 @@ limitations under the License. } .mx_ScrollableBaseDialog { + display: flex; + flex-direction: column; + width: 544px; /* fixed */ height: 516px; /* fixed */ - - .mx_CompoundDialog_content { - height: 349px; /* dialogHeight - header - footer */ - } + max-width: 100%; + min-height: 0; + max-height: 80%; .mx_CompoundDialog_footer { box-shadow: 0px -4px 4px rgba(0, 0, 0, 0.05); /* hardcoded colour for both themes */ + z-index: 1; } } diff --git a/src/components/views/dialogs/ScrollableBaseModal.tsx b/src/components/views/dialogs/ScrollableBaseModal.tsx index 32975c6833..97ddbda2c9 100644 --- a/src/components/views/dialogs/ScrollableBaseModal.tsx +++ b/src/components/views/dialogs/ScrollableBaseModal.tsx @@ -96,7 +96,7 @@ export default abstract class ScrollableBaseModal< aria-label={_t("Close dialog")} /> -
+
{this.renderContent()}
From 214fa13d7d5040929be4a8e97495e25299adc694 Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Fri, 6 Jan 2023 17:44:36 +0100 Subject: [PATCH 02/29] Update snapshots --- .../elements/__snapshots__/PollCreateDialog-test.tsx.snap | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap b/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap index 1bcf17dcd4..7e60d63359 100644 --- a/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap +++ b/test/components/views/elements/__snapshots__/PollCreateDialog-test.tsx.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`PollCreateDialog renders a blank poll 1`] = `"
"`; +exports[`PollCreateDialog renders a blank poll 1`] = `"
"`; -exports[`PollCreateDialog renders a question and some options 1`] = `"
"`; +exports[`PollCreateDialog renders a question and some options 1`] = `"
"`; -exports[`PollCreateDialog renders info from a previous event 1`] = `"
"`; +exports[`PollCreateDialog renders info from a previous event 1`] = `"
"`; From 7ca1b385d95045c850a6f0f66d7b5456cc945104 Mon Sep 17 00:00:00 2001 From: Germain Date: Mon, 9 Jan 2023 12:38:19 +0000 Subject: [PATCH 03/29] Force enable threads labs flag --- cypress/e2e/polls/polls.spec.ts | 2 +- cypress/e2e/threads/threads.spec.ts | 2 +- src/MatrixClientPeg.ts | 2 +- src/Unread.ts | 2 +- src/components/structures/MessagePanel.tsx | 2 +- src/components/structures/RoomSearchView.tsx | 2 +- src/components/structures/RoomView.tsx | 2 +- src/components/structures/ThreadPanel.tsx | 2 +- src/components/structures/TimelinePanel.tsx | 2 +- src/components/views/context_menus/MessageContextMenu.tsx | 4 ++-- src/components/views/messages/MessageActionBar.tsx | 8 ++++---- src/components/views/right_panel/RoomHeaderButtons.tsx | 2 +- src/components/views/rooms/EventTile.tsx | 6 +++--- src/components/views/rooms/SearchResultTile.tsx | 2 +- src/components/views/rooms/SendMessageComposer.tsx | 2 +- .../views/rooms/wysiwyg_composer/utils/message.ts | 2 +- src/settings/Settings.tsx | 4 ++-- src/stores/TypingStore.ts | 2 +- src/stores/right-panel/RightPanelStore.ts | 4 ++-- src/stores/widgets/StopGapWidgetDriver.ts | 2 +- src/utils/Reply.ts | 4 ++-- src/utils/exportUtils/HtmlExport.tsx | 2 +- test/components/structures/TimelinePanel-test.tsx | 8 ++++---- test/components/views/messages/MessageActionBar-test.tsx | 4 ++-- .../views/right_panel/RoomHeaderButtons-test.tsx | 2 +- test/components/views/rooms/EventTile-test.tsx | 2 +- test/components/views/settings/Notifications-test.tsx | 3 ++- 27 files changed, 41 insertions(+), 40 deletions(-) diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index c092d4f647..4a7b32eb04 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -77,7 +77,7 @@ describe("Polls", () => { }; beforeEach(() => { - cy.enableLabsFeature("feature_threadstable"); + cy.enableLabsFeature("feature_threadenabled"); cy.window().then((win) => { win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests }); diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 7b616bd13f..3a87ab18dc 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -29,7 +29,7 @@ describe("Threads", () => { beforeEach(() => { // Default threads to ON for this spec - cy.enableLabsFeature("feature_threadstable"); + cy.enableLabsFeature("feature_threadenabled"); cy.window().then((win) => { win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests }); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index a18b4e9a6f..f674892bf7 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -218,7 +218,7 @@ class MatrixClientPegClass implements IMatrixClientPeg { opts.pendingEventOrdering = PendingEventOrdering.Detached; opts.lazyLoadMembers = true; opts.clientWellKnownPollPeriod = 2 * 60 * 60; // 2 hours - opts.experimentalThreadSupport = SettingsStore.getValue("feature_threadstable"); + opts.experimentalThreadSupport = SettingsStore.getValue("feature_threadenabled"); if (SettingsStore.getValue("feature_sliding_sync")) { const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url"); diff --git a/src/Unread.ts b/src/Unread.ts index 17fe76f03f..d604a50d79 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -66,7 +66,7 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { // despite the name of the method :(( const readUpToId = room.getEventReadUpTo(myUserId!); - if (!SettingsStore.getValue("feature_threadstable")) { + if (!SettingsStore.getValue("feature_threadenabled")) { // as we don't send RRs for our own messages, make sure we special case that // if *we* sent the last message into the room, we consider it not unread! // Should fix: https://github.com/vector-im/element-web/issues/3263 diff --git a/src/components/structures/MessagePanel.tsx b/src/components/structures/MessagePanel.tsx index 8942f0abd4..cd3f322369 100644 --- a/src/components/structures/MessagePanel.tsx +++ b/src/components/structures/MessagePanel.tsx @@ -287,7 +287,7 @@ export default class MessagePanel extends React.Component { // and we check this in a hot code path. This is also cached in our // RoomContext, however we still need a fallback for roomless MessagePanels. this._showHiddenEvents = SettingsStore.getValue("showHiddenEventsInTimeline"); - this.threadsEnabled = SettingsStore.getValue("feature_threadstable"); + this.threadsEnabled = SettingsStore.getValue("feature_threadenabled"); this.showTypingNotificationsWatcherRef = SettingsStore.watchSetting( "showTypingNotifications", diff --git a/src/components/structures/RoomSearchView.tsx b/src/components/structures/RoomSearchView.tsx index 269980c6a3..d7a995b5c0 100644 --- a/src/components/structures/RoomSearchView.tsx +++ b/src/components/structures/RoomSearchView.tsx @@ -100,7 +100,7 @@ export const RoomSearchView = forwardRef( return b.length - a.length; }); - if (SettingsStore.getValue("feature_threadstable")) { + if (SettingsStore.getValue("feature_threadenabled")) { // Process all thread roots returned in this batch of search results // XXX: This won't work for results coming from Seshat which won't include the bundled relationship for (const result of results.results) { diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 3de552d1d0..eb034fd2b7 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1182,7 +1182,7 @@ export class RoomView extends React.Component { CHAT_EFFECTS.forEach((effect) => { if (containsEmoji(ev.getContent(), effect.emojis) || ev.getContent().msgtype === effect.msgType) { // For initial threads launch, chat effects are disabled see #19731 - if (!SettingsStore.getValue("feature_threadstable") || !ev.isRelation(THREAD_RELATION_TYPE.name)) { + if (!SettingsStore.getValue("feature_threadenabled") || !ev.isRelation(THREAD_RELATION_TYPE.name)) { dis.dispatch({ action: `effects.${effect.command}` }); } } diff --git a/src/components/structures/ThreadPanel.tsx b/src/components/structures/ThreadPanel.tsx index 51990a739d..b313a0948e 100644 --- a/src/components/structures/ThreadPanel.tsx +++ b/src/components/structures/ThreadPanel.tsx @@ -249,7 +249,7 @@ const ThreadPanel: React.FC = ({ roomId, onClose, permalinkCreator }) => const openFeedback = shouldShowFeedback() ? () => { Modal.createDialog(BetaFeedbackDialog, { - featureId: "feature_threadstable", + featureId: "feature_threadenabled", }); } : null; diff --git a/src/components/structures/TimelinePanel.tsx b/src/components/structures/TimelinePanel.tsx index c1fc13bbb1..fedada7c3f 100644 --- a/src/components/structures/TimelinePanel.tsx +++ b/src/components/structures/TimelinePanel.tsx @@ -1688,7 +1688,7 @@ class TimelinePanel extends React.Component { is very tied to the main room timeline, we are forcing the timeline to send read receipts for threaded events */ const isThreadTimeline = this.context.timelineRenderingType === TimelineRenderingType.Thread; - if (SettingsStore.getValue("feature_threadstable") && isThreadTimeline) { + if (SettingsStore.getValue("feature_threadenabled") && isThreadTimeline) { return 0; } const index = this.state.events.findIndex((ev) => ev.getId() === evId); diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index ed4c2ab4cb..295a4452cd 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -71,7 +71,7 @@ const ReplyInThreadButton = ({ mxEvent, closeMenu }: IReplyInThreadButton) => { if (Boolean(relationType) && relationType !== RelationType.Thread) return null; const onClick = (): void => { - if (!SettingsStore.getValue("feature_threadstable")) { + if (!SettingsStore.getValue("feature_threadenabled")) { dis.dispatch({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, @@ -640,7 +640,7 @@ export default class MessageContextMenu extends React.Component rightClick && contentActionable && canSendMessages && - SettingsStore.getValue("feature_threadstable") && + SettingsStore.getValue("feature_threadenabled") && Thread.hasServerSideSupport && timelineRenderingType !== TimelineRenderingType.Thread ) { diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 1ec7fae751..5b78a2614f 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -204,7 +204,7 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { const relationType = mxEvent?.getRelation()?.rel_type; const hasARelation = !!relationType && relationType !== RelationType.Thread; - const threadsEnabled = SettingsStore.getValue("feature_threadstable"); + const threadsEnabled = SettingsStore.getValue("feature_threadenabled"); if (!threadsEnabled && !Thread.hasServerSideSupport) { // hide the prompt if the user would only have degraded mode @@ -216,7 +216,7 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => { e.preventDefault(); e.stopPropagation(); - if (!SettingsStore.getValue("feature_threadstable")) { + if (!SettingsStore.getValue("feature_threadenabled")) { dis.dispatch({ action: Action.ViewUserSettings, initialTabId: UserTab.Labs, @@ -252,7 +252,7 @@ const ReplyInThreadButton = ({ mxEvent }: IReplyInThreadButton) => {
{!hasARelation && (
- {SettingsStore.getValue("feature_threadstable") + {SettingsStore.getValue("feature_threadenabled") ? _t("Beta feature") : _t("Beta feature. Click to learn more.")}
@@ -548,7 +548,7 @@ export default class MessageActionBar extends React.PureComponent { ); rightPanelPhaseButtons.set( RightPanelPhases.ThreadPanel, - SettingsStore.getValue("feature_threadstable") ? ( + SettingsStore.getValue("feature_threadenabled") ? ( } } - if (SettingsStore.getValue("feature_threadstable")) { + if (SettingsStore.getValue("feature_threadenabled")) { this.props.mxEvent.on(ThreadEvent.Update, this.updateThread); if (this.thread && !this.supportsThreadNotifications) { @@ -469,7 +469,7 @@ export class UnwrappedEventTile extends React.Component if (this.props.showReactions) { this.props.mxEvent.removeListener(MatrixEventEvent.RelationsCreated, this.onReactionsCreated); } - if (SettingsStore.getValue("feature_threadstable")) { + if (SettingsStore.getValue("feature_threadenabled")) { this.props.mxEvent.off(ThreadEvent.Update, this.updateThread); } this.threadState?.off(NotificationStateEvents.Update, this.onThreadStateUpdate); @@ -500,7 +500,7 @@ export class UnwrappedEventTile extends React.Component }; private get thread(): Thread | null { - if (!SettingsStore.getValue("feature_threadstable")) { + if (!SettingsStore.getValue("feature_threadenabled")) { return null; } diff --git a/src/components/views/rooms/SearchResultTile.tsx b/src/components/views/rooms/SearchResultTile.tsx index 269a35d8a2..f88e71e02b 100644 --- a/src/components/views/rooms/SearchResultTile.tsx +++ b/src/components/views/rooms/SearchResultTile.tsx @@ -68,7 +68,7 @@ export default class SearchResultTile extends React.Component { const layout = SettingsStore.getValue("layout"); const isTwelveHour = SettingsStore.getValue("showTwelveHourTimestamps"); const alwaysShowTimestamps = SettingsStore.getValue("alwaysShowTimestamps"); - const threadsEnabled = SettingsStore.getValue("feature_threadstable"); + const threadsEnabled = SettingsStore.getValue("feature_threadenabled"); for (let j = 0; j < timeline.length; j++) { const mxEv = timeline[j]; diff --git a/src/components/views/rooms/SendMessageComposer.tsx b/src/components/views/rooms/SendMessageComposer.tsx index 692591fb0b..60ce5d09b4 100644 --- a/src/components/views/rooms/SendMessageComposer.tsx +++ b/src/components/views/rooms/SendMessageComposer.tsx @@ -436,7 +436,7 @@ export class SendMessageComposer extends React.Component ( diff --git a/src/stores/TypingStore.ts b/src/stores/TypingStore.ts index 4a572e74d9..8296ebfe5f 100644 --- a/src/stores/TypingStore.ts +++ b/src/stores/TypingStore.ts @@ -65,7 +65,7 @@ export default class TypingStore { if (SettingsStore.getValue("lowBandwidth")) return; // Disable typing notification for threads for the initial launch // before we figure out a better user experience for them - if (SettingsStore.getValue("feature_threadstable") && threadId) return; + if (SettingsStore.getValue("feature_threadenabled") && threadId) return; let currentTyping = this.typingStates[roomId]; if ((!isTyping && !currentTyping) || (currentTyping && currentTyping.isTyping === isTyping)) { diff --git a/src/stores/right-panel/RightPanelStore.ts b/src/stores/right-panel/RightPanelStore.ts index b9e218b369..3aea9a4746 100644 --- a/src/stores/right-panel/RightPanelStore.ts +++ b/src/stores/right-panel/RightPanelStore.ts @@ -278,10 +278,10 @@ export default class RightPanelStore extends ReadyWatchingStore { // (A nicer fix could be to indicate, that the right panel is loading if there is missing state data and re-emit if the data is available) switch (card.phase) { case RightPanelPhases.ThreadPanel: - if (!SettingsStore.getValue("feature_threadstable")) return false; + if (!SettingsStore.getValue("feature_threadenabled")) return false; break; case RightPanelPhases.ThreadView: - if (!SettingsStore.getValue("feature_threadstable")) return false; + if (!SettingsStore.getValue("feature_threadenabled")) return false; if (!card.state.threadHeadEvent) { logger.warn("removed card from right panel because of missing threadHeadEvent in card state"); } diff --git a/src/stores/widgets/StopGapWidgetDriver.ts b/src/stores/widgets/StopGapWidgetDriver.ts index 42fdf0dea4..3caac0fbca 100644 --- a/src/stores/widgets/StopGapWidgetDriver.ts +++ b/src/stores/widgets/StopGapWidgetDriver.ts @@ -236,7 +236,7 @@ export class StopGapWidgetDriver extends WidgetDriver { // For initial threads launch, chat effects are disabled // see #19731 const isNotThread = content["m.relates_to"].rel_type !== THREAD_RELATION_TYPE.name; - if (!SettingsStore.getValue("feature_threadstable") || isNotThread) { + if (!SettingsStore.getValue("feature_threadenabled") || isNotThread) { dis.dispatch({ action: `effects.${effect.command}` }); } } diff --git a/src/utils/Reply.ts b/src/utils/Reply.ts index b6ee476bf6..7bafd9f66a 100644 --- a/src/utils/Reply.ts +++ b/src/utils/Reply.ts @@ -176,7 +176,7 @@ export function makeReplyMixIn(ev?: MatrixEvent): IEventRelation { }; if (ev.threadRootId) { - if (SettingsStore.getValue("feature_threadstable")) { + if (SettingsStore.getValue("feature_threadenabled")) { mixin.is_falling_back = false; } else { // Clients that do not offer a threading UI should behave as follows when replying, for best interaction @@ -203,7 +203,7 @@ export function shouldDisplayReply(event: MatrixEvent): boolean { const relation = event.getRelation(); if ( - SettingsStore.getValue("feature_threadstable") && + SettingsStore.getValue("feature_threadenabled") && relation?.rel_type === THREAD_RELATION_TYPE.name && relation?.is_falling_back ) { diff --git a/src/utils/exportUtils/HtmlExport.tsx b/src/utils/exportUtils/HtmlExport.tsx index 95a2fdbd60..57eb54ce8f 100644 --- a/src/utils/exportUtils/HtmlExport.tsx +++ b/src/utils/exportUtils/HtmlExport.tsx @@ -62,7 +62,7 @@ export default class HTMLExporter extends Exporter { this.mediaOmitText = !this.exportOptions.attachmentsIncluded ? _t("Media omitted") : _t("Media omitted - file size limit exceeded"); - this.threadsEnabled = SettingsStore.getValue("feature_threadstable"); + this.threadsEnabled = SettingsStore.getValue("feature_threadenabled"); } protected async getRoomAvatar() { diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index d7ff659c9d..1b4bc4d5bc 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -172,7 +172,7 @@ describe("TimelinePanel", () => { const getValueCopy = SettingsStore.getValue; SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { if (name === "sendReadReceipts") return true; - if (name === "feature_threadstable") return false; + if (name === "feature_threadenabled") return false; return getValueCopy(name); }); @@ -186,7 +186,7 @@ describe("TimelinePanel", () => { const getValueCopy = SettingsStore.getValue; SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { if (name === "sendReadReceipts") return false; - if (name === "feature_threadstable") return false; + if (name === "feature_threadenabled") return false; return getValueCopy(name); }); @@ -363,7 +363,7 @@ describe("TimelinePanel", () => { client.supportsExperimentalThreads = () => true; const getValueCopy = SettingsStore.getValue; SettingsStore.getValue = jest.fn().mockImplementation((name: string) => { - if (name === "feature_threadstable") return true; + if (name === "feature_threadenabled") return true; return getValueCopy(name); }); @@ -518,7 +518,7 @@ describe("TimelinePanel", () => { }); it("renders when the last message is an undecryptable thread root", async () => { - jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => name === "feature_threadstable"); + jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => name === "feature_threadenabled"); const client = MatrixClientPeg.get(); client.isRoomEncrypted = () => true; diff --git a/test/components/views/messages/MessageActionBar-test.tsx b/test/components/views/messages/MessageActionBar-test.tsx index 8b64f205f0..c5f6c03b78 100644 --- a/test/components/views/messages/MessageActionBar-test.tsx +++ b/test/components/views/messages/MessageActionBar-test.tsx @@ -389,7 +389,7 @@ describe("", () => { describe("when threads feature is not enabled", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockImplementation( - (setting) => setting !== "feature_threadstable", + (setting) => setting !== "feature_threadenabled", ); }); @@ -435,7 +435,7 @@ describe("", () => { describe("when threads feature is enabled", () => { beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockImplementation( - (setting) => setting === "feature_threadstable", + (setting) => setting === "feature_threadenabled", ); }); diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/RoomHeaderButtons-test.tsx index f9a3572aa8..20416c8f7c 100644 --- a/test/components/views/right_panel/RoomHeaderButtons-test.tsx +++ b/test/components/views/right_panel/RoomHeaderButtons-test.tsx @@ -40,7 +40,7 @@ describe("RoomHeaderButtons-test.tsx", function () { }); jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string) => { - if (name === "feature_threadstable") return true; + if (name === "feature_threadenabled") return true; }); }); diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index f425bc5aa5..78600ba97c 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -80,7 +80,7 @@ describe("EventTile", () => { jest.spyOn(client, "getRoom").mockReturnValue(room); jest.spyOn(client, "decryptEventIfNeeded").mockResolvedValue(); - jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => name === "feature_threadstable"); + jest.spyOn(SettingsStore, "getValue").mockImplementation((name) => name === "feature_threadenabled"); mxEvent = mkMessage({ room: room.roomId, diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx index f3bb4abc30..b33f72838a 100644 --- a/test/components/views/settings/Notifications-test.tsx +++ b/test/components/views/settings/Notifications-test.tsx @@ -30,7 +30,7 @@ import { fireEvent, getByTestId, render, screen, waitFor } from "@testing-librar import Notifications from "../../../../src/components/views/settings/Notifications"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { StandardActions } from "../../../../src/notifications/StandardActions"; -import { getMockClientWithEventEmitter, mkMessage } from "../../../test-utils"; +import { getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils"; // don't pollute test output with error logs from mock rejections jest.mock("matrix-js-sdk/src/logger"); @@ -205,6 +205,7 @@ describe("", () => { }; const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), getPushRules: jest.fn(), getPushers: jest.fn(), getThreePids: jest.fn(), From f97cef80aed8ee6011543f08bee8b1745a33a7db Mon Sep 17 00:00:00 2001 From: Johannes Marbach Date: Tue, 10 Jan 2023 12:14:25 +0100 Subject: [PATCH 04/29] Add comment explaining z-index --- res/css/views/dialogs/_CompoundDialog.pcss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/res/css/views/dialogs/_CompoundDialog.pcss b/res/css/views/dialogs/_CompoundDialog.pcss index dc0e61b65e..15df4f3951 100644 --- a/res/css/views/dialogs/_CompoundDialog.pcss +++ b/res/css/views/dialogs/_CompoundDialog.pcss @@ -88,6 +88,6 @@ limitations under the License. .mx_CompoundDialog_footer { box-shadow: 0px -4px 4px rgba(0, 0, 0, 0.05); /* hardcoded colour for both themes */ - z-index: 1; + z-index: 1; /* needed to make footer & shadow appear above dialog content */ } } From d103860a9453ee0daaae7520b27652dbf7997afb Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Tue, 10 Jan 2023 15:34:17 +0100 Subject: [PATCH 05/29] Change RTE mode icons (#9861) Change RTE mode icons --- .../room/composer/plain_text.svg | 37 +++++-------------- .../views/rooms/MessageComposerButtons.tsx | 4 +- 2 files changed, 12 insertions(+), 29 deletions(-) diff --git a/res/img/element-icons/room/composer/plain_text.svg b/res/img/element-icons/room/composer/plain_text.svg index 874ae1a47d..053d41649a 100644 --- a/res/img/element-icons/room/composer/plain_text.svg +++ b/res/img/element-icons/room/composer/plain_text.svg @@ -4,31 +4,14 @@ height="20" viewBox="0 0 20 20" fill="none" - xmlns="http://www.w3.org/2000/svg" - > - - - - - - - - - + xmlns="http://www.w3.org/2000/svg"> + + + + + + + + + diff --git a/src/components/views/rooms/MessageComposerButtons.tsx b/src/components/views/rooms/MessageComposerButtons.tsx index 2285733da9..bce1182a13 100644 --- a/src/components/views/rooms/MessageComposerButtons.tsx +++ b/src/components/views/rooms/MessageComposerButtons.tsx @@ -376,8 +376,8 @@ function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonP Date: Tue, 10 Jan 2023 15:51:20 +0100 Subject: [PATCH 06/29] Prevent starting a voice message while recording a broadcast (#9844) --- .../CantStartVoiceMessageBroadcastDialog.tsx | 36 ++ .../views/rooms/MessageComposer.tsx | 23 +- src/i18n/strings/en_EN.json | 2 + .../views/rooms/MessageComposer-test.tsx | 341 ++++++++++++------ test/test-utils/composer.ts | 10 +- 5 files changed, 296 insertions(+), 116 deletions(-) create mode 100644 src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog.tsx diff --git a/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog.tsx b/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog.tsx new file mode 100644 index 0000000000..b55598a6b5 --- /dev/null +++ b/src/components/views/dialogs/CantStartVoiceMessageBroadcastDialog.tsx @@ -0,0 +1,36 @@ +/* +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 { _t } from "../../../languageHandler"; +import Modal from "../../../Modal"; +import InfoDialog from "./InfoDialog"; + +export const createCantStartVoiceMessageBroadcastDialog = (): void => { + Modal.createDialog(InfoDialog, { + title: _t("Can't start voice message"), + description: ( +

+ {_t( + "You can't start a voice message as you are currently recording a live broadcast. " + + "Please end your live broadcast in order to start recording a voice message.", + )} +

+ ), + hasCloseButton: true, + }); +}; diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index d7e9a5f71a..799fc98d64 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -58,6 +58,8 @@ import { SendWysiwygComposer, sendMessage, getConversionFunctions } from "./wysi import { MatrixClientProps, withMatrixClientHOC } from "../../../contexts/MatrixClientContext"; import { setUpVoiceBroadcastPreRecording } from "../../../voice-broadcast/utils/setUpVoiceBroadcastPreRecording"; import { SdkContextClass } from "../../../contexts/SDKContext"; +import { VoiceBroadcastInfoState } from "../../../voice-broadcast"; +import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStartVoiceMessageBroadcastDialog"; let instanceCount = 0; @@ -445,6 +447,20 @@ export class MessageComposer extends React.Component { } } + private onRecordStartEndClick = (): void => { + const currentBroadcastRecording = SdkContextClass.instance.voiceBroadcastRecordingsStore.getCurrent(); + + if (currentBroadcastRecording && currentBroadcastRecording.getState() !== VoiceBroadcastInfoState.Stopped) { + createCantStartVoiceMessageBroadcastDialog(); + } else { + this.voiceRecordingButton.current?.onRecordStartEndClick(); + } + + if (this.context.narrow) { + this.toggleButtonMenu(); + } + }; + public render() { const hasE2EIcon = Boolean(!this.state.isWysiwygLabEnabled && this.props.e2eStatus); const e2eIcon = hasE2EIcon && ( @@ -588,12 +604,7 @@ export class MessageComposer extends React.Component { isStickerPickerOpen={this.state.isStickerPickerOpen} menuPosition={menuPosition} relation={this.props.relation} - onRecordStartEndClick={() => { - this.voiceRecordingButton.current?.onRecordStartEndClick(); - if (this.context.narrow) { - this.toggleButtonMenu(); - } - }} + onRecordStartEndClick={this.onRecordStartEndClick} setStickerPickerOpen={this.setStickerPickerOpen} showLocationButton={!window.electron} showPollsButton={this.state.showPollsButton} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index cbefb01830..d4a8b522b8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2688,6 +2688,8 @@ "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)": "Uncheck if you also want to remove system messages on this user (e.g. membership change, profile change…)", "Remove %(count)s messages|other": "Remove %(count)s messages", "Remove %(count)s messages|one": "Remove 1 message", + "Can't start voice message": "Can't start voice message", + "You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message.": "You can't start a voice message as you are currently recording a live broadcast. Please end your live broadcast in order to start recording a voice message.", "Unable to load commit detail: %(msg)s": "Unable to load commit detail: %(msg)s", "Unavailable": "Unavailable", "Changelog": "Changelog", diff --git a/test/components/views/rooms/MessageComposer-test.tsx b/test/components/views/rooms/MessageComposer-test.tsx index a20695320b..3f445b244d 100644 --- a/test/components/views/rooms/MessageComposer-test.tsx +++ b/test/components/views/rooms/MessageComposer-test.tsx @@ -15,15 +15,21 @@ limitations under the License. */ import * as React from "react"; -// eslint-disable-next-line deprecate/import -import { mount, ReactWrapper } from "enzyme"; -import { MatrixEvent, MsgType, RoomMember } from "matrix-js-sdk/src/matrix"; +import { EventType, MatrixEvent, Room, RoomMember } from "matrix-js-sdk/src/matrix"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; +import { act, render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; -import { createTestClient, mkEvent, mkStubRoom, stubClient } from "../../../test-utils"; -import MessageComposer, { - MessageComposer as MessageComposerClass, -} from "../../../../src/components/views/rooms/MessageComposer"; +import { + createTestClient, + filterConsole, + flushPromises, + mkEvent, + mkStubRoom, + mockPlatformPeg, + stubClient, +} from "../../../test-utils"; +import MessageComposer from "../../../../src/components/views/rooms/MessageComposer"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import RoomContext from "../../../../src/contexts/RoomContext"; @@ -31,42 +37,108 @@ import { IRoomState } from "../../../../src/components/structures/RoomView"; import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks"; import { LocalRoom } from "../../../../src/models/LocalRoom"; -import MessageComposerButtons from "../../../../src/components/views/rooms/MessageComposerButtons"; import { Features } from "../../../../src/settings/Settings"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { SettingLevel } from "../../../../src/settings/SettingLevel"; import dis from "../../../../src/dispatcher/dispatcher"; -import { Action } from "../../../../src/dispatcher/actions"; -import { SendMessageComposer } from "../../../../src/components/views/rooms/SendMessageComposer"; import { E2EStatus } from "../../../../src/utils/ShieldUtils"; -import { addTextToComposerEnzyme } from "../../../test-utils/composer"; +import { addTextToComposerRTL } from "../../../test-utils/composer"; import UIStore, { UI_EVENTS } from "../../../../src/stores/UIStore"; -import { SendWysiwygComposer } from "../../../../src/components/views/rooms/wysiwyg_composer"; +import { Action } from "../../../../src/dispatcher/actions"; +import { VoiceBroadcastInfoState, VoiceBroadcastRecording } from "../../../../src/voice-broadcast"; +import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; +import { SdkContextClass } from "../../../../src/contexts/SDKContext"; +import Modal from "../../../../src/Modal"; + +jest.mock("../../../../src/components/views/rooms/wysiwyg_composer", () => ({ + SendWysiwygComposer: jest.fn().mockImplementation(() =>
), +})); + +const openStickerPicker = async (): Promise => { + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + await userEvent.click(screen.getByLabelText("Sticker")); + }); +}; + +const startVoiceMessage = async (): Promise => { + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + await userEvent.click(screen.getByLabelText("Voice Message")); + }); +}; + +const setCurrentBroadcastRecording = (room: Room, state: VoiceBroadcastInfoState): void => { + const recording = new VoiceBroadcastRecording( + mkVoiceBroadcastInfoStateEvent(room.roomId, state, "@user:example.com", "ABC123"), + MatrixClientPeg.get(), + state, + ); + SdkContextClass.instance.voiceBroadcastRecordingsStore.setCurrent(recording); +}; + +const waitForModal = async (): Promise => { + await flushPromises(); + await flushPromises(); +}; + +const shouldClearModal = async (): Promise => { + afterEach(async () => { + Modal.closeCurrentModal("force"); + await waitForModal(); + }); +}; + +const expectVoiceMessageRecordingTriggered = (): void => { + // Checking for the voice message dialog text, if no mic can be found. + // By this we know at least that starting a voice message was triggered. + expect(screen.getByText("No microphone found")).toBeInTheDocument(); +}; describe("MessageComposer", () => { stubClient(); const cli = createTestClient(); + filterConsole("Starting load of AsyncWrapper for modal"); + + beforeEach(() => { + mockPlatformPeg(); + }); + + afterEach(() => { + jest.useRealTimers(); + + SdkContextClass.instance.voiceBroadcastRecordingsStore.clearCurrent(); + + // restore settings + act(() => { + [ + "MessageComposerInput.showStickersButton", + "MessageComposerInput.showPollsButton", + Features.VoiceBroadcast, + "feature_wysiwyg_composer", + ].forEach((setting: string): void => { + SettingsStore.setValue(setting, null, SettingLevel.DEVICE, SettingsStore.getDefaultValue(setting)); + }); + }); + }); + describe("for a Room", () => { const room = mkStubRoom("!roomId:server", "Room 1", cli); it("Renders a SendMessageComposer and MessageComposerButtons by default", () => { - const wrapper = wrapAndRender({ room }); - - expect(wrapper.find("SendMessageComposer")).toHaveLength(1); - expect(wrapper.find("MessageComposerButtons")).toHaveLength(1); + wrapAndRender({ room }); + expect(screen.getByLabelText("Send a message…")).toBeInTheDocument(); }); it("Does not render a SendMessageComposer or MessageComposerButtons when user has no permission", () => { - const wrapper = wrapAndRender({ room }, false); - - expect(wrapper.find("SendMessageComposer")).toHaveLength(0); - expect(wrapper.find("MessageComposerButtons")).toHaveLength(0); - expect(wrapper.find(".mx_MessageComposer_noperm_error")).toHaveLength(1); + wrapAndRender({ room }, false); + expect(screen.queryByLabelText("Send a message…")).not.toBeInTheDocument(); + expect(screen.getByText("You do not have permission to post to this room")).toBeInTheDocument(); }); it("Does not render a SendMessageComposer or MessageComposerButtons when room is tombstoned", () => { - const wrapper = wrapAndRender( + wrapAndRender( { room }, true, false, @@ -81,13 +153,12 @@ describe("MessageComposer", () => { }), ); - expect(wrapper.find("SendMessageComposer")).toHaveLength(0); - expect(wrapper.find("MessageComposerButtons")).toHaveLength(0); - expect(wrapper.find(".mx_MessageComposer_roomReplaced_header")).toHaveLength(1); + expect(screen.queryByLabelText("Send a message…")).not.toBeInTheDocument(); + expect(screen.getByText("This room has been replaced and is no longer active.")).toBeInTheDocument(); }); describe("when receiving a »reply_to_event«", () => { - let wrapper: ReactWrapper; + let roomContext: IRoomState; let resizeNotifier: ResizeNotifier; beforeEach(() => { @@ -95,18 +166,17 @@ describe("MessageComposer", () => { resizeNotifier = { notifyTimelineHeightChanged: jest.fn(), } as unknown as ResizeNotifier; - wrapper = wrapAndRender({ + roomContext = wrapAndRender({ room, resizeNotifier, - }); + }).roomContext; }); it("should call notifyTimelineHeightChanged() for the same context", () => { dis.dispatch({ action: "reply_to_event", - context: (wrapper.instance as unknown as MessageComposerClass).context, + context: roomContext.timelineRenderingType, }); - wrapper.update(); jest.advanceTimersByTime(150); expect(resizeNotifier.notifyTimelineHeightChanged).toHaveBeenCalled(); @@ -117,7 +187,6 @@ describe("MessageComposer", () => { action: "reply_to_event", context: "test", }); - wrapper.update(); jest.advanceTimersByTime(150); expect(resizeNotifier.notifyTimelineHeightChanged).not.toHaveBeenCalled(); @@ -128,28 +197,33 @@ describe("MessageComposer", () => { [ { setting: "MessageComposerInput.showStickersButton", - prop: "showStickersButton", + buttonLabel: "Sticker", }, { setting: "MessageComposerInput.showPollsButton", - prop: "showPollsButton", + buttonLabel: "Poll", }, { setting: Features.VoiceBroadcast, - prop: "showVoiceBroadcastButton", + buttonLabel: "Voice broadcast", }, - ].forEach(({ setting, prop }) => { + ].forEach(({ setting, buttonLabel }) => { [true, false].forEach((value: boolean) => { describe(`when ${setting} = ${value}`, () => { - let wrapper: ReactWrapper; - - beforeEach(() => { + beforeEach(async () => { SettingsStore.setValue(setting, null, SettingLevel.DEVICE, value); - wrapper = wrapAndRender({ room }); + wrapAndRender({ room }); + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + }); }); - it(`should pass the prop ${prop} = ${value}`, () => { - expect(wrapper.find(MessageComposerButtons).props()[prop]).toBe(value); + it(`should${value || "not"} display the button`, () => { + if (value) { + expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument(); + } else { + expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument(); + } }); describe(`and setting ${setting} to ${!value}`, () => { @@ -164,11 +238,14 @@ describe("MessageComposer", () => { }, true, ); - wrapper.update(); }); - it(`should pass the prop ${prop} = ${!value}`, () => { - expect(wrapper.find(MessageComposerButtons).props()[prop]).toBe(!value); + it(`should${!value || "not"} display the button`, () => { + if (!value) { + expect(screen.getByLabelText(buttonLabel)).toBeInTheDocument(); + } else { + expect(screen.queryByLabelText(buttonLabel)).not.toBeInTheDocument(); + } }); }); }); @@ -176,26 +253,22 @@ describe("MessageComposer", () => { }); it("should not render the send button", () => { - const wrapper = wrapAndRender({ room }); - expect(wrapper.find("SendButton")).toHaveLength(0); + wrapAndRender({ room }); + expect(screen.queryByLabelText("Send message")).not.toBeInTheDocument(); }); describe("when a message has been entered", () => { - let wrapper: ReactWrapper; - - beforeEach(() => { - wrapper = wrapAndRender({ room }); - addTextToComposerEnzyme(wrapper, "Hello"); - wrapper.update(); + beforeEach(async () => { + const renderResult = wrapAndRender({ room }).renderResult; + await addTextToComposerRTL(renderResult, "Hello"); }); it("should render the send button", () => { - expect(wrapper.find("SendButton")).toHaveLength(1); + expect(screen.getByLabelText("Send message")).toBeInTheDocument(); }); }); describe("UIStore interactions", () => { - let wrapper: ReactWrapper; let resizeCallback: Function; beforeEach(() => { @@ -205,74 +278,74 @@ describe("MessageComposer", () => { }); describe("when a non-resize event occurred in UIStore", () => { - let stateBefore: any; - - beforeEach(() => { - wrapper = wrapAndRender({ room }).children(); - stateBefore = { ...wrapper.instance().state }; + beforeEach(async () => { + wrapAndRender({ room }); + await openStickerPicker(); resizeCallback("test", {}); - wrapper.update(); }); - it("should not change the state", () => { - expect(wrapper.instance().state).toEqual(stateBefore); + it("should still display the sticker picker", () => { + expect(screen.getByText("You don't currently have any stickerpacks enabled")).toBeInTheDocument(); }); }); describe("when a resize to narrow event occurred in UIStore", () => { - beforeEach(() => { - wrapper = wrapAndRender({ room }, true, true).children(); - - wrapper.setState({ - isMenuOpen: true, - isStickerPickerOpen: true, - }); + beforeEach(async () => { + wrapAndRender({ room }, true, true); + await openStickerPicker(); resizeCallback(UI_EVENTS.Resize, {}); - wrapper.update(); }); - it("isMenuOpen should be true", () => { - expect(wrapper.state("isMenuOpen")).toBe(true); + it("should close the menu", () => { + expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument(); }); - it("isStickerPickerOpen should be false", () => { - expect(wrapper.state("isStickerPickerOpen")).toBe(false); + it("should not show the attachment button", () => { + expect(screen.queryByLabelText("Attachment")).not.toBeInTheDocument(); + }); + + it("should close the sticker picker", () => { + expect( + screen.queryByText("You don't currently have any stickerpacks enabled"), + ).not.toBeInTheDocument(); }); }); describe("when a resize to non-narrow event occurred in UIStore", () => { - beforeEach(() => { - wrapper = wrapAndRender({ room }, true, false).children(); - wrapper.setState({ - isMenuOpen: true, - isStickerPickerOpen: true, - }); + beforeEach(async () => { + wrapAndRender({ room }, true, false); + await openStickerPicker(); resizeCallback(UI_EVENTS.Resize, {}); - wrapper.update(); }); - it("isMenuOpen should be false", () => { - expect(wrapper.state("isMenuOpen")).toBe(false); + it("should close the menu", () => { + expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument(); }); - it("isStickerPickerOpen should be false", () => { - expect(wrapper.state("isStickerPickerOpen")).toBe(false); + it("should show the attachment button", () => { + expect(screen.getByLabelText("Attachment")).toBeInTheDocument(); + }); + + it("should close the sticker picker", () => { + expect( + screen.queryByText("You don't currently have any stickerpacks enabled"), + ).not.toBeInTheDocument(); }); }); }); describe("when not replying to an event", () => { it("should pass the expected placeholder to SendMessageComposer", () => { - const wrapper = wrapAndRender({ room }); - expect(wrapper.find(SendMessageComposer).props().placeholder).toBe("Send a message…"); + wrapAndRender({ room }); + expect(screen.getByLabelText("Send a message…")).toBeInTheDocument(); }); it("and an e2e status it should pass the expected placeholder to SendMessageComposer", () => { - const wrapper = wrapAndRender({ + wrapAndRender({ room, e2eStatus: E2EStatus.Normal, }); - expect(wrapper.find(SendMessageComposer).props().placeholder).toBe("Send an encrypted message…"); + expect(screen.getByLabelText("Send an encrypted message…")).toBeInTheDocument(); }); }); @@ -282,8 +355,8 @@ describe("MessageComposer", () => { const checkPlaceholder = (expected: string) => { it("should pass the expected placeholder to SendMessageComposer", () => { - const wrapper = wrapAndRender(props); - expect(wrapper.find(SendMessageComposer).props().placeholder).toBe(expected); + wrapAndRender(props); + expect(screen.getByLabelText(expected)).toBeInTheDocument(); }); }; @@ -296,7 +369,7 @@ describe("MessageComposer", () => { beforeEach(() => { replyToEvent = mkEvent({ event: true, - type: MsgType.Text, + type: EventType.RoomMessage, user: cli.getUserId(), content: {}, }); @@ -337,25 +410,72 @@ describe("MessageComposer", () => { }); }); }); + + describe("when clicking start a voice message", () => { + beforeEach(async () => { + wrapAndRender({ room }); + await startVoiceMessage(); + await flushPromises(); + }); + + shouldClearModal(); + + it("should try to start a voice message", () => { + expectVoiceMessageRecordingTriggered(); + }); + }); + + describe("when recording a voice broadcast and trying to start a voice message", () => { + beforeEach(async () => { + setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Started); + wrapAndRender({ room }); + await startVoiceMessage(); + await waitForModal(); + }); + + shouldClearModal(); + + it("should not start a voice message and display the info dialog", async () => { + expect(screen.queryByLabelText("Stop recording")).not.toBeInTheDocument(); + expect(screen.getByText("Can't start voice message")).toBeInTheDocument(); + }); + }); + + describe("when there is a stopped voice broadcast recording and trying to start a voice message", () => { + beforeEach(async () => { + setCurrentBroadcastRecording(room, VoiceBroadcastInfoState.Stopped); + wrapAndRender({ room }); + await startVoiceMessage(); + await waitForModal(); + }); + + shouldClearModal(); + + it("should try to start a voice message and should not display the info dialog", async () => { + expect(screen.queryByText("Can't start voice message")).not.toBeInTheDocument(); + expectVoiceMessageRecordingTriggered(); + }); + }); }); describe("for a LocalRoom", () => { const localRoom = new LocalRoom("!room:example.com", cli, cli.getUserId()!); - it("should pass the sticker picker disabled prop", () => { - const wrapper = wrapAndRender({ room: localRoom }); - expect(wrapper.find(MessageComposerButtons).props().showStickersButton).toBe(false); + it("should not show the stickers button", async () => { + wrapAndRender({ room: localRoom }); + await act(async () => { + await userEvent.click(screen.getByLabelText("More options")); + }); + expect(screen.queryByLabelText("Sticker")).not.toBeInTheDocument(); }); }); - it("should render SendWysiwygComposer", () => { + it("should render SendWysiwygComposer when enabled", () => { const room = mkStubRoom("!roomId:server", "Room 1", cli); - SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, true); - const wrapper = wrapAndRender({ room }); - SettingsStore.setValue("feature_wysiwyg_composer", null, SettingLevel.DEVICE, false); - expect(wrapper.find(SendWysiwygComposer)).toBeTruthy(); + wrapAndRender({ room }); + expect(screen.getByTestId("wysiwyg-composer")).toBeInTheDocument(); }); }); @@ -364,7 +484,7 @@ function wrapAndRender( canSendMessages = true, narrow = false, tombstone?: MatrixEvent, -): ReactWrapper { +) { const mockClient = MatrixClientPeg.get(); const roomId = "myroomid"; const room: any = props.room || { @@ -376,7 +496,7 @@ function wrapAndRender( }, }; - const roomState = { + const roomContext = { room, canSendMessages, tombstone, @@ -389,11 +509,14 @@ function wrapAndRender( permalinkCreator: new RoomPermalinkCreator(room), }; - return mount( - - - - - , - ); + return { + renderResult: render( + + + + + , + ), + roomContext, + }; } diff --git a/test/test-utils/composer.ts b/test/test-utils/composer.ts index 443c7a934e..7a69be62a8 100644 --- a/test/test-utils/composer.ts +++ b/test/test-utils/composer.ts @@ -17,7 +17,8 @@ limitations under the License. // eslint-disable-next-line deprecate/import import { ReactWrapper } from "enzyme"; import { act } from "react-dom/test-utils"; -import { fireEvent } from "@testing-library/react"; +import { act as actRTL, fireEvent, RenderResult } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; export const addTextToComposer = (container: HTMLElement, text: string) => act(() => { @@ -47,3 +48,10 @@ export const addTextToComposerEnzyme = (wrapper: ReactWrapper, text: string) => wrapper.find('[role="textbox"]').simulate("paste", pasteEvent); wrapper.update(); }); + +export const addTextToComposerRTL = async (renderResult: RenderResult, text: string): Promise => { + await actRTL(async () => { + await userEvent.click(renderResult.getByLabelText("Send a message…")); + await userEvent.keyboard(text); + }); +}; From 34f0229d755b9031a4cdf79c6088e2a4e5b54292 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:20:02 -0700 Subject: [PATCH 07/29] Remove unused/disabled room list drag&drop tests (#9883) It's been years since we've had drag and drop - just delete the test. --- test/components/views/rooms/RoomList-test.tsx | 273 ------------------ 1 file changed, 273 deletions(-) delete mode 100644 test/components/views/rooms/RoomList-test.tsx diff --git a/test/components/views/rooms/RoomList-test.tsx b/test/components/views/rooms/RoomList-test.tsx deleted file mode 100644 index e780f0a9ef..0000000000 --- a/test/components/views/rooms/RoomList-test.tsx +++ /dev/null @@ -1,273 +0,0 @@ -/* -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 ReactTestUtils from "react-dom/test-utils"; -import ReactDOM from "react-dom"; -import { PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix"; - -import * as TestUtils from "../../../test-utils"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import dis from "../../../../src/dispatcher/dispatcher"; -import DMRoomMap from "../../../../src/utils/DMRoomMap"; -import { DefaultTagID } from "../../../../src/stores/room-list/models"; -import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore"; -import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; -import RoomList from "../../../../src/components/views/rooms/RoomList"; -import RoomSublist from "../../../../src/components/views/rooms/RoomSublist"; -import { RoomTile } from "../../../../src/components/views/rooms/RoomTile"; -import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; -import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; - -function generateRoomId() { - return "!" + Math.random().toString().slice(2, 10) + ":domain"; -} - -describe("RoomList", () => { - function createRoom(opts) { - const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), { - // The room list now uses getPendingEvents(), so we need a detached ordering. - pendingEventOrdering: PendingEventOrdering.Detached, - }); - if (opts) { - Object.assign(room, opts); - } - return room; - } - - let parentDiv = null; - let root = null; - const myUserId = "@me:domain"; - - const movingRoomId = "!someroomid"; - let movingRoom: Room | undefined; - let otherRoom: Room | undefined; - - let myMember: RoomMember | undefined; - let myOtherMember: RoomMember | undefined; - - const client = getMockClientWithEventEmitter({ - ...mockClientMethodsUser(myUserId), - getRooms: jest.fn(), - getVisibleRooms: jest.fn(), - getRoom: jest.fn(), - }); - - const defaultProps = { - onKeyDown: jest.fn(), - onFocus: jest.fn(), - onBlur: jest.fn(), - onResize: jest.fn(), - resizeNotifier: {} as unknown as ResizeNotifier, - isMinimized: false, - activeSpace: "", - }; - - beforeEach(async function (done) { - RoomListStoreClass.TEST_MODE = true; - jest.clearAllMocks(); - - client.credentials = { userId: myUserId }; - - DMRoomMap.makeShared(); - - parentDiv = document.createElement("div"); - document.body.appendChild(parentDiv); - - const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList); - root = ReactDOM.render(, parentDiv); - ReactTestUtils.findRenderedComponentWithType(root, RoomList); - - movingRoom = createRoom({ name: "Moving room" }); - expect(movingRoom.roomId).not.toBe(null); - - // Mock joined member - myMember = new RoomMember(movingRoomId, myUserId); - myMember.membership = "join"; - movingRoom.updateMyMembership("join"); - movingRoom.getMember = (userId) => - ({ - [client.credentials.userId]: myMember, - }[userId]); - - otherRoom = createRoom({ name: "Other room" }); - myOtherMember = new RoomMember(otherRoom.roomId, myUserId); - myOtherMember.membership = "join"; - otherRoom.updateMyMembership("join"); - otherRoom.getMember = (userId) => - ({ - [client.credentials.userId]: myOtherMember, - }[userId]); - - // Mock the matrix client - const mockRooms = [ - movingRoom, - otherRoom, - createRoom({ tags: { "m.favourite": { order: 0.1 } }, name: "Some other room" }), - createRoom({ tags: { "m.favourite": { order: 0.2 } }, name: "Some other room 2" }), - createRoom({ tags: { "m.lowpriority": {} }, name: "Some unimportant room" }), - createRoom({ tags: { "custom.tag": {} }, name: "Some room customly tagged" }), - ]; - client.getRooms.mockReturnValue(mockRooms); - client.getVisibleRooms.mockReturnValue(mockRooms); - - const roomMap = {}; - client.getRooms().forEach((r) => { - roomMap[r.roomId] = r; - }); - - client.getRoom.mockImplementation((roomId) => roomMap[roomId]); - - // Now that everything has been set up, prepare and update the store - await (RoomListStore.instance as RoomListStoreClass).makeReady(client); - - done(); - }); - - afterEach(async (done) => { - if (parentDiv) { - ReactDOM.unmountComponentAtNode(parentDiv); - parentDiv.remove(); - parentDiv = null; - } - - await RoomListLayoutStore.instance.resetLayouts(); - await (RoomListStore.instance as RoomListStoreClass).resetStore(); - - done(); - }); - - function expectRoomInSubList(room, subListTest) { - const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSublist); - const containingSubList = subLists.find(subListTest); - - let expectedRoomTile; - try { - const roomTiles = ReactTestUtils.scryRenderedComponentsWithType(containingSubList, RoomTile); - console.info({ roomTiles: roomTiles.length }); - expectedRoomTile = roomTiles.find((tile) => tile.props.room === room); - } catch (err) { - // truncate the error message because it's spammy - err.message = - "Error finding RoomTile for " + - room.roomId + - " in " + - subListTest + - ": " + - err.message.split("componentType")[0] + - "..."; - throw err; - } - - expect(expectedRoomTile).toBeTruthy(); - expect(expectedRoomTile.props.room).toBe(room); - } - - function expectCorrectMove(oldTagId, newTagId) { - const getTagSubListTest = (tagId) => { - return (s) => s.props.tagId === tagId; - }; - - // Default to finding the destination sublist with newTag - const destSubListTest = getTagSubListTest(newTagId); - const srcSubListTest = getTagSubListTest(oldTagId); - - // Set up the room that will be moved such that it has the correct state for a room in - // the section for oldTagId - if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) { - movingRoom.tags = { [oldTagId]: {} }; - } else if (oldTagId === DefaultTagID.DM) { - // Mock inverse m.direct - // @ts-ignore forcing private property - DMRoomMap.shared().roomToUser = { - [movingRoom.roomId]: "@someotheruser:domain", - }; - } - - dis.dispatch({ action: "MatrixActions.sync", prevState: null, state: "PREPARED", matrixClient: client }); - - expectRoomInSubList(movingRoom, srcSubListTest); - - dis.dispatch({ - action: "RoomListActions.tagRoom.pending", - request: { - oldTagId, - newTagId, - room: movingRoom, - }, - }); - - expectRoomInSubList(movingRoom, destSubListTest); - } - - function itDoesCorrectOptimisticUpdatesForDraggedRoomTiles() { - // TODO: Re-enable dragging tests when we support dragging again. - describe.skip("does correct optimistic update when dragging from", () => { - it("rooms to people", () => { - expectCorrectMove(undefined, DefaultTagID.DM); - }); - - it("rooms to favourites", () => { - expectCorrectMove(undefined, "m.favourite"); - }); - - it("rooms to low priority", () => { - expectCorrectMove(undefined, "m.lowpriority"); - }); - - // XXX: Known to fail - the view does not update immediately to reflect the change. - // Whe running the app live, it updates when some other event occurs (likely the - // m.direct arriving) that these tests do not fire. - xit("people to rooms", () => { - expectCorrectMove(DefaultTagID.DM, undefined); - }); - - it("people to favourites", () => { - expectCorrectMove(DefaultTagID.DM, "m.favourite"); - }); - - it("people to lowpriority", () => { - expectCorrectMove(DefaultTagID.DM, "m.lowpriority"); - }); - - it("low priority to rooms", () => { - expectCorrectMove("m.lowpriority", undefined); - }); - - it("low priority to people", () => { - expectCorrectMove("m.lowpriority", DefaultTagID.DM); - }); - - it("low priority to low priority", () => { - expectCorrectMove("m.lowpriority", "m.lowpriority"); - }); - - it("favourites to rooms", () => { - expectCorrectMove("m.favourite", undefined); - }); - - it("favourites to people", () => { - expectCorrectMove("m.favourite", DefaultTagID.DM); - }); - - it("favourites to low priority", () => { - expectCorrectMove("m.favourite", "m.lowpriority"); - }); - }); - } - - itDoesCorrectOptimisticUpdatesForDraggedRoomTiles(); -}); From c09ca7b4eecd57c383e85aa0bc4bf384eefb2c90 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:20:10 -0700 Subject: [PATCH 08/29] Factor out `MessageEvent.from()` usage (#9882) * Factor out `MessageEvent.from()` usage The class/function is disappearing from the events-sdk, at least in this form. * Manually create contents for events used by cypress The utility function is out of range of the calling code at runtime, for some reason. * Run prettier * Maybe this will fix the build --- cypress/e2e/editing/editing.spec.ts | 8 +- cypress/e2e/timeline/timeline.spec.ts | 31 +++---- .../structures/TimelinePanel-test.tsx | 24 +++--- .../context_menus/MessageContextMenu-test.tsx | 83 ++++++++++++------- test/test-utils/events.ts | 42 ++++++++++ 5 files changed, 127 insertions(+), 61 deletions(-) create mode 100644 test/test-utils/events.ts diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 6f16a6a4cd..9051a70c60 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -16,15 +16,17 @@ limitations under the License. /// -import { MessageEvent } from "matrix-events-sdk"; - +import type { MsgType } from "matrix-js-sdk/src/@types/event"; import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { EventType } from "matrix-js-sdk/src/@types/event"; import { SynapseInstance } from "../../plugins/synapsedocker"; import Chainable = Cypress.Chainable; const sendEvent = (roomId: string): Chainable => { - return cy.sendEvent(roomId, null, "m.room.message" as EventType, MessageEvent.from("Message").serialize().content); + return cy.sendEvent(roomId, null, "m.room.message" as EventType, { + msgtype: "m.text" as MsgType, + body: "Message", + }); }; describe("Editing", () => { diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 9eea2a5567..90406eae02 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -16,10 +16,8 @@ limitations under the License. /// -import { MessageEvent } from "matrix-events-sdk"; - import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; -import type { EventType } from "matrix-js-sdk/src/@types/event"; +import type { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { SynapseInstance } from "../../plugins/synapsedocker"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; @@ -55,12 +53,17 @@ const expectAvatar = (e: JQuery, avatarUrl: string): void => { }; const sendEvent = (roomId: string, html = false): Chainable => { - return cy.sendEvent( - roomId, - null, - "m.room.message" as EventType, - MessageEvent.from("Message", html ? "Message" : undefined).serialize().content, - ); + const content = { + msgtype: "m.text" as MsgType, + body: "Message", + format: undefined, + formatted_body: undefined, + }; + if (html) { + content.format = "org.matrix.custom.html"; + content.formatted_body = "Message"; + } + return cy.sendEvent(roomId, null, "m.room.message" as EventType, content); }; describe("Timeline", () => { @@ -314,12 +317,10 @@ describe("Timeline", () => { }, }).as("preview_url"); - cy.sendEvent( - roomId, - null, - "m.room.message" as EventType, - MessageEvent.from("https://call.element.io/").serialize().content, - ); + cy.sendEvent(roomId, null, "m.room.message" as EventType, { + msgtype: "m.text" as MsgType, + body: "https://call.element.io/", + }); cy.visit("/#/room/" + roomId); cy.get(".mx_LinkPreviewWidget").should("exist").should("contain.text", "Element Call"); diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index d7ff659c9d..95847bb8c5 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -17,7 +17,6 @@ limitations under the License. import { render, RenderResult, waitFor, screen } from "@testing-library/react"; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from "enzyme"; -import { MessageEvent } from "matrix-events-sdk"; import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { EventTimelineSet, @@ -48,6 +47,7 @@ import SettingsStore from "../../../src/settings/SettingsStore"; import { isCallEvent } from "../../../src/components/structures/LegacyCallEventGrouper"; import { flushPromises, mkMembership, mkRoom, stubClient } from "../../test-utils"; import { mkThread } from "../../test-utils/threads"; +import { createMessageEventContent } from "../../test-utils/events"; const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => { const receiptContent = { @@ -89,8 +89,8 @@ const mockEvents = (room: Room, count = 2): MatrixEvent[] => { room_id: room.roomId, event_id: `${room.roomId}_event_${index}`, type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`Event${index}`).serialize().content, + sender: "userId", + content: createMessageEventContent("`Event${index}`"), }), ); } @@ -125,13 +125,15 @@ describe("TimelinePanel", () => { event_id: "ev0", sender: "@u2:m.org", origin_server_ts: 111, - ...MessageEvent.from("hello 1").serialize(), + type: EventType.RoomMessage, + content: createMessageEventContent("hello 1"), }); const ev1 = new MatrixEvent({ event_id: "ev1", sender: "@u2:m.org", origin_server_ts: 222, - ...MessageEvent.from("hello 2").serialize(), + type: EventType.RoomMessage, + content: createMessageEventContent("hello 2"), }); const roomId = "#room:example.com"; @@ -385,24 +387,24 @@ describe("TimelinePanel", () => { room_id: room.roomId, event_id: "event_reply_1", type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`ReplyEvent1`).serialize().content, + sender: "userId", + content: createMessageEventContent("ReplyEvent1"), }); reply2 = new MatrixEvent({ room_id: room.roomId, event_id: "event_reply_2", type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`ReplyEvent2`).serialize().content, + sender: "userId", + content: createMessageEventContent("ReplyEvent2"), }); root = new MatrixEvent({ room_id: room.roomId, event_id: "event_root_1", type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`RootEvent`).serialize().content, + sender: "userId", + content: createMessageEventContent("RootEvent"), }); const eventMap: { [key: string]: MatrixEvent } = { diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index 3fdf26832d..a0ad320c5b 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -26,7 +26,7 @@ import { getBeaconInfoIdentifier, EventType, } from "matrix-js-sdk/src/matrix"; -import { ExtensibleEvent, MessageEvent, M_POLL_KIND_DISCLOSED, PollStartEvent } from "matrix-events-sdk"; +import { M_POLL_KIND_DISCLOSED, PollStartEvent } from "matrix-events-sdk"; import { FeatureSupport, Thread } from "matrix-js-sdk/src/models/thread"; import { mocked } from "jest-mock"; import { act } from "@testing-library/react"; @@ -44,6 +44,7 @@ import { ReadPinsEventId } from "../../../../src/components/views/right_panel/ty import { Action } from "../../../../src/dispatcher/actions"; import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast"; +import { createMessageEventContent } from "../../../test-utils/events"; jest.mock("../../../../src/utils/strings", () => ({ copyPlaintext: jest.fn(), @@ -64,7 +65,7 @@ describe("MessageContextMenu", () => { }); it("does show copy link button when supplied a link", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const props = { link: "https://google.com/", }; @@ -75,7 +76,7 @@ describe("MessageContextMenu", () => { }); it("does not show copy link button when not supplied a link", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createMenuWithContent(eventContent); const copyLinkButton = menu.find('a[aria-label="Copy link"]'); expect(copyLinkButton).toHaveLength(0); @@ -91,8 +92,8 @@ describe("MessageContextMenu", () => { }); it("does not show pin option when user does not have rights to pin", () => { - const eventContent = MessageEvent.from("hello"); - const event = new MatrixEvent(eventContent.serialize()); + const eventContent = createMessageEventContent("hello"); + const event = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); const room = makeDefaultRoom(); // mock permission to disallow adding pinned messages to room @@ -116,8 +117,12 @@ describe("MessageContextMenu", () => { }); it("does not show pin option when pinning feature is disabled", () => { - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); const room = makeDefaultRoom(); // mock permission to allow adding pinned messages to room @@ -131,8 +136,12 @@ describe("MessageContextMenu", () => { }); it("shows pin option when pinning feature is enabled", () => { - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); const room = makeDefaultRoom(); // mock permission to allow adding pinned messages to room @@ -145,8 +154,12 @@ describe("MessageContextMenu", () => { it("pins event on pin option click", () => { const onFinished = jest.fn(); - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.get(); const room = makeDefaultRoom(); @@ -188,8 +201,12 @@ describe("MessageContextMenu", () => { }); it("unpins event on pin option click when event is pinned", () => { - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.get(); const room = makeDefaultRoom(); @@ -231,7 +248,7 @@ describe("MessageContextMenu", () => { describe("message forwarding", () => { it("allows forwarding a room message", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createMenuWithContent(eventContent); expect(menu.find('div[aria-label="Forward"]')).toHaveLength(1); }); @@ -335,7 +352,7 @@ describe("MessageContextMenu", () => { describe("open as map link", () => { it("does not allow opening a plain message in open street maps", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createMenuWithContent(eventContent); expect(menu.find('a[aria-label="Open in OpenStreetMap"]')).toHaveLength(0); }); @@ -380,7 +397,7 @@ describe("MessageContextMenu", () => { describe("right click", () => { it("copy button does work as expected", () => { const text = "hello"; - const eventContent = MessageEvent.from(text); + const eventContent = createMessageEventContent(text); mocked(getSelectedText).mockReturnValue(text); const menu = createRightClickMenuWithContent(eventContent); @@ -391,7 +408,7 @@ describe("MessageContextMenu", () => { it("copy button is not shown when there is nothing to copy", () => { const text = "hello"; - const eventContent = MessageEvent.from(text); + const eventContent = createMessageEventContent(text); mocked(getSelectedText).mockReturnValue(""); const menu = createRightClickMenuWithContent(eventContent); @@ -400,7 +417,7 @@ describe("MessageContextMenu", () => { }); it("shows edit button when we can edit", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); mocked(canEditContent).mockReturnValue(true); const menu = createRightClickMenuWithContent(eventContent); @@ -409,7 +426,7 @@ describe("MessageContextMenu", () => { }); it("does not show edit button when we cannot edit", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); mocked(canEditContent).mockReturnValue(false); const menu = createRightClickMenuWithContent(eventContent); @@ -418,7 +435,7 @@ describe("MessageContextMenu", () => { }); it("shows reply button when we can reply", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canSendMessages: true, }; @@ -429,11 +446,11 @@ describe("MessageContextMenu", () => { }); it("does not show reply button when we cannot reply", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canSendMessages: true, }; - const unsentMessage = new MatrixEvent(eventContent.serialize()); + const unsentMessage = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); // queued messages are not actionable unsentMessage.setStatus(EventStatus.QUEUED); @@ -443,7 +460,7 @@ describe("MessageContextMenu", () => { }); it("shows react button when we can react", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canReact: true, }; @@ -454,7 +471,7 @@ describe("MessageContextMenu", () => { }); it("does not show react button when we cannot react", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canReact: false, }; @@ -465,8 +482,8 @@ describe("MessageContextMenu", () => { }); it("shows view in room button when the event is a thread root", () => { - const eventContent = MessageEvent.from("hello"); - const mxEvent = new MatrixEvent(eventContent.serialize()); + const eventContent = createMessageEventContent("hello"); + const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); mxEvent.getThread = () => ({ rootEvent: mxEvent } as Thread); const props = { rightClick: true, @@ -481,7 +498,7 @@ describe("MessageContextMenu", () => { }); it("does not show view in room button when the event is not a thread root", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createRightClickMenuWithContent(eventContent); const reactButton = menu.find('div[aria-label="View in room"]'); @@ -489,8 +506,8 @@ describe("MessageContextMenu", () => { }); it("creates a new thread on reply in thread click", () => { - const eventContent = MessageEvent.from("hello"); - const mxEvent = new MatrixEvent(eventContent.serialize()); + const eventContent = createMessageEventContent("hello"); + const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); Thread.hasServerSideSupport = FeatureSupport.Stable; const context = { @@ -513,7 +530,7 @@ describe("MessageContextMenu", () => { }); }); -function createRightClickMenuWithContent(eventContent: ExtensibleEvent, context?: Partial): ReactWrapper { +function createRightClickMenuWithContent(eventContent: object, context?: Partial): ReactWrapper { return createMenuWithContent(eventContent, { rightClick: true }, context); } @@ -522,11 +539,13 @@ function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial>, context?: Partial, ): ReactWrapper { - const mxEvent = new MatrixEvent(eventContent.serialize()); + // XXX: We probably shouldn't be assuming all events are going to be message events, but considering this + // test is for the Message context menu, it's a fairly safe assumption. + const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); return createMenu(mxEvent, props, context); } diff --git a/test/test-utils/events.ts b/test/test-utils/events.ts new file mode 100644 index 0000000000..28189ffd8d --- /dev/null +++ b/test/test-utils/events.ts @@ -0,0 +1,42 @@ +/* +Copyright 2023 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 { MsgType } from "matrix-js-sdk/src/@types/event"; + +interface MessageContent { + msgtype: MsgType; + body: string; + format?: string; + formatted_body?: string; +} + +/** + * Creates the `content` for an `m.room.message` event based on input. + * @param text The text to put in the event. + * @param html Optional HTML to put in the event. + * @returns A complete `content` object for an `m.room.message` event. + */ +export function createMessageEventContent(text: string, html?: string): MessageContent { + const content: MessageContent = { + msgtype: MsgType.Text, + body: text, + }; + if (html) { + content.format = "org.matrix.custom.html"; + content.formatted_body = html; + } + return content; +} From 67396020e8238ba29b69254e67d1482f116289b8 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:21:03 -0700 Subject: [PATCH 09/29] Remove extensible events v1 experimental rendering (#9881) * Remove extensible events v1 experimental rendering With the changes to extensible events in v2 largely being contained to a room version, we don't need to have this code or the labs flag anymore. If the labs flag becomes useful in the future, it will be re-added. * Run prettier --- src/TextForEvent.tsx | 21 +--------------- src/components/views/messages/TextualBody.tsx | 24 ------------------- src/i18n/strings/en_EN.json | 1 - src/settings/Settings.tsx | 7 ------ 4 files changed, 1 insertion(+), 52 deletions(-) diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 01b7fe4eaf..8be6cd4a40 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -20,15 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { removeDirectionOverrideChars } from "matrix-js-sdk/src/utils"; import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; -import { - M_EMOTE, - M_NOTICE, - M_MESSAGE, - MessageEvent, - M_POLL_START, - M_POLL_END, - PollStartEvent, -} from "matrix-events-sdk"; +import { M_POLL_START, M_POLL_END, PollStartEvent } from "matrix-events-sdk"; import { _t } from "./languageHandler"; import * as Roles from "./Roles"; @@ -347,17 +339,6 @@ function textForMessageEvent(ev: MatrixEvent): () => string | null { message = textForRedactedPollAndMessageEvent(ev); } - if (SettingsStore.isEnabled("feature_extensible_events")) { - const extev = ev.unstableExtensibleEvent as MessageEvent; - if (extev) { - if (extev.isEquivalentTo(M_EMOTE)) { - return `* ${senderDisplayName} ${extev.text}`; - } else if (extev.isEquivalentTo(M_NOTICE) || extev.isEquivalentTo(M_MESSAGE)) { - return `${senderDisplayName}: ${extev.text}`; - } - } - } - if (ev.getContent().msgtype === MsgType.Emote) { message = "* " + senderDisplayName + " " + message; } else if (ev.getContent().msgtype === MsgType.Image) { diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 4d4818c860..d1824308f4 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -18,7 +18,6 @@ import React, { createRef, SyntheticEvent, MouseEvent, ReactNode } from "react"; import ReactDOM from "react-dom"; import highlight from "highlight.js"; import { MsgType } from "matrix-js-sdk/src/@types/event"; -import { isEventLike, LegacyMsgType, M_MESSAGE, MessageEvent } from "matrix-events-sdk"; import * as HtmlUtils from "../../../HtmlUtils"; import { formatDate } from "../../../DateUtils"; @@ -579,29 +578,6 @@ export default class TextualBody extends React.Component { // only strip reply if this is the original replying event, edits thereafter do not have the fallback const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); let body: ReactNode; - if (SettingsStore.isEnabled("feature_extensible_events")) { - const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent; - if (extev?.isEquivalentTo(M_MESSAGE)) { - isEmote = isEventLike(extev.wireFormat, LegacyMsgType.Emote); - isNotice = isEventLike(extev.wireFormat, LegacyMsgType.Notice); - body = HtmlUtils.bodyToHtml( - { - body: extev.text, - format: extev.html ? "org.matrix.custom.html" : undefined, - formatted_body: extev.html, - msgtype: MsgType.Text, - }, - this.props.highlights, - { - disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"), - // Part of Replies fallback support - stripReplyFallback: stripReply, - ref: this.contentRef, - returnString: false, - }, - ); - } - } if (!body) { isEmote = content.msgtype === MsgType.Emote; isNotice = content.msgtype === MsgType.Notice; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d4a8b522b8..16acf9b8dc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -938,7 +938,6 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", - "Show extensible event representation of events": "Show extensible event representation of events", "Show current avatar and name for users in message history": "Show current avatar and name for users in message history", "Show HTML representation of room topics": "Show HTML representation of room topics", "Show info about bridges in room settings": "Show info about bridges in room settings", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 03ae5bd1db..3fd38f0eab 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -340,13 +340,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_extensible_events": { - isFeature: true, - labsGroup: LabGroup.Developer, // developer for now, eventually Messaging and default on - supportedLevels: LEVELS_FEATURE, - displayName: _td("Show extensible event representation of events"), - default: false, - }, "useOnlyCurrentProfiles": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Show current avatar and name for users in message history"), From b4f2db22dfaaf69989d47ad198aab959189908b5 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:21:03 -0700 Subject: [PATCH 10/29] Revert "Remove extensible events v1 experimental rendering (#9881)" This reverts commit 67396020e8238ba29b69254e67d1482f116289b8. --- src/TextForEvent.tsx | 21 +++++++++++++++- src/components/views/messages/TextualBody.tsx | 24 +++++++++++++++++++ src/i18n/strings/en_EN.json | 1 + src/settings/Settings.tsx | 7 ++++++ 4 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 8be6cd4a40..01b7fe4eaf 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -20,7 +20,15 @@ import { logger } from "matrix-js-sdk/src/logger"; import { removeDirectionOverrideChars } from "matrix-js-sdk/src/utils"; import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; -import { M_POLL_START, M_POLL_END, PollStartEvent } from "matrix-events-sdk"; +import { + M_EMOTE, + M_NOTICE, + M_MESSAGE, + MessageEvent, + M_POLL_START, + M_POLL_END, + PollStartEvent, +} from "matrix-events-sdk"; import { _t } from "./languageHandler"; import * as Roles from "./Roles"; @@ -339,6 +347,17 @@ function textForMessageEvent(ev: MatrixEvent): () => string | null { message = textForRedactedPollAndMessageEvent(ev); } + if (SettingsStore.isEnabled("feature_extensible_events")) { + const extev = ev.unstableExtensibleEvent as MessageEvent; + if (extev) { + if (extev.isEquivalentTo(M_EMOTE)) { + return `* ${senderDisplayName} ${extev.text}`; + } else if (extev.isEquivalentTo(M_NOTICE) || extev.isEquivalentTo(M_MESSAGE)) { + return `${senderDisplayName}: ${extev.text}`; + } + } + } + if (ev.getContent().msgtype === MsgType.Emote) { message = "* " + senderDisplayName + " " + message; } else if (ev.getContent().msgtype === MsgType.Image) { diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index d1824308f4..4d4818c860 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -18,6 +18,7 @@ import React, { createRef, SyntheticEvent, MouseEvent, ReactNode } from "react"; import ReactDOM from "react-dom"; import highlight from "highlight.js"; import { MsgType } from "matrix-js-sdk/src/@types/event"; +import { isEventLike, LegacyMsgType, M_MESSAGE, MessageEvent } from "matrix-events-sdk"; import * as HtmlUtils from "../../../HtmlUtils"; import { formatDate } from "../../../DateUtils"; @@ -578,6 +579,29 @@ export default class TextualBody extends React.Component { // only strip reply if this is the original replying event, edits thereafter do not have the fallback const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); let body: ReactNode; + if (SettingsStore.isEnabled("feature_extensible_events")) { + const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent; + if (extev?.isEquivalentTo(M_MESSAGE)) { + isEmote = isEventLike(extev.wireFormat, LegacyMsgType.Emote); + isNotice = isEventLike(extev.wireFormat, LegacyMsgType.Notice); + body = HtmlUtils.bodyToHtml( + { + body: extev.text, + format: extev.html ? "org.matrix.custom.html" : undefined, + formatted_body: extev.html, + msgtype: MsgType.Text, + }, + this.props.highlights, + { + disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"), + // Part of Replies fallback support + stripReplyFallback: stripReply, + ref: this.contentRef, + returnString: false, + }, + ); + } + } if (!body) { isEmote = content.msgtype === MsgType.Emote; isNotice = content.msgtype === MsgType.Notice; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 16acf9b8dc..d4a8b522b8 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -938,6 +938,7 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", + "Show extensible event representation of events": "Show extensible event representation of events", "Show current avatar and name for users in message history": "Show current avatar and name for users in message history", "Show HTML representation of room topics": "Show HTML representation of room topics", "Show info about bridges in room settings": "Show info about bridges in room settings", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 3fd38f0eab..03ae5bd1db 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -340,6 +340,13 @@ export const SETTINGS: { [setting: string]: ISetting } = { supportedLevels: LEVELS_FEATURE, default: false, }, + "feature_extensible_events": { + isFeature: true, + labsGroup: LabGroup.Developer, // developer for now, eventually Messaging and default on + supportedLevels: LEVELS_FEATURE, + displayName: _td("Show extensible event representation of events"), + default: false, + }, "useOnlyCurrentProfiles": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Show current avatar and name for users in message history"), From 99e38ca88e084f06c45e16fce5b1994e01a5ae11 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:20:10 -0700 Subject: [PATCH 11/29] Revert "Factor out `MessageEvent.from()` usage (#9882)" This reverts commit c09ca7b4eecd57c383e85aa0bc4bf384eefb2c90. --- cypress/e2e/editing/editing.spec.ts | 8 +- cypress/e2e/timeline/timeline.spec.ts | 31 ++++--- .../structures/TimelinePanel-test.tsx | 24 +++--- .../context_menus/MessageContextMenu-test.tsx | 83 +++++++------------ test/test-utils/events.ts | 42 ---------- 5 files changed, 61 insertions(+), 127 deletions(-) delete mode 100644 test/test-utils/events.ts diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 9051a70c60..6f16a6a4cd 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -16,17 +16,15 @@ limitations under the License. /// -import type { MsgType } from "matrix-js-sdk/src/@types/event"; +import { MessageEvent } from "matrix-events-sdk"; + import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { EventType } from "matrix-js-sdk/src/@types/event"; import { SynapseInstance } from "../../plugins/synapsedocker"; import Chainable = Cypress.Chainable; const sendEvent = (roomId: string): Chainable => { - return cy.sendEvent(roomId, null, "m.room.message" as EventType, { - msgtype: "m.text" as MsgType, - body: "Message", - }); + return cy.sendEvent(roomId, null, "m.room.message" as EventType, MessageEvent.from("Message").serialize().content); }; describe("Editing", () => { diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 90406eae02..9eea2a5567 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -16,8 +16,10 @@ limitations under the License. /// +import { MessageEvent } from "matrix-events-sdk"; + import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; -import type { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; +import type { EventType } from "matrix-js-sdk/src/@types/event"; import { SynapseInstance } from "../../plugins/synapsedocker"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; @@ -53,17 +55,12 @@ const expectAvatar = (e: JQuery, avatarUrl: string): void => { }; const sendEvent = (roomId: string, html = false): Chainable => { - const content = { - msgtype: "m.text" as MsgType, - body: "Message", - format: undefined, - formatted_body: undefined, - }; - if (html) { - content.format = "org.matrix.custom.html"; - content.formatted_body = "Message"; - } - return cy.sendEvent(roomId, null, "m.room.message" as EventType, content); + return cy.sendEvent( + roomId, + null, + "m.room.message" as EventType, + MessageEvent.from("Message", html ? "Message" : undefined).serialize().content, + ); }; describe("Timeline", () => { @@ -317,10 +314,12 @@ describe("Timeline", () => { }, }).as("preview_url"); - cy.sendEvent(roomId, null, "m.room.message" as EventType, { - msgtype: "m.text" as MsgType, - body: "https://call.element.io/", - }); + cy.sendEvent( + roomId, + null, + "m.room.message" as EventType, + MessageEvent.from("https://call.element.io/").serialize().content, + ); cy.visit("/#/room/" + roomId); cy.get(".mx_LinkPreviewWidget").should("exist").should("contain.text", "Element Call"); diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index 95847bb8c5..d7ff659c9d 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -17,6 +17,7 @@ limitations under the License. import { render, RenderResult, waitFor, screen } from "@testing-library/react"; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from "enzyme"; +import { MessageEvent } from "matrix-events-sdk"; import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { EventTimelineSet, @@ -47,7 +48,6 @@ import SettingsStore from "../../../src/settings/SettingsStore"; import { isCallEvent } from "../../../src/components/structures/LegacyCallEventGrouper"; import { flushPromises, mkMembership, mkRoom, stubClient } from "../../test-utils"; import { mkThread } from "../../test-utils/threads"; -import { createMessageEventContent } from "../../test-utils/events"; const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => { const receiptContent = { @@ -89,8 +89,8 @@ const mockEvents = (room: Room, count = 2): MatrixEvent[] => { room_id: room.roomId, event_id: `${room.roomId}_event_${index}`, type: EventType.RoomMessage, - sender: "userId", - content: createMessageEventContent("`Event${index}`"), + user_id: "userId", + content: MessageEvent.from(`Event${index}`).serialize().content, }), ); } @@ -125,15 +125,13 @@ describe("TimelinePanel", () => { event_id: "ev0", sender: "@u2:m.org", origin_server_ts: 111, - type: EventType.RoomMessage, - content: createMessageEventContent("hello 1"), + ...MessageEvent.from("hello 1").serialize(), }); const ev1 = new MatrixEvent({ event_id: "ev1", sender: "@u2:m.org", origin_server_ts: 222, - type: EventType.RoomMessage, - content: createMessageEventContent("hello 2"), + ...MessageEvent.from("hello 2").serialize(), }); const roomId = "#room:example.com"; @@ -387,24 +385,24 @@ describe("TimelinePanel", () => { room_id: room.roomId, event_id: "event_reply_1", type: EventType.RoomMessage, - sender: "userId", - content: createMessageEventContent("ReplyEvent1"), + user_id: "userId", + content: MessageEvent.from(`ReplyEvent1`).serialize().content, }); reply2 = new MatrixEvent({ room_id: room.roomId, event_id: "event_reply_2", type: EventType.RoomMessage, - sender: "userId", - content: createMessageEventContent("ReplyEvent2"), + user_id: "userId", + content: MessageEvent.from(`ReplyEvent2`).serialize().content, }); root = new MatrixEvent({ room_id: room.roomId, event_id: "event_root_1", type: EventType.RoomMessage, - sender: "userId", - content: createMessageEventContent("RootEvent"), + user_id: "userId", + content: MessageEvent.from(`RootEvent`).serialize().content, }); const eventMap: { [key: string]: MatrixEvent } = { diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index a0ad320c5b..3fdf26832d 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -26,7 +26,7 @@ import { getBeaconInfoIdentifier, EventType, } from "matrix-js-sdk/src/matrix"; -import { M_POLL_KIND_DISCLOSED, PollStartEvent } from "matrix-events-sdk"; +import { ExtensibleEvent, MessageEvent, M_POLL_KIND_DISCLOSED, PollStartEvent } from "matrix-events-sdk"; import { FeatureSupport, Thread } from "matrix-js-sdk/src/models/thread"; import { mocked } from "jest-mock"; import { act } from "@testing-library/react"; @@ -44,7 +44,6 @@ import { ReadPinsEventId } from "../../../../src/components/views/right_panel/ty import { Action } from "../../../../src/dispatcher/actions"; import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast"; -import { createMessageEventContent } from "../../../test-utils/events"; jest.mock("../../../../src/utils/strings", () => ({ copyPlaintext: jest.fn(), @@ -65,7 +64,7 @@ describe("MessageContextMenu", () => { }); it("does show copy link button when supplied a link", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const props = { link: "https://google.com/", }; @@ -76,7 +75,7 @@ describe("MessageContextMenu", () => { }); it("does not show copy link button when not supplied a link", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const menu = createMenuWithContent(eventContent); const copyLinkButton = menu.find('a[aria-label="Copy link"]'); expect(copyLinkButton).toHaveLength(0); @@ -92,8 +91,8 @@ describe("MessageContextMenu", () => { }); it("does not show pin option when user does not have rights to pin", () => { - const eventContent = createMessageEventContent("hello"); - const event = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); + const eventContent = MessageEvent.from("hello"); + const event = new MatrixEvent(eventContent.serialize()); const room = makeDefaultRoom(); // mock permission to disallow adding pinned messages to room @@ -117,12 +116,8 @@ describe("MessageContextMenu", () => { }); it("does not show pin option when pinning feature is disabled", () => { - const eventContent = createMessageEventContent("hello"); - const pinnableEvent = new MatrixEvent({ - type: EventType.RoomMessage, - content: eventContent, - room_id: roomId, - }); + const eventContent = MessageEvent.from("hello"); + const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); const room = makeDefaultRoom(); // mock permission to allow adding pinned messages to room @@ -136,12 +131,8 @@ describe("MessageContextMenu", () => { }); it("shows pin option when pinning feature is enabled", () => { - const eventContent = createMessageEventContent("hello"); - const pinnableEvent = new MatrixEvent({ - type: EventType.RoomMessage, - content: eventContent, - room_id: roomId, - }); + const eventContent = MessageEvent.from("hello"); + const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); const room = makeDefaultRoom(); // mock permission to allow adding pinned messages to room @@ -154,12 +145,8 @@ describe("MessageContextMenu", () => { it("pins event on pin option click", () => { const onFinished = jest.fn(); - const eventContent = createMessageEventContent("hello"); - const pinnableEvent = new MatrixEvent({ - type: EventType.RoomMessage, - content: eventContent, - room_id: roomId, - }); + const eventContent = MessageEvent.from("hello"); + const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.get(); const room = makeDefaultRoom(); @@ -201,12 +188,8 @@ describe("MessageContextMenu", () => { }); it("unpins event on pin option click when event is pinned", () => { - const eventContent = createMessageEventContent("hello"); - const pinnableEvent = new MatrixEvent({ - type: EventType.RoomMessage, - content: eventContent, - room_id: roomId, - }); + const eventContent = MessageEvent.from("hello"); + const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.get(); const room = makeDefaultRoom(); @@ -248,7 +231,7 @@ describe("MessageContextMenu", () => { describe("message forwarding", () => { it("allows forwarding a room message", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const menu = createMenuWithContent(eventContent); expect(menu.find('div[aria-label="Forward"]')).toHaveLength(1); }); @@ -352,7 +335,7 @@ describe("MessageContextMenu", () => { describe("open as map link", () => { it("does not allow opening a plain message in open street maps", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const menu = createMenuWithContent(eventContent); expect(menu.find('a[aria-label="Open in OpenStreetMap"]')).toHaveLength(0); }); @@ -397,7 +380,7 @@ describe("MessageContextMenu", () => { describe("right click", () => { it("copy button does work as expected", () => { const text = "hello"; - const eventContent = createMessageEventContent(text); + const eventContent = MessageEvent.from(text); mocked(getSelectedText).mockReturnValue(text); const menu = createRightClickMenuWithContent(eventContent); @@ -408,7 +391,7 @@ describe("MessageContextMenu", () => { it("copy button is not shown when there is nothing to copy", () => { const text = "hello"; - const eventContent = createMessageEventContent(text); + const eventContent = MessageEvent.from(text); mocked(getSelectedText).mockReturnValue(""); const menu = createRightClickMenuWithContent(eventContent); @@ -417,7 +400,7 @@ describe("MessageContextMenu", () => { }); it("shows edit button when we can edit", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); mocked(canEditContent).mockReturnValue(true); const menu = createRightClickMenuWithContent(eventContent); @@ -426,7 +409,7 @@ describe("MessageContextMenu", () => { }); it("does not show edit button when we cannot edit", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); mocked(canEditContent).mockReturnValue(false); const menu = createRightClickMenuWithContent(eventContent); @@ -435,7 +418,7 @@ describe("MessageContextMenu", () => { }); it("shows reply button when we can reply", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const context = { canSendMessages: true, }; @@ -446,11 +429,11 @@ describe("MessageContextMenu", () => { }); it("does not show reply button when we cannot reply", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const context = { canSendMessages: true, }; - const unsentMessage = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); + const unsentMessage = new MatrixEvent(eventContent.serialize()); // queued messages are not actionable unsentMessage.setStatus(EventStatus.QUEUED); @@ -460,7 +443,7 @@ describe("MessageContextMenu", () => { }); it("shows react button when we can react", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const context = { canReact: true, }; @@ -471,7 +454,7 @@ describe("MessageContextMenu", () => { }); it("does not show react button when we cannot react", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const context = { canReact: false, }; @@ -482,8 +465,8 @@ describe("MessageContextMenu", () => { }); it("shows view in room button when the event is a thread root", () => { - const eventContent = createMessageEventContent("hello"); - const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); + const eventContent = MessageEvent.from("hello"); + const mxEvent = new MatrixEvent(eventContent.serialize()); mxEvent.getThread = () => ({ rootEvent: mxEvent } as Thread); const props = { rightClick: true, @@ -498,7 +481,7 @@ describe("MessageContextMenu", () => { }); it("does not show view in room button when the event is not a thread root", () => { - const eventContent = createMessageEventContent("hello"); + const eventContent = MessageEvent.from("hello"); const menu = createRightClickMenuWithContent(eventContent); const reactButton = menu.find('div[aria-label="View in room"]'); @@ -506,8 +489,8 @@ describe("MessageContextMenu", () => { }); it("creates a new thread on reply in thread click", () => { - const eventContent = createMessageEventContent("hello"); - const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); + const eventContent = MessageEvent.from("hello"); + const mxEvent = new MatrixEvent(eventContent.serialize()); Thread.hasServerSideSupport = FeatureSupport.Stable; const context = { @@ -530,7 +513,7 @@ describe("MessageContextMenu", () => { }); }); -function createRightClickMenuWithContent(eventContent: object, context?: Partial): ReactWrapper { +function createRightClickMenuWithContent(eventContent: ExtensibleEvent, context?: Partial): ReactWrapper { return createMenuWithContent(eventContent, { rightClick: true }, context); } @@ -539,13 +522,11 @@ function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial>, context?: Partial, ): ReactWrapper { - // XXX: We probably shouldn't be assuming all events are going to be message events, but considering this - // test is for the Message context menu, it's a fairly safe assumption. - const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); + const mxEvent = new MatrixEvent(eventContent.serialize()); return createMenu(mxEvent, props, context); } diff --git a/test/test-utils/events.ts b/test/test-utils/events.ts deleted file mode 100644 index 28189ffd8d..0000000000 --- a/test/test-utils/events.ts +++ /dev/null @@ -1,42 +0,0 @@ -/* -Copyright 2023 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 { MsgType } from "matrix-js-sdk/src/@types/event"; - -interface MessageContent { - msgtype: MsgType; - body: string; - format?: string; - formatted_body?: string; -} - -/** - * Creates the `content` for an `m.room.message` event based on input. - * @param text The text to put in the event. - * @param html Optional HTML to put in the event. - * @returns A complete `content` object for an `m.room.message` event. - */ -export function createMessageEventContent(text: string, html?: string): MessageContent { - const content: MessageContent = { - msgtype: MsgType.Text, - body: text, - }; - if (html) { - content.format = "org.matrix.custom.html"; - content.formatted_body = html; - } - return content; -} From 0453283bede917a220e9b04cffd415ec61e7bdea Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:20:02 -0700 Subject: [PATCH 12/29] Revert "Remove unused/disabled room list drag&drop tests (#9883)" This reverts commit 34f0229d755b9031a4cdf79c6088e2a4e5b54292. --- test/components/views/rooms/RoomList-test.tsx | 273 ++++++++++++++++++ 1 file changed, 273 insertions(+) create mode 100644 test/components/views/rooms/RoomList-test.tsx diff --git a/test/components/views/rooms/RoomList-test.tsx b/test/components/views/rooms/RoomList-test.tsx new file mode 100644 index 0000000000..e780f0a9ef --- /dev/null +++ b/test/components/views/rooms/RoomList-test.tsx @@ -0,0 +1,273 @@ +/* +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 ReactTestUtils from "react-dom/test-utils"; +import ReactDOM from "react-dom"; +import { PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix"; + +import * as TestUtils from "../../../test-utils"; +import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; +import dis from "../../../../src/dispatcher/dispatcher"; +import DMRoomMap from "../../../../src/utils/DMRoomMap"; +import { DefaultTagID } from "../../../../src/stores/room-list/models"; +import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore"; +import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; +import RoomList from "../../../../src/components/views/rooms/RoomList"; +import RoomSublist from "../../../../src/components/views/rooms/RoomSublist"; +import { RoomTile } from "../../../../src/components/views/rooms/RoomTile"; +import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; +import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; + +function generateRoomId() { + return "!" + Math.random().toString().slice(2, 10) + ":domain"; +} + +describe("RoomList", () => { + function createRoom(opts) { + const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), { + // The room list now uses getPendingEvents(), so we need a detached ordering. + pendingEventOrdering: PendingEventOrdering.Detached, + }); + if (opts) { + Object.assign(room, opts); + } + return room; + } + + let parentDiv = null; + let root = null; + const myUserId = "@me:domain"; + + const movingRoomId = "!someroomid"; + let movingRoom: Room | undefined; + let otherRoom: Room | undefined; + + let myMember: RoomMember | undefined; + let myOtherMember: RoomMember | undefined; + + const client = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(myUserId), + getRooms: jest.fn(), + getVisibleRooms: jest.fn(), + getRoom: jest.fn(), + }); + + const defaultProps = { + onKeyDown: jest.fn(), + onFocus: jest.fn(), + onBlur: jest.fn(), + onResize: jest.fn(), + resizeNotifier: {} as unknown as ResizeNotifier, + isMinimized: false, + activeSpace: "", + }; + + beforeEach(async function (done) { + RoomListStoreClass.TEST_MODE = true; + jest.clearAllMocks(); + + client.credentials = { userId: myUserId }; + + DMRoomMap.makeShared(); + + parentDiv = document.createElement("div"); + document.body.appendChild(parentDiv); + + const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList); + root = ReactDOM.render(, parentDiv); + ReactTestUtils.findRenderedComponentWithType(root, RoomList); + + movingRoom = createRoom({ name: "Moving room" }); + expect(movingRoom.roomId).not.toBe(null); + + // Mock joined member + myMember = new RoomMember(movingRoomId, myUserId); + myMember.membership = "join"; + movingRoom.updateMyMembership("join"); + movingRoom.getMember = (userId) => + ({ + [client.credentials.userId]: myMember, + }[userId]); + + otherRoom = createRoom({ name: "Other room" }); + myOtherMember = new RoomMember(otherRoom.roomId, myUserId); + myOtherMember.membership = "join"; + otherRoom.updateMyMembership("join"); + otherRoom.getMember = (userId) => + ({ + [client.credentials.userId]: myOtherMember, + }[userId]); + + // Mock the matrix client + const mockRooms = [ + movingRoom, + otherRoom, + createRoom({ tags: { "m.favourite": { order: 0.1 } }, name: "Some other room" }), + createRoom({ tags: { "m.favourite": { order: 0.2 } }, name: "Some other room 2" }), + createRoom({ tags: { "m.lowpriority": {} }, name: "Some unimportant room" }), + createRoom({ tags: { "custom.tag": {} }, name: "Some room customly tagged" }), + ]; + client.getRooms.mockReturnValue(mockRooms); + client.getVisibleRooms.mockReturnValue(mockRooms); + + const roomMap = {}; + client.getRooms().forEach((r) => { + roomMap[r.roomId] = r; + }); + + client.getRoom.mockImplementation((roomId) => roomMap[roomId]); + + // Now that everything has been set up, prepare and update the store + await (RoomListStore.instance as RoomListStoreClass).makeReady(client); + + done(); + }); + + afterEach(async (done) => { + if (parentDiv) { + ReactDOM.unmountComponentAtNode(parentDiv); + parentDiv.remove(); + parentDiv = null; + } + + await RoomListLayoutStore.instance.resetLayouts(); + await (RoomListStore.instance as RoomListStoreClass).resetStore(); + + done(); + }); + + function expectRoomInSubList(room, subListTest) { + const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSublist); + const containingSubList = subLists.find(subListTest); + + let expectedRoomTile; + try { + const roomTiles = ReactTestUtils.scryRenderedComponentsWithType(containingSubList, RoomTile); + console.info({ roomTiles: roomTiles.length }); + expectedRoomTile = roomTiles.find((tile) => tile.props.room === room); + } catch (err) { + // truncate the error message because it's spammy + err.message = + "Error finding RoomTile for " + + room.roomId + + " in " + + subListTest + + ": " + + err.message.split("componentType")[0] + + "..."; + throw err; + } + + expect(expectedRoomTile).toBeTruthy(); + expect(expectedRoomTile.props.room).toBe(room); + } + + function expectCorrectMove(oldTagId, newTagId) { + const getTagSubListTest = (tagId) => { + return (s) => s.props.tagId === tagId; + }; + + // Default to finding the destination sublist with newTag + const destSubListTest = getTagSubListTest(newTagId); + const srcSubListTest = getTagSubListTest(oldTagId); + + // Set up the room that will be moved such that it has the correct state for a room in + // the section for oldTagId + if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) { + movingRoom.tags = { [oldTagId]: {} }; + } else if (oldTagId === DefaultTagID.DM) { + // Mock inverse m.direct + // @ts-ignore forcing private property + DMRoomMap.shared().roomToUser = { + [movingRoom.roomId]: "@someotheruser:domain", + }; + } + + dis.dispatch({ action: "MatrixActions.sync", prevState: null, state: "PREPARED", matrixClient: client }); + + expectRoomInSubList(movingRoom, srcSubListTest); + + dis.dispatch({ + action: "RoomListActions.tagRoom.pending", + request: { + oldTagId, + newTagId, + room: movingRoom, + }, + }); + + expectRoomInSubList(movingRoom, destSubListTest); + } + + function itDoesCorrectOptimisticUpdatesForDraggedRoomTiles() { + // TODO: Re-enable dragging tests when we support dragging again. + describe.skip("does correct optimistic update when dragging from", () => { + it("rooms to people", () => { + expectCorrectMove(undefined, DefaultTagID.DM); + }); + + it("rooms to favourites", () => { + expectCorrectMove(undefined, "m.favourite"); + }); + + it("rooms to low priority", () => { + expectCorrectMove(undefined, "m.lowpriority"); + }); + + // XXX: Known to fail - the view does not update immediately to reflect the change. + // Whe running the app live, it updates when some other event occurs (likely the + // m.direct arriving) that these tests do not fire. + xit("people to rooms", () => { + expectCorrectMove(DefaultTagID.DM, undefined); + }); + + it("people to favourites", () => { + expectCorrectMove(DefaultTagID.DM, "m.favourite"); + }); + + it("people to lowpriority", () => { + expectCorrectMove(DefaultTagID.DM, "m.lowpriority"); + }); + + it("low priority to rooms", () => { + expectCorrectMove("m.lowpriority", undefined); + }); + + it("low priority to people", () => { + expectCorrectMove("m.lowpriority", DefaultTagID.DM); + }); + + it("low priority to low priority", () => { + expectCorrectMove("m.lowpriority", "m.lowpriority"); + }); + + it("favourites to rooms", () => { + expectCorrectMove("m.favourite", undefined); + }); + + it("favourites to people", () => { + expectCorrectMove("m.favourite", DefaultTagID.DM); + }); + + it("favourites to low priority", () => { + expectCorrectMove("m.favourite", "m.lowpriority"); + }); + }); + } + + itDoesCorrectOptimisticUpdatesForDraggedRoomTiles(); +}); From edc44922c2809162aac6cec07751c49fab4273de Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:20:02 -0700 Subject: [PATCH 13/29] Revert "Revert "Remove unused/disabled room list drag&drop tests (#9883)"" This reverts commit 0453283bede917a220e9b04cffd415ec61e7bdea. --- test/components/views/rooms/RoomList-test.tsx | 273 ------------------ 1 file changed, 273 deletions(-) delete mode 100644 test/components/views/rooms/RoomList-test.tsx diff --git a/test/components/views/rooms/RoomList-test.tsx b/test/components/views/rooms/RoomList-test.tsx deleted file mode 100644 index e780f0a9ef..0000000000 --- a/test/components/views/rooms/RoomList-test.tsx +++ /dev/null @@ -1,273 +0,0 @@ -/* -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 ReactTestUtils from "react-dom/test-utils"; -import ReactDOM from "react-dom"; -import { PendingEventOrdering, Room, RoomMember } from "matrix-js-sdk/src/matrix"; - -import * as TestUtils from "../../../test-utils"; -import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; -import dis from "../../../../src/dispatcher/dispatcher"; -import DMRoomMap from "../../../../src/utils/DMRoomMap"; -import { DefaultTagID } from "../../../../src/stores/room-list/models"; -import RoomListStore, { RoomListStoreClass } from "../../../../src/stores/room-list/RoomListStore"; -import RoomListLayoutStore from "../../../../src/stores/room-list/RoomListLayoutStore"; -import RoomList from "../../../../src/components/views/rooms/RoomList"; -import RoomSublist from "../../../../src/components/views/rooms/RoomSublist"; -import { RoomTile } from "../../../../src/components/views/rooms/RoomTile"; -import { getMockClientWithEventEmitter, mockClientMethodsUser } from "../../../test-utils"; -import ResizeNotifier from "../../../../src/utils/ResizeNotifier"; - -function generateRoomId() { - return "!" + Math.random().toString().slice(2, 10) + ":domain"; -} - -describe("RoomList", () => { - function createRoom(opts) { - const room = new Room(generateRoomId(), MatrixClientPeg.get(), client.getUserId(), { - // The room list now uses getPendingEvents(), so we need a detached ordering. - pendingEventOrdering: PendingEventOrdering.Detached, - }); - if (opts) { - Object.assign(room, opts); - } - return room; - } - - let parentDiv = null; - let root = null; - const myUserId = "@me:domain"; - - const movingRoomId = "!someroomid"; - let movingRoom: Room | undefined; - let otherRoom: Room | undefined; - - let myMember: RoomMember | undefined; - let myOtherMember: RoomMember | undefined; - - const client = getMockClientWithEventEmitter({ - ...mockClientMethodsUser(myUserId), - getRooms: jest.fn(), - getVisibleRooms: jest.fn(), - getRoom: jest.fn(), - }); - - const defaultProps = { - onKeyDown: jest.fn(), - onFocus: jest.fn(), - onBlur: jest.fn(), - onResize: jest.fn(), - resizeNotifier: {} as unknown as ResizeNotifier, - isMinimized: false, - activeSpace: "", - }; - - beforeEach(async function (done) { - RoomListStoreClass.TEST_MODE = true; - jest.clearAllMocks(); - - client.credentials = { userId: myUserId }; - - DMRoomMap.makeShared(); - - parentDiv = document.createElement("div"); - document.body.appendChild(parentDiv); - - const WrappedRoomList = TestUtils.wrapInMatrixClientContext(RoomList); - root = ReactDOM.render(, parentDiv); - ReactTestUtils.findRenderedComponentWithType(root, RoomList); - - movingRoom = createRoom({ name: "Moving room" }); - expect(movingRoom.roomId).not.toBe(null); - - // Mock joined member - myMember = new RoomMember(movingRoomId, myUserId); - myMember.membership = "join"; - movingRoom.updateMyMembership("join"); - movingRoom.getMember = (userId) => - ({ - [client.credentials.userId]: myMember, - }[userId]); - - otherRoom = createRoom({ name: "Other room" }); - myOtherMember = new RoomMember(otherRoom.roomId, myUserId); - myOtherMember.membership = "join"; - otherRoom.updateMyMembership("join"); - otherRoom.getMember = (userId) => - ({ - [client.credentials.userId]: myOtherMember, - }[userId]); - - // Mock the matrix client - const mockRooms = [ - movingRoom, - otherRoom, - createRoom({ tags: { "m.favourite": { order: 0.1 } }, name: "Some other room" }), - createRoom({ tags: { "m.favourite": { order: 0.2 } }, name: "Some other room 2" }), - createRoom({ tags: { "m.lowpriority": {} }, name: "Some unimportant room" }), - createRoom({ tags: { "custom.tag": {} }, name: "Some room customly tagged" }), - ]; - client.getRooms.mockReturnValue(mockRooms); - client.getVisibleRooms.mockReturnValue(mockRooms); - - const roomMap = {}; - client.getRooms().forEach((r) => { - roomMap[r.roomId] = r; - }); - - client.getRoom.mockImplementation((roomId) => roomMap[roomId]); - - // Now that everything has been set up, prepare and update the store - await (RoomListStore.instance as RoomListStoreClass).makeReady(client); - - done(); - }); - - afterEach(async (done) => { - if (parentDiv) { - ReactDOM.unmountComponentAtNode(parentDiv); - parentDiv.remove(); - parentDiv = null; - } - - await RoomListLayoutStore.instance.resetLayouts(); - await (RoomListStore.instance as RoomListStoreClass).resetStore(); - - done(); - }); - - function expectRoomInSubList(room, subListTest) { - const subLists = ReactTestUtils.scryRenderedComponentsWithType(root, RoomSublist); - const containingSubList = subLists.find(subListTest); - - let expectedRoomTile; - try { - const roomTiles = ReactTestUtils.scryRenderedComponentsWithType(containingSubList, RoomTile); - console.info({ roomTiles: roomTiles.length }); - expectedRoomTile = roomTiles.find((tile) => tile.props.room === room); - } catch (err) { - // truncate the error message because it's spammy - err.message = - "Error finding RoomTile for " + - room.roomId + - " in " + - subListTest + - ": " + - err.message.split("componentType")[0] + - "..."; - throw err; - } - - expect(expectedRoomTile).toBeTruthy(); - expect(expectedRoomTile.props.room).toBe(room); - } - - function expectCorrectMove(oldTagId, newTagId) { - const getTagSubListTest = (tagId) => { - return (s) => s.props.tagId === tagId; - }; - - // Default to finding the destination sublist with newTag - const destSubListTest = getTagSubListTest(newTagId); - const srcSubListTest = getTagSubListTest(oldTagId); - - // Set up the room that will be moved such that it has the correct state for a room in - // the section for oldTagId - if (oldTagId === DefaultTagID.Favourite || oldTagId === DefaultTagID.LowPriority) { - movingRoom.tags = { [oldTagId]: {} }; - } else if (oldTagId === DefaultTagID.DM) { - // Mock inverse m.direct - // @ts-ignore forcing private property - DMRoomMap.shared().roomToUser = { - [movingRoom.roomId]: "@someotheruser:domain", - }; - } - - dis.dispatch({ action: "MatrixActions.sync", prevState: null, state: "PREPARED", matrixClient: client }); - - expectRoomInSubList(movingRoom, srcSubListTest); - - dis.dispatch({ - action: "RoomListActions.tagRoom.pending", - request: { - oldTagId, - newTagId, - room: movingRoom, - }, - }); - - expectRoomInSubList(movingRoom, destSubListTest); - } - - function itDoesCorrectOptimisticUpdatesForDraggedRoomTiles() { - // TODO: Re-enable dragging tests when we support dragging again. - describe.skip("does correct optimistic update when dragging from", () => { - it("rooms to people", () => { - expectCorrectMove(undefined, DefaultTagID.DM); - }); - - it("rooms to favourites", () => { - expectCorrectMove(undefined, "m.favourite"); - }); - - it("rooms to low priority", () => { - expectCorrectMove(undefined, "m.lowpriority"); - }); - - // XXX: Known to fail - the view does not update immediately to reflect the change. - // Whe running the app live, it updates when some other event occurs (likely the - // m.direct arriving) that these tests do not fire. - xit("people to rooms", () => { - expectCorrectMove(DefaultTagID.DM, undefined); - }); - - it("people to favourites", () => { - expectCorrectMove(DefaultTagID.DM, "m.favourite"); - }); - - it("people to lowpriority", () => { - expectCorrectMove(DefaultTagID.DM, "m.lowpriority"); - }); - - it("low priority to rooms", () => { - expectCorrectMove("m.lowpriority", undefined); - }); - - it("low priority to people", () => { - expectCorrectMove("m.lowpriority", DefaultTagID.DM); - }); - - it("low priority to low priority", () => { - expectCorrectMove("m.lowpriority", "m.lowpriority"); - }); - - it("favourites to rooms", () => { - expectCorrectMove("m.favourite", undefined); - }); - - it("favourites to people", () => { - expectCorrectMove("m.favourite", DefaultTagID.DM); - }); - - it("favourites to low priority", () => { - expectCorrectMove("m.favourite", "m.lowpriority"); - }); - }); - } - - itDoesCorrectOptimisticUpdatesForDraggedRoomTiles(); -}); From 19ba620de228dcc5307ec9ef1f868db0a64833c6 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:20:10 -0700 Subject: [PATCH 14/29] Revert "Revert "Factor out `MessageEvent.from()` usage (#9882)"" This reverts commit 99e38ca88e084f06c45e16fce5b1994e01a5ae11. --- cypress/e2e/editing/editing.spec.ts | 8 +- cypress/e2e/timeline/timeline.spec.ts | 31 +++---- .../structures/TimelinePanel-test.tsx | 24 +++--- .../context_menus/MessageContextMenu-test.tsx | 83 ++++++++++++------- test/test-utils/events.ts | 42 ++++++++++ 5 files changed, 127 insertions(+), 61 deletions(-) create mode 100644 test/test-utils/events.ts diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 6f16a6a4cd..9051a70c60 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -16,15 +16,17 @@ limitations under the License. /// -import { MessageEvent } from "matrix-events-sdk"; - +import type { MsgType } from "matrix-js-sdk/src/@types/event"; import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { EventType } from "matrix-js-sdk/src/@types/event"; import { SynapseInstance } from "../../plugins/synapsedocker"; import Chainable = Cypress.Chainable; const sendEvent = (roomId: string): Chainable => { - return cy.sendEvent(roomId, null, "m.room.message" as EventType, MessageEvent.from("Message").serialize().content); + return cy.sendEvent(roomId, null, "m.room.message" as EventType, { + msgtype: "m.text" as MsgType, + body: "Message", + }); }; describe("Editing", () => { diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 9eea2a5567..90406eae02 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -16,10 +16,8 @@ limitations under the License. /// -import { MessageEvent } from "matrix-events-sdk"; - import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; -import type { EventType } from "matrix-js-sdk/src/@types/event"; +import type { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; import { SynapseInstance } from "../../plugins/synapsedocker"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; @@ -55,12 +53,17 @@ const expectAvatar = (e: JQuery, avatarUrl: string): void => { }; const sendEvent = (roomId: string, html = false): Chainable => { - return cy.sendEvent( - roomId, - null, - "m.room.message" as EventType, - MessageEvent.from("Message", html ? "Message" : undefined).serialize().content, - ); + const content = { + msgtype: "m.text" as MsgType, + body: "Message", + format: undefined, + formatted_body: undefined, + }; + if (html) { + content.format = "org.matrix.custom.html"; + content.formatted_body = "Message"; + } + return cy.sendEvent(roomId, null, "m.room.message" as EventType, content); }; describe("Timeline", () => { @@ -314,12 +317,10 @@ describe("Timeline", () => { }, }).as("preview_url"); - cy.sendEvent( - roomId, - null, - "m.room.message" as EventType, - MessageEvent.from("https://call.element.io/").serialize().content, - ); + cy.sendEvent(roomId, null, "m.room.message" as EventType, { + msgtype: "m.text" as MsgType, + body: "https://call.element.io/", + }); cy.visit("/#/room/" + roomId); cy.get(".mx_LinkPreviewWidget").should("exist").should("contain.text", "Element Call"); diff --git a/test/components/structures/TimelinePanel-test.tsx b/test/components/structures/TimelinePanel-test.tsx index d7ff659c9d..95847bb8c5 100644 --- a/test/components/structures/TimelinePanel-test.tsx +++ b/test/components/structures/TimelinePanel-test.tsx @@ -17,7 +17,6 @@ limitations under the License. import { render, RenderResult, waitFor, screen } from "@testing-library/react"; // eslint-disable-next-line deprecate/import import { mount, ReactWrapper } from "enzyme"; -import { MessageEvent } from "matrix-events-sdk"; import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { EventTimelineSet, @@ -48,6 +47,7 @@ import SettingsStore from "../../../src/settings/SettingsStore"; import { isCallEvent } from "../../../src/components/structures/LegacyCallEventGrouper"; import { flushPromises, mkMembership, mkRoom, stubClient } from "../../test-utils"; import { mkThread } from "../../test-utils/threads"; +import { createMessageEventContent } from "../../test-utils/events"; const newReceipt = (eventId: string, userId: string, readTs: number, fullyReadTs: number): MatrixEvent => { const receiptContent = { @@ -89,8 +89,8 @@ const mockEvents = (room: Room, count = 2): MatrixEvent[] => { room_id: room.roomId, event_id: `${room.roomId}_event_${index}`, type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`Event${index}`).serialize().content, + sender: "userId", + content: createMessageEventContent("`Event${index}`"), }), ); } @@ -125,13 +125,15 @@ describe("TimelinePanel", () => { event_id: "ev0", sender: "@u2:m.org", origin_server_ts: 111, - ...MessageEvent.from("hello 1").serialize(), + type: EventType.RoomMessage, + content: createMessageEventContent("hello 1"), }); const ev1 = new MatrixEvent({ event_id: "ev1", sender: "@u2:m.org", origin_server_ts: 222, - ...MessageEvent.from("hello 2").serialize(), + type: EventType.RoomMessage, + content: createMessageEventContent("hello 2"), }); const roomId = "#room:example.com"; @@ -385,24 +387,24 @@ describe("TimelinePanel", () => { room_id: room.roomId, event_id: "event_reply_1", type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`ReplyEvent1`).serialize().content, + sender: "userId", + content: createMessageEventContent("ReplyEvent1"), }); reply2 = new MatrixEvent({ room_id: room.roomId, event_id: "event_reply_2", type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`ReplyEvent2`).serialize().content, + sender: "userId", + content: createMessageEventContent("ReplyEvent2"), }); root = new MatrixEvent({ room_id: room.roomId, event_id: "event_root_1", type: EventType.RoomMessage, - user_id: "userId", - content: MessageEvent.from(`RootEvent`).serialize().content, + sender: "userId", + content: createMessageEventContent("RootEvent"), }); const eventMap: { [key: string]: MatrixEvent } = { diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index 3fdf26832d..a0ad320c5b 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -26,7 +26,7 @@ import { getBeaconInfoIdentifier, EventType, } from "matrix-js-sdk/src/matrix"; -import { ExtensibleEvent, MessageEvent, M_POLL_KIND_DISCLOSED, PollStartEvent } from "matrix-events-sdk"; +import { M_POLL_KIND_DISCLOSED, PollStartEvent } from "matrix-events-sdk"; import { FeatureSupport, Thread } from "matrix-js-sdk/src/models/thread"; import { mocked } from "jest-mock"; import { act } from "@testing-library/react"; @@ -44,6 +44,7 @@ import { ReadPinsEventId } from "../../../../src/components/views/right_panel/ty import { Action } from "../../../../src/dispatcher/actions"; import { mkVoiceBroadcastInfoStateEvent } from "../../../voice-broadcast/utils/test-utils"; import { VoiceBroadcastInfoState } from "../../../../src/voice-broadcast"; +import { createMessageEventContent } from "../../../test-utils/events"; jest.mock("../../../../src/utils/strings", () => ({ copyPlaintext: jest.fn(), @@ -64,7 +65,7 @@ describe("MessageContextMenu", () => { }); it("does show copy link button when supplied a link", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const props = { link: "https://google.com/", }; @@ -75,7 +76,7 @@ describe("MessageContextMenu", () => { }); it("does not show copy link button when not supplied a link", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createMenuWithContent(eventContent); const copyLinkButton = menu.find('a[aria-label="Copy link"]'); expect(copyLinkButton).toHaveLength(0); @@ -91,8 +92,8 @@ describe("MessageContextMenu", () => { }); it("does not show pin option when user does not have rights to pin", () => { - const eventContent = MessageEvent.from("hello"); - const event = new MatrixEvent(eventContent.serialize()); + const eventContent = createMessageEventContent("hello"); + const event = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); const room = makeDefaultRoom(); // mock permission to disallow adding pinned messages to room @@ -116,8 +117,12 @@ describe("MessageContextMenu", () => { }); it("does not show pin option when pinning feature is disabled", () => { - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); const room = makeDefaultRoom(); // mock permission to allow adding pinned messages to room @@ -131,8 +136,12 @@ describe("MessageContextMenu", () => { }); it("shows pin option when pinning feature is enabled", () => { - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); const room = makeDefaultRoom(); // mock permission to allow adding pinned messages to room @@ -145,8 +154,12 @@ describe("MessageContextMenu", () => { it("pins event on pin option click", () => { const onFinished = jest.fn(); - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.get(); const room = makeDefaultRoom(); @@ -188,8 +201,12 @@ describe("MessageContextMenu", () => { }); it("unpins event on pin option click when event is pinned", () => { - const eventContent = MessageEvent.from("hello"); - const pinnableEvent = new MatrixEvent({ ...eventContent.serialize(), room_id: roomId }); + const eventContent = createMessageEventContent("hello"); + const pinnableEvent = new MatrixEvent({ + type: EventType.RoomMessage, + content: eventContent, + room_id: roomId, + }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.get(); const room = makeDefaultRoom(); @@ -231,7 +248,7 @@ describe("MessageContextMenu", () => { describe("message forwarding", () => { it("allows forwarding a room message", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createMenuWithContent(eventContent); expect(menu.find('div[aria-label="Forward"]')).toHaveLength(1); }); @@ -335,7 +352,7 @@ describe("MessageContextMenu", () => { describe("open as map link", () => { it("does not allow opening a plain message in open street maps", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createMenuWithContent(eventContent); expect(menu.find('a[aria-label="Open in OpenStreetMap"]')).toHaveLength(0); }); @@ -380,7 +397,7 @@ describe("MessageContextMenu", () => { describe("right click", () => { it("copy button does work as expected", () => { const text = "hello"; - const eventContent = MessageEvent.from(text); + const eventContent = createMessageEventContent(text); mocked(getSelectedText).mockReturnValue(text); const menu = createRightClickMenuWithContent(eventContent); @@ -391,7 +408,7 @@ describe("MessageContextMenu", () => { it("copy button is not shown when there is nothing to copy", () => { const text = "hello"; - const eventContent = MessageEvent.from(text); + const eventContent = createMessageEventContent(text); mocked(getSelectedText).mockReturnValue(""); const menu = createRightClickMenuWithContent(eventContent); @@ -400,7 +417,7 @@ describe("MessageContextMenu", () => { }); it("shows edit button when we can edit", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); mocked(canEditContent).mockReturnValue(true); const menu = createRightClickMenuWithContent(eventContent); @@ -409,7 +426,7 @@ describe("MessageContextMenu", () => { }); it("does not show edit button when we cannot edit", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); mocked(canEditContent).mockReturnValue(false); const menu = createRightClickMenuWithContent(eventContent); @@ -418,7 +435,7 @@ describe("MessageContextMenu", () => { }); it("shows reply button when we can reply", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canSendMessages: true, }; @@ -429,11 +446,11 @@ describe("MessageContextMenu", () => { }); it("does not show reply button when we cannot reply", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canSendMessages: true, }; - const unsentMessage = new MatrixEvent(eventContent.serialize()); + const unsentMessage = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); // queued messages are not actionable unsentMessage.setStatus(EventStatus.QUEUED); @@ -443,7 +460,7 @@ describe("MessageContextMenu", () => { }); it("shows react button when we can react", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canReact: true, }; @@ -454,7 +471,7 @@ describe("MessageContextMenu", () => { }); it("does not show react button when we cannot react", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const context = { canReact: false, }; @@ -465,8 +482,8 @@ describe("MessageContextMenu", () => { }); it("shows view in room button when the event is a thread root", () => { - const eventContent = MessageEvent.from("hello"); - const mxEvent = new MatrixEvent(eventContent.serialize()); + const eventContent = createMessageEventContent("hello"); + const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); mxEvent.getThread = () => ({ rootEvent: mxEvent } as Thread); const props = { rightClick: true, @@ -481,7 +498,7 @@ describe("MessageContextMenu", () => { }); it("does not show view in room button when the event is not a thread root", () => { - const eventContent = MessageEvent.from("hello"); + const eventContent = createMessageEventContent("hello"); const menu = createRightClickMenuWithContent(eventContent); const reactButton = menu.find('div[aria-label="View in room"]'); @@ -489,8 +506,8 @@ describe("MessageContextMenu", () => { }); it("creates a new thread on reply in thread click", () => { - const eventContent = MessageEvent.from("hello"); - const mxEvent = new MatrixEvent(eventContent.serialize()); + const eventContent = createMessageEventContent("hello"); + const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); Thread.hasServerSideSupport = FeatureSupport.Stable; const context = { @@ -513,7 +530,7 @@ describe("MessageContextMenu", () => { }); }); -function createRightClickMenuWithContent(eventContent: ExtensibleEvent, context?: Partial): ReactWrapper { +function createRightClickMenuWithContent(eventContent: object, context?: Partial): ReactWrapper { return createMenuWithContent(eventContent, { rightClick: true }, context); } @@ -522,11 +539,13 @@ function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial>, context?: Partial, ): ReactWrapper { - const mxEvent = new MatrixEvent(eventContent.serialize()); + // XXX: We probably shouldn't be assuming all events are going to be message events, but considering this + // test is for the Message context menu, it's a fairly safe assumption. + const mxEvent = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); return createMenu(mxEvent, props, context); } diff --git a/test/test-utils/events.ts b/test/test-utils/events.ts new file mode 100644 index 0000000000..28189ffd8d --- /dev/null +++ b/test/test-utils/events.ts @@ -0,0 +1,42 @@ +/* +Copyright 2023 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 { MsgType } from "matrix-js-sdk/src/@types/event"; + +interface MessageContent { + msgtype: MsgType; + body: string; + format?: string; + formatted_body?: string; +} + +/** + * Creates the `content` for an `m.room.message` event based on input. + * @param text The text to put in the event. + * @param html Optional HTML to put in the event. + * @returns A complete `content` object for an `m.room.message` event. + */ +export function createMessageEventContent(text: string, html?: string): MessageContent { + const content: MessageContent = { + msgtype: MsgType.Text, + body: text, + }; + if (html) { + content.format = "org.matrix.custom.html"; + content.formatted_body = html; + } + return content; +} From b642df98e9047fd901ea66edb4f76d9bcce339d0 Mon Sep 17 00:00:00 2001 From: Travis Ralston Date: Tue, 10 Jan 2023 09:21:03 -0700 Subject: [PATCH 15/29] Revert "Revert "Remove extensible events v1 experimental rendering (#9881)"" This reverts commit b4f2db22dfaaf69989d47ad198aab959189908b5. --- src/TextForEvent.tsx | 21 +--------------- src/components/views/messages/TextualBody.tsx | 24 ------------------- src/i18n/strings/en_EN.json | 1 - src/settings/Settings.tsx | 7 ------ 4 files changed, 1 insertion(+), 52 deletions(-) diff --git a/src/TextForEvent.tsx b/src/TextForEvent.tsx index 01b7fe4eaf..8be6cd4a40 100644 --- a/src/TextForEvent.tsx +++ b/src/TextForEvent.tsx @@ -20,15 +20,7 @@ import { logger } from "matrix-js-sdk/src/logger"; import { removeDirectionOverrideChars } from "matrix-js-sdk/src/utils"; import { GuestAccess, HistoryVisibility, JoinRule } from "matrix-js-sdk/src/@types/partials"; import { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; -import { - M_EMOTE, - M_NOTICE, - M_MESSAGE, - MessageEvent, - M_POLL_START, - M_POLL_END, - PollStartEvent, -} from "matrix-events-sdk"; +import { M_POLL_START, M_POLL_END, PollStartEvent } from "matrix-events-sdk"; import { _t } from "./languageHandler"; import * as Roles from "./Roles"; @@ -347,17 +339,6 @@ function textForMessageEvent(ev: MatrixEvent): () => string | null { message = textForRedactedPollAndMessageEvent(ev); } - if (SettingsStore.isEnabled("feature_extensible_events")) { - const extev = ev.unstableExtensibleEvent as MessageEvent; - if (extev) { - if (extev.isEquivalentTo(M_EMOTE)) { - return `* ${senderDisplayName} ${extev.text}`; - } else if (extev.isEquivalentTo(M_NOTICE) || extev.isEquivalentTo(M_MESSAGE)) { - return `${senderDisplayName}: ${extev.text}`; - } - } - } - if (ev.getContent().msgtype === MsgType.Emote) { message = "* " + senderDisplayName + " " + message; } else if (ev.getContent().msgtype === MsgType.Image) { diff --git a/src/components/views/messages/TextualBody.tsx b/src/components/views/messages/TextualBody.tsx index 4d4818c860..d1824308f4 100644 --- a/src/components/views/messages/TextualBody.tsx +++ b/src/components/views/messages/TextualBody.tsx @@ -18,7 +18,6 @@ import React, { createRef, SyntheticEvent, MouseEvent, ReactNode } from "react"; import ReactDOM from "react-dom"; import highlight from "highlight.js"; import { MsgType } from "matrix-js-sdk/src/@types/event"; -import { isEventLike, LegacyMsgType, M_MESSAGE, MessageEvent } from "matrix-events-sdk"; import * as HtmlUtils from "../../../HtmlUtils"; import { formatDate } from "../../../DateUtils"; @@ -579,29 +578,6 @@ export default class TextualBody extends React.Component { // only strip reply if this is the original replying event, edits thereafter do not have the fallback const stripReply = !mxEvent.replacingEvent() && !!getParentEventId(mxEvent); let body: ReactNode; - if (SettingsStore.isEnabled("feature_extensible_events")) { - const extev = this.props.mxEvent.unstableExtensibleEvent as MessageEvent; - if (extev?.isEquivalentTo(M_MESSAGE)) { - isEmote = isEventLike(extev.wireFormat, LegacyMsgType.Emote); - isNotice = isEventLike(extev.wireFormat, LegacyMsgType.Notice); - body = HtmlUtils.bodyToHtml( - { - body: extev.text, - format: extev.html ? "org.matrix.custom.html" : undefined, - formatted_body: extev.html, - msgtype: MsgType.Text, - }, - this.props.highlights, - { - disableBigEmoji: isEmote || !SettingsStore.getValue("TextualBody.enableBigEmoji"), - // Part of Replies fallback support - stripReplyFallback: stripReply, - ref: this.contentRef, - returnString: false, - }, - ); - } - } if (!body) { isEmote = content.msgtype === MsgType.Emote; isNotice = content.msgtype === MsgType.Notice; diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index d4a8b522b8..16acf9b8dc 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -938,7 +938,6 @@ "Show message previews for reactions in DMs": "Show message previews for reactions in DMs", "Show message previews for reactions in all rooms": "Show message previews for reactions in all rooms", "Offline encrypted messaging using dehydrated devices": "Offline encrypted messaging using dehydrated devices", - "Show extensible event representation of events": "Show extensible event representation of events", "Show current avatar and name for users in message history": "Show current avatar and name for users in message history", "Show HTML representation of room topics": "Show HTML representation of room topics", "Show info about bridges in room settings": "Show info about bridges in room settings", diff --git a/src/settings/Settings.tsx b/src/settings/Settings.tsx index 03ae5bd1db..3fd38f0eab 100644 --- a/src/settings/Settings.tsx +++ b/src/settings/Settings.tsx @@ -340,13 +340,6 @@ export const SETTINGS: { [setting: string]: ISetting } = { supportedLevels: LEVELS_FEATURE, default: false, }, - "feature_extensible_events": { - isFeature: true, - labsGroup: LabGroup.Developer, // developer for now, eventually Messaging and default on - supportedLevels: LEVELS_FEATURE, - displayName: _td("Show extensible event representation of events"), - default: false, - }, "useOnlyCurrentProfiles": { supportedLevels: LEVELS_ACCOUNT_SETTINGS, displayName: _td("Show current avatar and name for users in message history"), From 79033eb03408a58c714c47162fe9923ed9247380 Mon Sep 17 00:00:00 2001 From: devonh Date: Tue, 10 Jan 2023 23:29:56 +0000 Subject: [PATCH 16/29] Add dendrite support to cypress tests (#9884) * Minimum hacks required to run cypress tests with dendrite * Remove wget hack since dendrite containers now have curl * Add basic dendritedocker plugin & hack into login spec for testing * Add generic HomeserverInstance interface * Add env var to configure which homeserver to use * Remove synapse specific homeserver support api * Update the rest of the tests to use HomeserverInstance * Update cypress docs to reference new homeserver abstraction * Fix formatting issues * Change dendrite to use main branch container --- .gitignore | 1 + cypress.config.ts | 1 + cypress/e2e/composer/composer.spec.ts | 14 +- cypress/e2e/create-room/create-room.spec.ts | 12 +- cypress/e2e/crypto/crypto.spec.ts | 18 +- cypress/e2e/crypto/decryption-failure.spec.ts | 18 +- cypress/e2e/editing/editing.spec.ts | 12 +- .../get-openid-token.spec.ts | 12 +- cypress/e2e/integration-manager/kick.spec.ts | 14 +- .../integration-manager/read_events.spec.ts | 12 +- .../integration-manager/send_event.spec.ts | 12 +- cypress/e2e/lazy-loading/lazy-loading.spec.ts | 16 +- cypress/e2e/location/location.spec.ts | 12 +- cypress/e2e/login/consent.spec.ts | 16 +- cypress/e2e/login/login.spec.ts | 20 +- cypress/e2e/polls/polls.spec.ts | 22 +- cypress/e2e/register/register.spec.ts | 16 +- .../pills-click-in-app.spec.ts | 12 +- cypress/e2e/right-panel/right-panel.spec.ts | 12 +- .../e2e/room-directory/room-directory.spec.ts | 14 +- cypress/e2e/room/room.spec.ts | 16 +- .../e2e/settings/device-management.spec.ts | 16 +- .../e2e/settings/hidden-rr-migration.spec.ts | 20 +- cypress/e2e/sliding-sync/sliding-sync.ts | 48 +-- cypress/e2e/spaces/spaces.spec.ts | 16 +- cypress/e2e/spotlight/spotlight.spec.ts | 16 +- cypress/e2e/threads/threads.spec.ts | 14 +- cypress/e2e/timeline/timeline.spec.ts | 12 +- cypress/e2e/toasts/analytics-toast.ts | 18 +- cypress/e2e/update/update.spec.ts | 12 +- cypress/e2e/user-menu/user-menu.spec.ts | 12 +- .../user-onboarding/user-onboarding-new.ts | 14 +- .../user-onboarding/user-onboarding-old.ts | 12 +- cypress/e2e/user-view/user-view.spec.ts | 14 +- cypress/e2e/widgets/layout.spec.ts | 12 +- cypress/e2e/widgets/stickers.spec.ts | 12 +- cypress/e2e/widgets/widget-pip-close.spec.ts | 14 +- cypress/plugins/dendritedocker/index.ts | 181 +++++++++ .../templates/default/dendrite.yaml | 374 ++++++++++++++++++ cypress/plugins/docker/index.ts | 4 +- cypress/plugins/index.ts | 2 + cypress/plugins/sliding-sync/index.ts | 8 +- cypress/plugins/synapsedocker/index.ts | 23 +- cypress/plugins/utils/homeserver.ts | 28 ++ cypress/support/bot.ts | 33 +- cypress/support/e2e.ts | 2 +- cypress/support/{synapse.ts => homeserver.ts} | 48 +-- cypress/support/login.ts | 18 +- cypress/support/proxy.ts | 12 +- docs/cypress.md | 32 +- 50 files changed, 947 insertions(+), 362 deletions(-) create mode 100644 cypress/plugins/dendritedocker/index.ts create mode 100644 cypress/plugins/dendritedocker/templates/default/dendrite.yaml create mode 100644 cypress/plugins/utils/homeserver.ts rename cypress/support/{synapse.ts => homeserver.ts} (63%) diff --git a/.gitignore b/.gitignore index 7d257d7e9a..489b5ccb39 100644 --- a/.gitignore +++ b/.gitignore @@ -24,6 +24,7 @@ package-lock.json /cypress/downloads /cypress/screenshots /cypress/synapselogs +/cypress/dendritelogs # These could have files in them but don't currently # Cypress will still auto-create them though... /cypress/performance diff --git a/cypress.config.ts b/cypress.config.ts index 3aca5da28d..253857e375 100644 --- a/cypress.config.ts +++ b/cypress.config.ts @@ -33,6 +33,7 @@ export default defineConfig({ env: { // Docker tag to use for `ghcr.io/matrix-org/sliding-sync-proxy` image. SLIDING_SYNC_PROXY_TAG: "v0.6.0", + HOMESERVER: "synapse", }, retries: { runMode: 4, diff --git a/cypress/e2e/composer/composer.spec.ts b/cypress/e2e/composer/composer.spec.ts index 0b042ccefe..6d2879ff10 100644 --- a/cypress/e2e/composer/composer.spec.ts +++ b/cypress/e2e/composer/composer.spec.ts @@ -16,25 +16,25 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { SettingLevel } from "../../../src/settings/SettingLevel"; describe("Composer", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); describe("CIDER", () => { beforeEach(() => { - cy.initTestUser(synapse, "Janet").then(() => { + cy.initTestUser(homeserver, "Janet").then(() => { cy.createRoom({ name: "Composing Room" }); }); cy.viewRoomByName("Composing Room"); @@ -101,7 +101,7 @@ describe("Composer", () => { describe("WYSIWYG", () => { beforeEach(() => { cy.enableLabsFeature("feature_wysiwyg_composer"); - cy.initTestUser(synapse, "Janet").then(() => { + cy.initTestUser(homeserver, "Janet").then(() => { cy.createRoom({ name: "Composing Room" }); }); cy.viewRoomByName("Composing Room"); diff --git a/cypress/e2e/create-room/create-room.spec.ts b/cypress/e2e/create-room/create-room.spec.ts index 704fa4a981..72805d5e12 100644 --- a/cypress/e2e/create-room/create-room.spec.ts +++ b/cypress/e2e/create-room/create-room.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; function openCreateRoomDialog(): Chainable> { @@ -26,18 +26,18 @@ function openCreateRoomDialog(): Chainable> { } describe("Create Room", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Jim"); + cy.initTestUser(homeserver, "Jim"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should allow us to create a public room with name, topic & address set", () => { diff --git a/cypress/e2e/crypto/crypto.spec.ts b/cypress/e2e/crypto/crypto.spec.ts index eb0e762829..716eff8ddb 100644 --- a/cypress/e2e/crypto/crypto.spec.ts +++ b/cypress/e2e/crypto/crypto.spec.ts @@ -18,12 +18,12 @@ import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/m import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; import type { CypressBot } from "../../support/bot"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; type EmojiMapping = [emoji: string, name: string]; interface CryptoTestContext extends Mocha.Context { - synapse: SynapseInstance; + homeserver: HomeserverInstance; bob: CypressBot; } @@ -155,16 +155,16 @@ const verify = function (this: CryptoTestContext) { describe("Cryptography", function () { beforeEach(function () { - cy.startSynapse("default") - .as("synapse") - .then((synapse: SynapseInstance) => { - cy.initTestUser(synapse, "Alice", undefined, "alice_"); - cy.getBot(synapse, { displayName: "Bob", autoAcceptInvites: false, userIdPrefix: "bob_" }).as("bob"); + cy.startHomeserver("default") + .as("homeserver") + .then((homeserver: HomeserverInstance) => { + cy.initTestUser(homeserver, "Alice", undefined, "alice_"); + cy.getBot(homeserver, { displayName: "Bob", autoAcceptInvites: false, userIdPrefix: "bob_" }).as("bob"); }); }); afterEach(function (this: CryptoTestContext) { - cy.stopSynapse(this.synapse); + cy.stopHomeserver(this.homeserver); }); it("setting up secure key backup should work", () => { @@ -215,7 +215,7 @@ describe("Cryptography", function () { cy.bootstrapCrossSigning(); // bob has a second, not cross-signed, device - cy.loginBot(this.synapse, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); + cy.loginBot(this.homeserver, this.bob.getUserId(), this.bob.__cypress_password, {}).as("bobSecondDevice"); autoJoin(this.bob); diff --git a/cypress/e2e/crypto/decryption-failure.spec.ts b/cypress/e2e/crypto/decryption-failure.spec.ts index c947240400..6cc0a69e3c 100644 --- a/cypress/e2e/crypto/decryption-failure.spec.ts +++ b/cypress/e2e/crypto/decryption-failure.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import type { VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; import type { ISasEvent } from "matrix-js-sdk/src/crypto/verification/SAS"; import type { MatrixClient } from "matrix-js-sdk/src/matrix"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; import Chainable = Cypress.Chainable; @@ -56,20 +56,20 @@ const handleVerificationRequest = (request: VerificationRequest): Chainable { - let synapse: SynapseInstance | undefined; + let homeserver: HomeserverInstance | undefined; let testUser: UserCredentials | undefined; let bot: MatrixClient | undefined; let roomId: string; beforeEach(function () { - cy.startSynapse("default").then((syn: SynapseInstance) => { - synapse = syn; - cy.initTestUser(synapse, TEST_USER) + cy.startHomeserver("default").then((hs: HomeserverInstance) => { + homeserver = hs; + cy.initTestUser(homeserver, TEST_USER) .then((creds: UserCredentials) => { testUser = creds; }) .then(() => { - cy.getBot(synapse, { displayName: BOT_USER }).then((cli) => { + cy.getBot(homeserver, { displayName: BOT_USER }).then((cli) => { bot = cli; }); }) @@ -97,7 +97,7 @@ describe("Decryption Failure Bar", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it( @@ -105,7 +105,7 @@ describe("Decryption Failure Bar", () => { "and there are other verified devices or backups", () => { let otherDevice: MatrixClient | undefined; - cy.loginBot(synapse, testUser.username, testUser.password, {}) + cy.loginBot(homeserver, testUser.username, testUser.password, {}) .then(async (cli) => { otherDevice = cli; await otherDevice.bootstrapCrossSigning({ @@ -169,7 +169,7 @@ describe("Decryption Failure Bar", () => { "should prompt the user to reset keys, if this device isn't verified " + "and there are no other verified devices or backups", () => { - cy.loginBot(synapse, testUser.username, testUser.password, {}).then(async (cli) => { + cy.loginBot(homeserver, testUser.username, testUser.password, {}).then(async (cli) => { await cli.bootstrapCrossSigning({ authUploadDeviceSigningKeys: async (makeRequest) => { await makeRequest({}); diff --git a/cypress/e2e/editing/editing.spec.ts b/cypress/e2e/editing/editing.spec.ts index 9051a70c60..f8d7dd1e3f 100644 --- a/cypress/e2e/editing/editing.spec.ts +++ b/cypress/e2e/editing/editing.spec.ts @@ -19,7 +19,7 @@ limitations under the License. import type { MsgType } from "matrix-js-sdk/src/@types/event"; import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { EventType } from "matrix-js-sdk/src/@types/event"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; const sendEvent = (roomId: string): Chainable => { @@ -30,12 +30,12 @@ const sendEvent = (roomId: string): Chainable => { }; describe("Editing", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, "Edith").then(() => { + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Edith").then(() => { cy.injectAxe(); return cy.createRoom({ name: "Test room" }).as("roomId"); }); @@ -43,7 +43,7 @@ describe("Editing", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should close the composer when clicking save after making a change and undoing it", () => { diff --git a/cypress/e2e/integration-manager/get-openid-token.spec.ts b/cypress/e2e/integration-manager/get-openid-token.spec.ts index f799437862..f7b3eeaeca 100644 --- a/cypress/e2e/integration-manager/get-openid-token.spec.ts +++ b/cypress/e2e/integration-manager/get-openid-token.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; const ROOM_NAME = "Integration Manager Test"; @@ -73,17 +73,17 @@ function sendActionFromIntegrationManager(integrationManagerUrl: string) { describe("Integration Manager: Get OpenID Token", () => { let testUser: UserCredentials; - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let integrationManagerUrl: string; beforeEach(() => { cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { integrationManagerUrl = url; }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { cy.window().then((win) => { win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); @@ -122,7 +122,7 @@ describe("Integration Manager: Get OpenID Token", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); cy.stopWebServers(); }); diff --git a/cypress/e2e/integration-manager/kick.spec.ts b/cypress/e2e/integration-manager/kick.spec.ts index e182d840de..2cd66fa51b 100644 --- a/cypress/e2e/integration-manager/kick.spec.ts +++ b/cypress/e2e/integration-manager/kick.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { MatrixClient } from "../../global"; import { UserCredentials } from "../../support/login"; @@ -94,17 +94,17 @@ function expectKickedMessage(shouldExist: boolean) { describe("Integration Manager: Kick", () => { let testUser: UserCredentials; - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let integrationManagerUrl: string; beforeEach(() => { cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { integrationManagerUrl = url; }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { cy.window().then((win) => { win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); @@ -140,12 +140,12 @@ describe("Integration Manager: Kick", () => { name: ROOM_NAME, }).as("roomId"); - cy.getBot(synapse, { displayName: BOT_DISPLAY_NAME, autoAcceptInvites: true }).as("bob"); + cy.getBot(homeserver, { displayName: BOT_DISPLAY_NAME, autoAcceptInvites: true }).as("bob"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); cy.stopWebServers(); }); diff --git a/cypress/e2e/integration-manager/read_events.spec.ts b/cypress/e2e/integration-manager/read_events.spec.ts index 662df22813..c331038db9 100644 --- a/cypress/e2e/integration-manager/read_events.spec.ts +++ b/cypress/e2e/integration-manager/read_events.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; const ROOM_NAME = "Integration Manager Test"; @@ -87,17 +87,17 @@ function sendActionFromIntegrationManager( describe("Integration Manager: Read Events", () => { let testUser: UserCredentials; - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let integrationManagerUrl: string; beforeEach(() => { cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { integrationManagerUrl = url; }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { cy.window().then((win) => { win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); @@ -136,7 +136,7 @@ describe("Integration Manager: Read Events", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); cy.stopWebServers(); }); diff --git a/cypress/e2e/integration-manager/send_event.spec.ts b/cypress/e2e/integration-manager/send_event.spec.ts index 7b706b047d..ac412e2468 100644 --- a/cypress/e2e/integration-manager/send_event.spec.ts +++ b/cypress/e2e/integration-manager/send_event.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; const ROOM_NAME = "Integration Manager Test"; @@ -93,17 +93,17 @@ function sendActionFromIntegrationManager( describe("Integration Manager: Send Event", () => { let testUser: UserCredentials; - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let integrationManagerUrl: string; beforeEach(() => { cy.serveHtmlFile(INTEGRATION_MANAGER_HTML).then((url) => { integrationManagerUrl = url; }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, USER_DISPLAY_NAME, () => { + cy.initTestUser(homeserver, USER_DISPLAY_NAME, () => { cy.window().then((win) => { win.localStorage.setItem("mx_scalar_token", INTEGRATION_MANAGER_TOKEN); win.localStorage.setItem(`mx_scalar_token_at_${integrationManagerUrl}`, INTEGRATION_MANAGER_TOKEN); @@ -142,7 +142,7 @@ describe("Integration Manager: Send Event", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); cy.stopWebServers(); }); diff --git a/cypress/e2e/lazy-loading/lazy-loading.spec.ts b/cypress/e2e/lazy-loading/lazy-loading.spec.ts index 4c6ce14dfe..e174364aeb 100644 --- a/cypress/e2e/lazy-loading/lazy-loading.spec.ts +++ b/cypress/e2e/lazy-loading/lazy-loading.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { MatrixClient } from "../../global"; import Chainable = Cypress.Chainable; @@ -26,7 +26,7 @@ interface Charly { } describe("Lazy Loading", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let bob: MatrixClient; const charlies: Charly[] = []; @@ -35,12 +35,12 @@ describe("Lazy Loading", () => { win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Alice"); + cy.initTestUser(homeserver, "Alice"); - cy.getBot(synapse, { + cy.getBot(homeserver, { displayName: "Bob", startClient: false, autoAcceptInvites: false, @@ -50,7 +50,7 @@ describe("Lazy Loading", () => { for (let i = 1; i <= 10; i++) { const displayName = `Charly #${i}`; - cy.getBot(synapse, { + cy.getBot(homeserver, { displayName, startClient: false, autoAcceptInvites: false, @@ -62,7 +62,7 @@ describe("Lazy Loading", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); const name = "Lazy Loading Test"; diff --git a/cypress/e2e/location/location.spec.ts b/cypress/e2e/location/location.spec.ts index 614c88cf8b..0d512705a0 100644 --- a/cypress/e2e/location/location.spec.ts +++ b/cypress/e2e/location/location.spec.ts @@ -16,11 +16,11 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; describe("Location sharing", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; const selectLocationShareTypeOption = (shareType: string): Chainable => { return cy.get(`[data-test-id="share-location-option-${shareType}"]`); @@ -34,15 +34,15 @@ describe("Location sharing", () => { cy.window().then((win) => { win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Tom"); + cy.initTestUser(homeserver, "Tom"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("sends and displays pin drop location message successfully", () => { diff --git a/cypress/e2e/login/consent.spec.ts b/cypress/e2e/login/consent.spec.ts index dc62ca6060..6175300567 100644 --- a/cypress/e2e/login/consent.spec.ts +++ b/cypress/e2e/login/consent.spec.ts @@ -18,21 +18,21 @@ limitations under the License. import { SinonStub } from "cypress/types/sinon"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("Consent", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("consent").then((data) => { - synapse = data; + cy.startHomeserver("consent").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Bob"); + cy.initTestUser(homeserver, "Bob"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should prompt the user to consent to terms when server deems it necessary", () => { @@ -53,8 +53,8 @@ describe("Consent", () => { cy.get("@windowOpen").then((stub) => { const url = stub.getCall(0).args[0]; - // Go to Synapse's consent page and accept it - cy.origin(synapse.baseUrl, { args: { url } }, ({ url }) => { + // Go to Homeserver's consent page and accept it + cy.origin(homeserver.baseUrl, { args: { url } }, ({ url }) => { cy.visit(url); cy.get('[type="submit"]').click(); diff --git a/cypress/e2e/login/login.spec.ts b/cypress/e2e/login/login.spec.ts index de75c28a2e..59d9c499ce 100644 --- a/cypress/e2e/login/login.spec.ts +++ b/cypress/e2e/login/login.spec.ts @@ -16,17 +16,17 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("Login", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { cy.stubDefaultServer(); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); describe("m.login.password", () => { @@ -34,9 +34,9 @@ describe("Login", () => { const password = "p4s5W0rD"; beforeEach(() => { - cy.startSynapse("consent").then((data) => { - synapse = data; - cy.registerUser(synapse, username, password); + cy.startHomeserver("consent").then((data) => { + homeserver = data; + cy.registerUser(homeserver, username, password); cy.visit("/#/login"); }); }); @@ -49,7 +49,7 @@ describe("Login", () => { cy.checkA11y(); cy.get(".mx_ServerPicker_change").click(); - cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(homeserver.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); // wait for the dialog to go away cy.get(".mx_ServerPickerDialog").should("not.exist"); @@ -64,9 +64,9 @@ describe("Login", () => { describe("logout", () => { beforeEach(() => { - cy.startSynapse("consent").then((data) => { - synapse = data; - cy.initTestUser(synapse, "Erin"); + cy.startHomeserver("consent").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Erin"); }); }); diff --git a/cypress/e2e/polls/polls.spec.ts b/cypress/e2e/polls/polls.spec.ts index c092d4f647..477c195ac3 100644 --- a/cypress/e2e/polls/polls.spec.ts +++ b/cypress/e2e/polls/polls.spec.ts @@ -18,14 +18,14 @@ limitations under the License. import { PollResponseEvent } from "matrix-events-sdk"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { MatrixClient } from "../../global"; import Chainable = Cypress.Chainable; const hideTimestampCSS = ".mx_MessageTimestamp { visibility: hidden !important; }"; describe("Polls", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; type CreatePollOptions = { title: string; @@ -81,20 +81,20 @@ describe("Polls", () => { cy.window().then((win) => { win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Tom"); + cy.initTestUser(homeserver, "Tom"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should be creatable and votable", () => { let bot: MatrixClient; - cy.getBot(synapse, { displayName: "BotBob" }).then((_bot) => { + cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { bot = _bot; }); @@ -163,7 +163,7 @@ describe("Polls", () => { it("should be editable from context menu if no votes have been cast", () => { let bot: MatrixClient; - cy.getBot(synapse, { displayName: "BotBob" }).then((_bot) => { + cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { bot = _bot; }); @@ -206,7 +206,7 @@ describe("Polls", () => { it("should not be editable from context menu if votes have been cast", () => { let bot: MatrixClient; - cy.getBot(synapse, { displayName: "BotBob" }).then((_bot) => { + cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { bot = _bot; }); @@ -256,10 +256,10 @@ describe("Polls", () => { it("should be displayed correctly in thread panel", () => { let botBob: MatrixClient; let botCharlie: MatrixClient; - cy.getBot(synapse, { displayName: "BotBob" }).then((_bot) => { + cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { botBob = _bot; }); - cy.getBot(synapse, { displayName: "BotCharlie" }).then((_bot) => { + cy.getBot(homeserver, { displayName: "BotCharlie" }).then((_bot) => { botCharlie = _bot; }); diff --git a/cypress/e2e/register/register.spec.ts b/cypress/e2e/register/register.spec.ts index df5eafb6a0..f6ea4379be 100644 --- a/cypress/e2e/register/register.spec.ts +++ b/cypress/e2e/register/register.spec.ts @@ -16,21 +16,21 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("Registration", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { cy.stubDefaultServer(); cy.visit("/#/register"); - cy.startSynapse("consent").then((data) => { - synapse = data; + cy.startHomeserver("consent").then((data) => { + homeserver = data; }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("registers an account and lands on the home screen", () => { @@ -42,13 +42,13 @@ describe("Registration", () => { cy.get(".mx_Dialog").percySnapshotElement("Server Picker", { widths: [516] }); cy.checkA11y(); - cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(homeserver.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); // wait for the dialog to go away cy.get(".mx_ServerPickerDialog").should("not.exist"); cy.get("#mx_RegistrationForm_username").should("be.visible"); - // Hide the server text as it contains the randomly allocated Synapse port + // Hide the server text as it contains the randomly allocated Homeserver port const percyCSS = ".mx_ServerPicker_server { visibility: hidden !important; }"; cy.percySnapshot("Registration", { percyCSS }); cy.checkA11y(); @@ -88,7 +88,7 @@ describe("Registration", () => { it("should require username to fulfil requirements and be available", () => { cy.get(".mx_ServerPicker_change", { timeout: 15000 }).click(); cy.get(".mx_ServerPickerDialog_continue").should("be.visible"); - cy.get(".mx_ServerPickerDialog_otherHomeserver").type(synapse.baseUrl); + cy.get(".mx_ServerPickerDialog_otherHomeserver").type(homeserver.baseUrl); cy.get(".mx_ServerPickerDialog_continue").click(); // wait for the dialog to go away cy.get(".mx_ServerPickerDialog").should("not.exist"); diff --git a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts b/cypress/e2e/regression-tests/pills-click-in-app.spec.ts index 4e48426997..301da254da 100644 --- a/cypress/e2e/regression-tests/pills-click-in-app.spec.ts +++ b/cypress/e2e/regression-tests/pills-click-in-app.spec.ts @@ -16,21 +16,21 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("Pills", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Sally"); + cy.initTestUser(homeserver, "Sally"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should navigate clicks internally to the app", () => { diff --git a/cypress/e2e/right-panel/right-panel.spec.ts b/cypress/e2e/right-panel/right-panel.spec.ts index 84e9db9ac6..6ada7f41d4 100644 --- a/cypress/e2e/right-panel/right-panel.spec.ts +++ b/cypress/e2e/right-panel/right-panel.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; const ROOM_NAME = "Test room"; @@ -43,12 +43,12 @@ const checkRoomSummaryCard = (name: string): Chainable> => { }; describe("RightPanel", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, NAME).then(() => + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, NAME).then(() => cy.window({ log: false }).then(() => { cy.createRoom({ name: ROOM_NAME }); cy.createSpace({ name: SPACE_NAME }); @@ -58,7 +58,7 @@ describe("RightPanel", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); describe("in rooms", () => { diff --git a/cypress/e2e/room-directory/room-directory.spec.ts b/cypress/e2e/room-directory/room-directory.spec.ts index b0fec151a7..fe4474d31e 100644 --- a/cypress/e2e/room-directory/room-directory.spec.ts +++ b/cypress/e2e/room-directory/room-directory.spec.ts @@ -16,23 +16,23 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { MatrixClient } from "../../global"; describe("Room Directory", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Ray"); - cy.getBot(synapse, { displayName: "Paul" }).as("bot"); + cy.initTestUser(homeserver, "Ray"); + cy.getBot(homeserver, { displayName: "Paul" }).as("bot"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should allow admin to add alias & publish room to directory", () => { diff --git a/cypress/e2e/room/room.spec.ts b/cypress/e2e/room/room.spec.ts index 1d09475166..a8a3a9a7e6 100644 --- a/cypress/e2e/room/room.spec.ts +++ b/cypress/e2e/room/room.spec.ts @@ -18,34 +18,34 @@ limitations under the License. import { EventType } from "matrix-js-sdk/src/@types/event"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { MatrixClient } from "../../global"; describe("Room Directory", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Alice"); + cy.initTestUser(homeserver, "Alice"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should switch between existing dm rooms without a loader", () => { let bobClient: MatrixClient; let charlieClient: MatrixClient; - cy.getBot(synapse, { + cy.getBot(homeserver, { displayName: "Bob", }).then((bob) => { bobClient = bob; }); - cy.getBot(synapse, { + cy.getBot(homeserver, { displayName: "Charlie", }).then((charlie) => { charlieClient = charlie; diff --git a/cypress/e2e/settings/device-management.spec.ts b/cypress/e2e/settings/device-management.spec.ts index 32e99faf77..af0cdf6c2a 100644 --- a/cypress/e2e/settings/device-management.spec.ts +++ b/cypress/e2e/settings/device-management.spec.ts @@ -16,34 +16,34 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import type { UserCredentials } from "../../support/login"; describe("Device manager", () => { - let synapse: SynapseInstance | undefined; + let homeserver: HomeserverInstance | undefined; let user: UserCredentials | undefined; beforeEach(() => { cy.enableLabsFeature("feature_new_device_manager"); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Alice") + cy.initTestUser(homeserver, "Alice") .then((credentials) => { user = credentials; }) .then(() => { // create some extra sessions to manage - return cy.loginUser(synapse, user.username, user.password); + return cy.loginUser(homeserver, user.username, user.password); }) .then(() => { - return cy.loginUser(synapse, user.username, user.password); + return cy.loginUser(homeserver, user.username, user.password); }); }); }); afterEach(() => { - cy.stopSynapse(synapse!); + cy.stopHomeserver(homeserver!); }); it("should display sessions", () => { diff --git a/cypress/e2e/settings/hidden-rr-migration.spec.ts b/cypress/e2e/settings/hidden-rr-migration.spec.ts index 8fd9482310..729bf7ebd7 100644 --- a/cypress/e2e/settings/hidden-rr-migration.spec.ts +++ b/cypress/e2e/settings/hidden-rr-migration.spec.ts @@ -16,10 +16,10 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; -function seedLabs(synapse: SynapseInstance, labsVal: boolean | null): void { - cy.initTestUser(synapse, "Sally", () => { +function seedLabs(homeserver: HomeserverInstance, labsVal: boolean | null): void { + cy.initTestUser(homeserver, "Sally", () => { // seed labs flag cy.window({ log: false }).then((win) => { if (typeof labsVal === "boolean") { @@ -61,30 +61,30 @@ describe("Hidden Read Receipts Setting Migration", () => { // For a security-sensitive feature like hidden read receipts, it's absolutely vital // that we migrate the setting appropriately. - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should not migrate the lack of a labs flag", () => { - seedLabs(synapse, null); + seedLabs(homeserver, null); testForVal(null); }); it("should migrate labsHiddenRR=false as sendRR=true", () => { - seedLabs(synapse, false); + seedLabs(homeserver, false); testForVal(true); }); it("should migrate labsHiddenRR=true as sendRR=false", () => { - seedLabs(synapse, true); + seedLabs(homeserver, true); testForVal(false); }); }); diff --git a/cypress/e2e/sliding-sync/sliding-sync.ts b/cypress/e2e/sliding-sync/sliding-sync.ts index 3d9f9b35f9..1b2642eeb4 100644 --- a/cypress/e2e/sliding-sync/sliding-sync.ts +++ b/cypress/e2e/sliding-sync/sliding-sync.ts @@ -20,43 +20,45 @@ import _ from "lodash"; import { MatrixClient } from "matrix-js-sdk/src/matrix"; import { Interception } from "cypress/types/net-stubbing"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import { ProxyInstance } from "../../plugins/sliding-sync"; describe("Sliding Sync", () => { beforeEach(() => { - cy.startSynapse("default") - .as("synapse") - .then((synapse) => { - cy.startProxy(synapse).as("proxy"); + cy.startHomeserver("default") + .as("homeserver") + .then((homeserver) => { + cy.startProxy(homeserver).as("proxy"); }); - cy.all([cy.get("@synapse"), cy.get("@proxy")]).then(([synapse, proxy]) => { - cy.enableLabsFeature("feature_sliding_sync"); + cy.all([cy.get("@homeserver"), cy.get("@proxy")]).then( + ([homeserver, proxy]) => { + cy.enableLabsFeature("feature_sliding_sync"); - cy.intercept("/config.json?cachebuster=*", (req) => { - return req.continue((res) => { - res.send(200, { - ...res.body, - setting_defaults: { - feature_sliding_sync_proxy_url: `http://localhost:${proxy.port}`, - }, + cy.intercept("/config.json?cachebuster=*", (req) => { + return req.continue((res) => { + res.send(200, { + ...res.body, + setting_defaults: { + feature_sliding_sync_proxy_url: `http://localhost:${proxy.port}`, + }, + }); }); }); - }); - cy.initTestUser(synapse, "Sloth").then(() => { - return cy.window({ log: false }).then(() => { - cy.createRoom({ name: "Test Room" }).as("roomId"); + cy.initTestUser(homeserver, "Sloth").then(() => { + return cy.window({ log: false }).then(() => { + cy.createRoom({ name: "Test Room" }).as("roomId"); + }); }); - }); - }); + }, + ); }); afterEach(() => { - cy.get("@synapse").then(cy.stopSynapse); + cy.get("@homeserver").then(cy.stopHomeserver); cy.get("@proxy").then(cy.stopProxy); }); @@ -84,9 +86,9 @@ describe("Sliding Sync", () => { }; const createAndJoinBob = () => { // create a Bob user - cy.get("@synapse").then((synapse) => { + cy.get("@homeserver").then((homeserver) => { return cy - .getBot(synapse, { + .getBot(homeserver, { displayName: "Bob", }) .as("bob"); diff --git a/cypress/e2e/spaces/spaces.spec.ts b/cypress/e2e/spaces/spaces.spec.ts index 47232dc5af..f07746c0f5 100644 --- a/cypress/e2e/spaces/spaces.spec.ts +++ b/cypress/e2e/spaces/spaces.spec.ts @@ -18,7 +18,7 @@ limitations under the License. import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; import { UserCredentials } from "../../support/login"; @@ -59,14 +59,14 @@ function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state" } describe("Spaces", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let user: UserCredentials; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Sue").then((_user) => { + cy.initTestUser(homeserver, "Sue").then((_user) => { user = _user; cy.mockClipboard(); }); @@ -74,7 +74,7 @@ describe("Spaces", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it.only("should allow user to create public space", () => { @@ -173,7 +173,7 @@ describe("Spaces", () => { it("should allow user to invite another to a space", () => { let bot: MatrixClient; - cy.getBot(synapse, { displayName: "BotBob" }).then((_bot) => { + cy.getBot(homeserver, { displayName: "BotBob" }).then((_bot) => { bot = _bot; }); @@ -208,7 +208,7 @@ describe("Spaces", () => { }); cy.getSpacePanelButton("My Space").should("exist"); - cy.getBot(synapse, { displayName: "BotBob" }).then({ timeout: 10000 }, async (bot) => { + cy.getBot(homeserver, { displayName: "BotBob" }).then({ timeout: 10000 }, async (bot) => { const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space")); await bot.invite(roomId, user.userId); }); diff --git a/cypress/e2e/spotlight/spotlight.spec.ts b/cypress/e2e/spotlight/spotlight.spec.ts index 9b89365714..d9ead17bb3 100644 --- a/cypress/e2e/spotlight/spotlight.spec.ts +++ b/cypress/e2e/spotlight/spotlight.spec.ts @@ -17,7 +17,7 @@ limitations under the License. /// import { MatrixClient } from "../../global"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; import Loggable = Cypress.Loggable; import Timeoutable = Cypress.Timeoutable; @@ -136,7 +136,7 @@ Cypress.Commands.add("startDM", (name: string) => { }); describe("Spotlight", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; const bot1Name = "BotBob"; let bot1: MatrixClient; @@ -154,16 +154,16 @@ describe("Spotlight", () => { let room3Id: string; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, "Jim") + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Jim") .then(() => - cy.getBot(synapse, { displayName: bot1Name }).then((_bot1) => { + cy.getBot(homeserver, { displayName: bot1Name }).then((_bot1) => { bot1 = _bot1; }), ) .then(() => - cy.getBot(synapse, { displayName: bot2Name }).then((_bot2) => { + cy.getBot(homeserver, { displayName: bot2Name }).then((_bot2) => { // eslint-disable-next-line @typescript-eslint/no-unused-vars bot2 = _bot2; }), @@ -205,7 +205,7 @@ describe("Spotlight", () => { afterEach(() => { cy.visit("/#/home"); - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should be able to add and remove filters via keyboard", () => { diff --git a/cypress/e2e/threads/threads.spec.ts b/cypress/e2e/threads/threads.spec.ts index 7b616bd13f..0de87ea9cd 100644 --- a/cypress/e2e/threads/threads.spec.ts +++ b/cypress/e2e/threads/threads.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { MatrixClient } from "../../global"; function markWindowBeforeReload(): void { @@ -25,7 +25,7 @@ function markWindowBeforeReload(): void { } describe("Threads", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { // Default threads to ON for this spec @@ -33,15 +33,15 @@ describe("Threads", () => { cy.window().then((win) => { win.localStorage.setItem("mx_lhs_size", "0"); // Collapse left panel for these tests }); - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Tom"); + cy.initTestUser(homeserver, "Tom"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should reload when enabling threads beta", () => { @@ -75,7 +75,7 @@ describe("Threads", () => { it("should be usable for a conversation", () => { let bot: MatrixClient; - cy.getBot(synapse, { + cy.getBot(homeserver, { displayName: "BotBob", autoAcceptInvites: false, }).then((_bot) => { diff --git a/cypress/e2e/timeline/timeline.spec.ts b/cypress/e2e/timeline/timeline.spec.ts index 90406eae02..17b763a089 100644 --- a/cypress/e2e/timeline/timeline.spec.ts +++ b/cypress/e2e/timeline/timeline.spec.ts @@ -18,7 +18,7 @@ limitations under the License. import type { ISendEventResponse } from "matrix-js-sdk/src/@types/requests"; import type { EventType, MsgType } from "matrix-js-sdk/src/@types/event"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { SettingLevel } from "../../../src/settings/SettingLevel"; import { Layout } from "../../../src/settings/enums/Layout"; import Chainable = Cypress.Chainable; @@ -67,7 +67,7 @@ const sendEvent = (roomId: string, html = false): Chainable }; describe("Timeline", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let roomId: string; @@ -75,9 +75,9 @@ describe("Timeline", () => { let newAvatarUrl: string; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, OLD_NAME).then(() => + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, OLD_NAME).then(() => cy.createRoom({ name: ROOM_NAME }).then((_room1Id) => { roomId = _room1Id; }), @@ -86,7 +86,7 @@ describe("Timeline", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); describe("useOnlyCurrentProfiles", () => { diff --git a/cypress/e2e/toasts/analytics-toast.ts b/cypress/e2e/toasts/analytics-toast.ts index 4c9cbed02f..4cc8baa838 100644 --- a/cypress/e2e/toasts/analytics-toast.ts +++ b/cypress/e2e/toasts/analytics-toast.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import Chainable = Cypress.Chainable; function assertNoToasts(): void { @@ -40,10 +40,10 @@ function rejectToast(expectedTitle: string): void { } describe("Analytics Toast", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should not show an analytics toast if config has nothing about posthog", () => { @@ -55,9 +55,9 @@ describe("Analytics Toast", () => { }); }); - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, "Tod"); + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Tod"); }); rejectToast("Notifications"); @@ -78,9 +78,9 @@ describe("Analytics Toast", () => { }); }); - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, "Tod"); + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Tod"); rejectToast("Notifications"); }); }); diff --git a/cypress/e2e/update/update.spec.ts b/cypress/e2e/update/update.spec.ts index eaf039b6ea..99a8fb32a9 100644 --- a/cypress/e2e/update/update.spec.ts +++ b/cypress/e2e/update/update.spec.ts @@ -16,19 +16,19 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("Update", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should navigate to ?updated=$VERSION if realises it is immediately out of date on load", () => { @@ -42,7 +42,7 @@ describe("Update", () => { }, }).as("version"); - cy.initTestUser(synapse, "Ursa"); + cy.initTestUser(homeserver, "Ursa"); cy.wait("@version"); cy.url() diff --git a/cypress/e2e/user-menu/user-menu.spec.ts b/cypress/e2e/user-menu/user-menu.spec.ts index 841dc82e85..bec1821e1b 100644 --- a/cypress/e2e/user-menu/user-menu.spec.ts +++ b/cypress/e2e/user-menu/user-menu.spec.ts @@ -16,25 +16,25 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import type { UserCredentials } from "../../support/login"; describe("User Menu", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let user: UserCredentials; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Jeff").then((credentials) => { + cy.initTestUser(homeserver, "Jeff").then((credentials) => { user = credentials; }); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should contain our name & userId", () => { diff --git a/cypress/e2e/user-onboarding/user-onboarding-new.ts b/cypress/e2e/user-onboarding/user-onboarding-new.ts index a3975cb5ac..b4b6c83105 100644 --- a/cypress/e2e/user-onboarding/user-onboarding-new.ts +++ b/cypress/e2e/user-onboarding/user-onboarding-new.ts @@ -17,18 +17,18 @@ limitations under the License. /// import { MatrixClient } from "../../global"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("User Onboarding (new user)", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; const bot1Name = "BotBob"; let bot1: MatrixClient; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, "Jane Doe"); + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Jane Doe"); cy.window({ log: false }).then((win) => { win.localStorage.setItem("mx_registration_time", "1656633601"); }); @@ -36,7 +36,7 @@ describe("User Onboarding (new user)", () => { // wait for the app to load return cy.get(".mx_MatrixChat", { timeout: 15000 }); }); - cy.getBot(synapse, { displayName: bot1Name }).then((_bot1) => { + cy.getBot(homeserver, { displayName: bot1Name }).then((_bot1) => { bot1 = _bot1; }); cy.get(".mx_UserOnboardingPage").should("exist"); @@ -51,7 +51,7 @@ describe("User Onboarding (new user)", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("page is shown and preference exists", () => { diff --git a/cypress/e2e/user-onboarding/user-onboarding-old.ts b/cypress/e2e/user-onboarding/user-onboarding-old.ts index 90ae73b257..7f0c2b7612 100644 --- a/cypress/e2e/user-onboarding/user-onboarding-old.ts +++ b/cypress/e2e/user-onboarding/user-onboarding-old.ts @@ -16,15 +16,15 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; describe("User Onboarding (old user)", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; - cy.initTestUser(synapse, "Jane Doe"); + cy.startHomeserver("default").then((data) => { + homeserver = data; + cy.initTestUser(homeserver, "Jane Doe"); cy.window({ log: false }).then((win) => { win.localStorage.setItem("mx_registration_time", "2"); }); @@ -37,7 +37,7 @@ describe("User Onboarding (old user)", () => { afterEach(() => { cy.visit("/#/home"); - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("page and preference are hidden", () => { diff --git a/cypress/e2e/user-view/user-view.spec.ts b/cypress/e2e/user-view/user-view.spec.ts index df7bd933ae..529ef16cf1 100644 --- a/cypress/e2e/user-view/user-view.spec.ts +++ b/cypress/e2e/user-view/user-view.spec.ts @@ -16,23 +16,23 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { MatrixClient } from "../../global"; describe("UserView", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Violet"); - cy.getBot(synapse, { displayName: "Usman" }).as("bot"); + cy.initTestUser(homeserver, "Violet"); + cy.getBot(homeserver, { displayName: "Usman" }).as("bot"); }); }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); }); it("should render the user view as expected", () => { diff --git a/cypress/e2e/widgets/layout.spec.ts b/cypress/e2e/widgets/layout.spec.ts index 886b3062c8..16bee8d222 100644 --- a/cypress/e2e/widgets/layout.spec.ts +++ b/cypress/e2e/widgets/layout.spec.ts @@ -17,7 +17,7 @@ limitations under the License. import { IWidget } from "matrix-widget-api"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; const ROOM_NAME = "Test Room"; const WIDGET_ID = "fake-widget"; @@ -34,14 +34,14 @@ const WIDGET_HTML = ` describe("Widget Layout", () => { let widgetUrl: string; - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let roomId: string; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Sally"); + cy.initTestUser(homeserver, "Sally"); }); cy.serveHtmlFile(WIDGET_HTML).then((url) => { widgetUrl = url; @@ -91,7 +91,7 @@ describe("Widget Layout", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); cy.stopWebServers(); }); diff --git a/cypress/e2e/widgets/stickers.spec.ts b/cypress/e2e/widgets/stickers.spec.ts index c714b84416..5c016b406a 100644 --- a/cypress/e2e/widgets/stickers.spec.ts +++ b/cypress/e2e/widgets/stickers.spec.ts @@ -16,7 +16,7 @@ limitations under the License. /// -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; const STICKER_PICKER_WIDGET_ID = "fake-sticker-picker"; const STICKER_PICKER_WIDGET_NAME = "Fake Stickers"; @@ -102,13 +102,13 @@ describe("Stickers", () => { // See sendStickerFromPicker() for more detail on iframe comms. let stickerPickerUrl: string; - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Sally"); + cy.initTestUser(homeserver, "Sally"); }); cy.serveHtmlFile(WIDGET_HTML).then((url) => { stickerPickerUrl = url; @@ -116,7 +116,7 @@ describe("Stickers", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); cy.stopWebServers(); }); diff --git a/cypress/e2e/widgets/widget-pip-close.spec.ts b/cypress/e2e/widgets/widget-pip-close.spec.ts index 59376d8572..ca717947d0 100644 --- a/cypress/e2e/widgets/widget-pip-close.spec.ts +++ b/cypress/e2e/widgets/widget-pip-close.spec.ts @@ -20,7 +20,7 @@ limitations under the License. import { IWidget } from "matrix-widget-api/src/interfaces/IWidget"; import type { MatrixClient, MatrixEvent } from "matrix-js-sdk/src/matrix"; -import { SynapseInstance } from "../../plugins/synapsedocker"; +import { HomeserverInstance } from "../../plugins/utils/homeserver"; import { UserCredentials } from "../../support/login"; const DEMO_WIDGET_ID = "demo-widget-id"; @@ -90,7 +90,7 @@ function waitForRoomWidget(win: Cypress.AUTWindow, widgetId: string, roomId: str } describe("Widget PIP", () => { - let synapse: SynapseInstance; + let homeserver: HomeserverInstance; let user: UserCredentials; let bot: MatrixClient; let demoWidgetUrl: string; @@ -173,13 +173,13 @@ describe("Widget PIP", () => { } beforeEach(() => { - cy.startSynapse("default").then((data) => { - synapse = data; + cy.startHomeserver("default").then((data) => { + homeserver = data; - cy.initTestUser(synapse, "Mike").then((_user) => { + cy.initTestUser(homeserver, "Mike").then((_user) => { user = _user; }); - cy.getBot(synapse, { displayName: "Bot", autoAcceptInvites: false }).then((_bot) => { + cy.getBot(homeserver, { displayName: "Bot", autoAcceptInvites: false }).then((_bot) => { bot = _bot; }); }); @@ -189,7 +189,7 @@ describe("Widget PIP", () => { }); afterEach(() => { - cy.stopSynapse(synapse); + cy.stopHomeserver(homeserver); cy.stopWebServers(); }); diff --git a/cypress/plugins/dendritedocker/index.ts b/cypress/plugins/dendritedocker/index.ts new file mode 100644 index 0000000000..43791d9bae --- /dev/null +++ b/cypress/plugins/dendritedocker/index.ts @@ -0,0 +1,181 @@ +/* +Copyright 2023 The Matrix.org Foundation C.I.C. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. +*/ + +/// + +import * as path from "path"; +import * as os from "os"; +import * as crypto from "crypto"; +import * as fse from "fs-extra"; + +import PluginEvents = Cypress.PluginEvents; +import PluginConfigOptions = Cypress.PluginConfigOptions; +import { getFreePort } from "../utils/port"; +import { dockerExec, dockerLogs, dockerRun, dockerStop } from "../docker"; +import { HomeserverConfig, HomeserverInstance } from "../utils/homeserver"; + +// A cypress plugins to add command to start & stop dendrites in +// docker with preset templates. + +const dendrites = new Map(); + +function randB64Bytes(numBytes: number): string { + return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); +} + +async function cfgDirFromTemplate(template: string): Promise { + template = "default"; + const templateDir = path.join(__dirname, "templates", template); + const configFile = "dendrite.yaml"; + + const stats = await fse.stat(templateDir); + if (!stats?.isDirectory) { + throw new Error(`No such template: ${template}`); + } + const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), "react-sdk-dendritedocker-")); + + // copy the contents of the template dir, omitting homeserver.yaml as we'll template that + console.log(`Copy ${templateDir} -> ${tempDir}`); + await fse.copy(templateDir, tempDir, { filter: (f) => path.basename(f) !== configFile }); + + const registrationSecret = randB64Bytes(16); + + const port = await getFreePort(); + const baseUrl = `http://localhost:${port}`; + + // now copy homeserver.yaml, applying substitutions + console.log(`Gen ${path.join(templateDir, configFile)}`); + let hsYaml = await fse.readFile(path.join(templateDir, configFile), "utf8"); + hsYaml = hsYaml.replace(/{{REGISTRATION_SECRET}}/g, registrationSecret); + await fse.writeFile(path.join(tempDir, configFile), hsYaml); + + await dockerRun({ + image: "matrixdotorg/dendrite-monolith:main", + params: ["--rm", "--entrypoint=", "-v", `${tempDir}:/mnt`], + containerName: `react-sdk-cypress-dendrite-keygen`, + cmd: ["/usr/bin/generate-keys", "-private-key", "/mnt/matrix_key.pem"], + }); + + return { + port, + baseUrl, + configDir: tempDir, + registrationSecret, + }; +} + +// Start a dendrite instance: the template must be the name of +// one of the templates in the cypress/plugins/dendritedocker/templates +// directory +async function dendriteStart(template: string): Promise { + const denCfg = await cfgDirFromTemplate(template); + + console.log(`Starting dendrite with config dir ${denCfg.configDir}...`); + + const dendriteId = await dockerRun({ + image: "matrixdotorg/dendrite-monolith:main", + params: [ + "--rm", + "-v", + `${denCfg.configDir}:/etc/dendrite`, + "-p", + `${denCfg.port}:8008/tcp`, + "--entrypoint", + "/usr/bin/dendrite-monolith-server", + ], + containerName: `react-sdk-cypress-dendrite`, + cmd: ["--really-enable-open-registration", "true", "run"], + }); + + console.log(`Started dendrite with id ${dendriteId} on port ${denCfg.port}.`); + + // Await Dendrite healthcheck + await dockerExec({ + containerId: dendriteId, + params: [ + "curl", + "--connect-timeout", + "30", + "--retry", + "30", + "--retry-delay", + "1", + "--retry-all-errors", + "--silent", + "http://localhost:8008/_matrix/client/versions", + ], + }); + + const dendrite: HomeserverInstance = { serverId: dendriteId, ...denCfg }; + dendrites.set(dendriteId, dendrite); + return dendrite; +} + +async function dendriteStop(id: string): Promise { + const denCfg = dendrites.get(id); + + if (!denCfg) throw new Error("Unknown dendrite ID"); + + const dendriteLogsPath = path.join("cypress", "dendritelogs", id); + await fse.ensureDir(dendriteLogsPath); + + await dockerLogs({ + containerId: id, + stdoutFile: path.join(dendriteLogsPath, "stdout.log"), + stderrFile: path.join(dendriteLogsPath, "stderr.log"), + }); + + await dockerStop({ + containerId: id, + }); + + await fse.remove(denCfg.configDir); + + dendrites.delete(id); + + console.log(`Stopped dendrite id ${id}.`); + // cypress deliberately fails if you return 'undefined', so + // return null to signal all is well, and we've handled the task. + return null; +} + +/** + * @type {Cypress.PluginConfig} + */ +export function dendriteDocker(on: PluginEvents, config: PluginConfigOptions) { + on("task", { + dendriteStart, + dendriteStop, + }); + + on("after:spec", async (spec) => { + // Cleans up any remaining dendrite instances after a spec run + // This is on the theory that we should avoid re-using dendrite + // instances between spec runs: they should be cheap enough to + // start that we can have a separate one for each spec run or even + // test. If we accidentally re-use dendrites, we could inadvertently + // make our tests depend on each other. + for (const denId of dendrites.keys()) { + console.warn(`Cleaning up dendrite ID ${denId} after ${spec.name}`); + await dendriteStop(denId); + } + }); + + on("before:run", async () => { + // tidy up old dendrite log files before each run + await fse.emptyDir(path.join("cypress", "dendritelogs")); + }); +} diff --git a/cypress/plugins/dendritedocker/templates/default/dendrite.yaml b/cypress/plugins/dendritedocker/templates/default/dendrite.yaml new file mode 100644 index 0000000000..8af5854d6c --- /dev/null +++ b/cypress/plugins/dendritedocker/templates/default/dendrite.yaml @@ -0,0 +1,374 @@ +# This is the Dendrite configuration file. +# +# The configuration is split up into sections - each Dendrite component has a +# configuration section, in addition to the "global" section which applies to +# all components. + +# The version of the configuration file. +version: 2 + +# Global Matrix configuration. This configuration applies to all components. +global: + # The domain name of this homeserver. + server_name: localhost + + # The path to the signing private key file, used to sign requests and events. + # Note that this is NOT the same private key as used for TLS! To generate a + # signing key, use "./bin/generate-keys --private-key matrix_key.pem". + private_key: matrix_key.pem + + # The paths and expiry timestamps (as a UNIX timestamp in millisecond precision) + # to old signing keys that were formerly in use on this domain name. These + # keys will not be used for federation request or event signing, but will be + # provided to any other homeserver that asks when trying to verify old events. + old_private_keys: + # If the old private key file is available: + # - private_key: old_matrix_key.pem + # expired_at: 1601024554498 + # If only the public key (in base64 format) and key ID are known: + # - public_key: mn59Kxfdq9VziYHSBzI7+EDPDcBS2Xl7jeUdiiQcOnM= + # key_id: ed25519:mykeyid + # expired_at: 1601024554498 + + # How long a remote server can cache our server signing key before requesting it + # again. Increasing this number will reduce the number of requests made by other + # servers for our key but increases the period that a compromised key will be + # considered valid by other homeservers. + key_validity_period: 168h0m0s + + # Global database connection pool, for PostgreSQL monolith deployments only. If + # this section is populated then you can omit the "database" blocks in all other + # sections. For polylith deployments, or monolith deployments using SQLite databases, + # you must configure the "database" block for each component instead. + # database: + # connection_string: postgresql://username:password@hostname/dendrite?sslmode=disable + # max_open_conns: 90 + # max_idle_conns: 5 + # conn_max_lifetime: -1 + + # Configuration for in-memory caches. Caches can often improve performance by + # keeping frequently accessed items (like events, identifiers etc.) in memory + # rather than having to read them from the database. + cache: + # The estimated maximum size for the global cache in bytes, or in terabytes, + # gigabytes, megabytes or kilobytes when the appropriate 'tb', 'gb', 'mb' or + # 'kb' suffix is specified. Note that this is not a hard limit, nor is it a + # memory limit for the entire process. A cache that is too small may ultimately + # provide little or no benefit. + max_size_estimated: 1gb + + # The maximum amount of time that a cache entry can live for in memory before + # it will be evicted and/or refreshed from the database. Lower values result in + # easier admission of new cache entries but may also increase database load in + # comparison to higher values, so adjust conservatively. Higher values may make + # it harder for new items to make it into the cache, e.g. if new rooms suddenly + # become popular. + max_age: 1h + + # The server name to delegate server-server communications to, with optional port + # e.g. localhost:443 + well_known_server_name: "" + + # The server name to delegate client-server communications to, with optional port + # e.g. localhost:443 + well_known_client_name: "" + + # Lists of domains that the server will trust as identity servers to verify third + # party identifiers such as phone numbers and email addresses. + trusted_third_party_id_servers: + - matrix.org + - vector.im + + # Disables federation. Dendrite will not be able to communicate with other servers + # in the Matrix federation and the federation API will not be exposed. + disable_federation: false + + # Configures the handling of presence events. Inbound controls whether we receive + # presence events from other servers, outbound controls whether we send presence + # events for our local users to other servers. + presence: + enable_inbound: false + enable_outbound: false + + # Configures phone-home statistics reporting. These statistics contain the server + # name, number of active users and some information on your deployment config. + # We use this information to understand how Dendrite is being used in the wild. + report_stats: + enabled: false + endpoint: https://matrix.org/report-usage-stats/push + + # Server notices allows server admins to send messages to all users on the server. + server_notices: + enabled: false + # The local part, display name and avatar URL (as a mxc:// URL) for the user that + # will send the server notices. These are visible to all users on the deployment. + local_part: "_server" + display_name: "Server Alerts" + avatar_url: "" + # The room name to be used when sending server notices. This room name will + # appear in user clients. + room_name: "Server Alerts" + + # Configuration for NATS JetStream + jetstream: + # A list of NATS Server addresses to connect to. If none are specified, an + # internal NATS server will be started automatically when running Dendrite in + # monolith mode. For polylith deployments, it is required to specify the address + # of at least one NATS Server node. + addresses: + # - localhost:4222 + + # Disable the validation of TLS certificates of NATS. This is + # not recommended in production since it may allow NATS traffic + # to be sent to an insecure endpoint. + disable_tls_validation: false + + # Persistent directory to store JetStream streams in. This directory should be + # preserved across Dendrite restarts. + storage_path: ./ + + # The prefix to use for stream names for this homeserver - really only useful + # if you are running more than one Dendrite server on the same NATS deployment. + topic_prefix: Dendrite + + # Configuration for Prometheus metric collection. + metrics: + enabled: false + basic_auth: + username: metrics + password: metrics + + # Optional DNS cache. The DNS cache may reduce the load on DNS servers if there + # is no local caching resolver available for use. + dns_cache: + enabled: false + cache_size: 256 + cache_lifetime: "5m" # 5 minutes; https://pkg.go.dev/time@master#ParseDuration + +# Configuration for the Appservice API. +app_service_api: + # Disable the validation of TLS certificates of appservices. This is + # not recommended in production since it may allow appservice traffic + # to be sent to an insecure endpoint. + disable_tls_validation: false + + # Appservice configuration files to load into this homeserver. + config_files: + # - /path/to/appservice_registration.yaml + +# Configuration for the Client API. +client_api: + # Prevents new users from being able to register on this homeserver, except when + # using the registration shared secret below. + registration_disabled: false + + # Prevents new guest accounts from being created. Guest registration is also + # disabled implicitly by setting 'registration_disabled' above. + guests_disabled: true + + # If set, allows registration by anyone who knows the shared secret, regardless + # of whether registration is otherwise disabled. + registration_shared_secret: "{{REGISTRATION_SECRET}}" + + # Whether to require reCAPTCHA for registration. If you have enabled registration + # then this is HIGHLY RECOMMENDED to reduce the risk of your homeserver being used + # for coordinated spam attacks. + enable_registration_captcha: false + + # Settings for ReCAPTCHA. + recaptcha_public_key: "" + recaptcha_private_key: "" + recaptcha_bypass_secret: "" + + # To use hcaptcha.com instead of ReCAPTCHA, set the following parameters, otherwise just keep them empty. + # recaptcha_siteverify_api: "https://hcaptcha.com/siteverify" + # recaptcha_api_js_url: "https://js.hcaptcha.com/1/api.js" + # recaptcha_form_field: "h-captcha-response" + # recaptcha_sitekey_class: "h-captcha" + + # TURN server information that this homeserver should send to clients. + turn: + turn_user_lifetime: "5m" + turn_uris: + # - turn:turn.server.org?transport=udp + # - turn:turn.server.org?transport=tcp + turn_shared_secret: "" + # If your TURN server requires static credentials, then you will need to enter + # them here instead of supplying a shared secret. Note that these credentials + # will be visible to clients! + # turn_username: "" + # turn_password: "" + + # Settings for rate-limited endpoints. Rate limiting kicks in after the threshold + # number of "slots" have been taken by requests from a specific host. Each "slot" + # will be released after the cooloff time in milliseconds. Server administrators + # and appservice users are exempt from rate limiting by default. + rate_limiting: + enabled: true + threshold: 20 + cooloff_ms: 500 + exempt_user_ids: + # - "@user:domain.com" + +# Configuration for the Federation API. +federation_api: + # How many times we will try to resend a failed transaction to a specific server. The + # backoff is 2**x seconds, so 1 = 2 seconds, 2 = 4 seconds, 3 = 8 seconds etc. Once + # the max retries are exceeded, Dendrite will no longer try to send transactions to + # that server until it comes back to life and connects to us again. + send_max_retries: 16 + + # Disable the validation of TLS certificates of remote federated homeservers. Do not + # enable this option in production as it presents a security risk! + disable_tls_validation: false + + # Disable HTTP keepalives, which also prevents connection reuse. Dendrite will typically + # keep HTTP connections open to remote hosts for 5 minutes as they can be reused much + # more quickly than opening new connections each time. Disabling keepalives will close + # HTTP connections immediately after a successful request but may result in more CPU and + # memory being used on TLS handshakes for each new connection instead. + disable_http_keepalives: false + + # Perspective keyservers to use as a backup when direct key fetches fail. This may + # be required to satisfy key requests for servers that are no longer online when + # joining some rooms. + key_perspectives: + - server_name: matrix.org + keys: + - key_id: ed25519:auto + public_key: Noi6WqcDj0QmPxCNQqgezwTlBKrfqehY1u2FyWP9uYw + - key_id: ed25519:a_RXGa + public_key: l8Hft5qXKn1vfHrg3p4+W8gELQVo8N13JkluMfmn2sQ + + # This option will control whether Dendrite will prefer to look up keys directly + # or whether it should try perspective servers first, using direct fetches as a + # last resort. + prefer_direct_fetch: false + + database: + connection_string: file:dendrite-federationapi.db + +# Configuration for the Media API. +media_api: + # Storage path for uploaded media. May be relative or absolute. + base_path: ./media_store + + # The maximum allowed file size (in bytes) for media uploads to this homeserver + # (0 = unlimited). If using a reverse proxy, ensure it allows requests at least + #this large (e.g. the client_max_body_size setting in nginx). + max_file_size_bytes: 10485760 + + # Whether to dynamically generate thumbnails if needed. + dynamic_thumbnails: false + + # The maximum number of simultaneous thumbnail generators to run. + max_thumbnail_generators: 10 + + # A list of thumbnail sizes to be generated for media content. + thumbnail_sizes: + - width: 32 + height: 32 + method: crop + - width: 96 + height: 96 + method: crop + - width: 640 + height: 480 + method: scale + + database: + connection_string: file:dendrite-mediaapi.db + +# Configuration for enabling experimental MSCs on this homeserver. +mscs: + mscs: + # - msc2836 # (Threading, see https://github.com/matrix-org/matrix-doc/pull/2836) + # - msc2946 # (Spaces Summary, see https://github.com/matrix-org/matrix-doc/pull/2946) + + database: + connection_string: file:dendrite-msc.db + +# Configuration for the Sync API. +sync_api: + # This option controls which HTTP header to inspect to find the real remote IP + # address of the client. This is likely required if Dendrite is running behind + # a reverse proxy server. + # real_ip_header: X-Real-IP + + # Configuration for the full-text search engine. + search: + # Whether or not search is enabled. + enabled: false + + # The path where the search index will be created in. + index_path: "./searchindex" + + # The language most likely to be used on the server - used when indexing, to + # ensure the returned results match expectations. A full list of possible languages + # can be found at https://github.com/blevesearch/bleve/tree/master/analysis/lang + language: "en" + + database: + connection_string: file:dendrite-syncapi.db + +# Configuration for the User API. +user_api: + # The cost when hashing passwords on registration/login. Default: 10. Min: 4, Max: 31 + # See https://pkg.go.dev/golang.org/x/crypto/bcrypt for more information. + # Setting this lower makes registration/login consume less CPU resources at the cost + # of security should the database be compromised. Setting this higher makes registration/login + # consume more CPU resources but makes it harder to brute force password hashes. This value + # can be lowered if performing tests or on embedded Dendrite instances (e.g WASM builds). + bcrypt_cost: 10 + + # The length of time that a token issued for a relying party from + # /_matrix/client/r0/user/{userId}/openid/request_token endpoint + # is considered to be valid in milliseconds. + # The default lifetime is 3600000ms (60 minutes). + # openid_token_lifetime_ms: 3600000 + + # Users who register on this homeserver will automatically be joined to the rooms listed under "auto_join_rooms" option. + # By default, any room aliases included in this list will be created as a publicly joinable room + # when the first user registers for the homeserver. If the room already exists, + # make certain it is a publicly joinable room, i.e. the join rule of the room must be set to 'public'. + # As Spaces are just rooms under the hood, Space aliases may also be used. + auto_join_rooms: + # - "#main:matrix.org" + + account_database: + connection_string: file:dendrite-userapi.db + +room_server: + database: + connection_string: file:dendrite-roomserverapi.db + +key_server: + database: + connection_string: file:dendrite-keyserverapi.db + +# Configuration for Opentracing. +# See https://github.com/matrix-org/dendrite/tree/master/docs/tracing for information on +# how this works and how to set it up. +tracing: + enabled: false + jaeger: + serviceName: "" + disabled: false + rpc_metrics: false + tags: [] + sampler: null + reporter: null + headers: null + baggage_restrictions: null + throttler: null + +# Logging configuration. The "std" logging type controls the logs being sent to +# stdout. The "file" logging type controls logs being written to a log folder on +# the disk. Supported log levels are "debug", "info", "warn", "error". +logging: + - type: std + level: debug + - type: file + level: debug + params: + path: ./logs diff --git a/cypress/plugins/docker/index.ts b/cypress/plugins/docker/index.ts index 7c5fa1555a..e3c1cad7bd 100644 --- a/cypress/plugins/docker/index.ts +++ b/cypress/plugins/docker/index.ts @@ -30,7 +30,7 @@ export function dockerRun(opts: { image: string; containerName: string; params?: string[]; - cmd?: string; + cmd?: string[]; }): Promise { const userInfo = os.userInfo(); const params = opts.params ?? []; @@ -49,7 +49,7 @@ export function dockerRun(opts: { opts.image, ]; - if (opts.cmd) args.push(opts.cmd); + if (opts.cmd) args.push(...opts.cmd); return new Promise((resolve, reject) => { childProcess.execFile("docker", args, (err, stdout) => { diff --git a/cypress/plugins/index.ts b/cypress/plugins/index.ts index 603f31ed09..1971a70c5b 100644 --- a/cypress/plugins/index.ts +++ b/cypress/plugins/index.ts @@ -19,6 +19,7 @@ limitations under the License. import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; import { synapseDocker } from "./synapsedocker"; +import { dendriteDocker } from "./dendritedocker"; import { slidingSyncProxyDocker } from "./sliding-sync"; import { webserver } from "./webserver"; import { docker } from "./docker"; @@ -30,6 +31,7 @@ import { log } from "./log"; export default function (on: PluginEvents, config: PluginConfigOptions) { docker(on, config); synapseDocker(on, config); + dendriteDocker(on, config); slidingSyncProxyDocker(on, config); webserver(on, config); log(on, config); diff --git a/cypress/plugins/sliding-sync/index.ts b/cypress/plugins/sliding-sync/index.ts index c2495325e0..8204fb578d 100644 --- a/cypress/plugins/sliding-sync/index.ts +++ b/cypress/plugins/sliding-sync/index.ts @@ -20,7 +20,7 @@ import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; import { dockerExec, dockerIp, dockerRun, dockerStop } from "../docker"; import { getFreePort } from "../utils/port"; -import { SynapseInstance } from "../synapsedocker"; +import { HomeserverInstance } from "../utils/homeserver"; // A cypress plugin to add command to start & stop https://github.com/matrix-org/sliding-sync // SLIDING_SYNC_PROXY_TAG env used as the docker tag to use for `ghcr.io/matrix-org/sliding-sync-proxy` image. @@ -35,7 +35,7 @@ const instances = new Map(); const PG_PASSWORD = "p4S5w0rD"; -async function proxyStart(dockerTag: string, synapse: SynapseInstance): Promise { +async function proxyStart(dockerTag: string, homeserver: HomeserverInstance): Promise { console.log(new Date(), "Starting sliding sync proxy..."); const postgresId = await dockerRun({ @@ -45,7 +45,7 @@ async function proxyStart(dockerTag: string, synapse: SynapseInstance): Promise< }); const postgresIp = await dockerIp({ containerId: postgresId }); - const synapseIp = await dockerIp({ containerId: synapse.synapseId }); + const homeserverIp = await dockerIp({ containerId: homeserver.serverId }); console.log(new Date(), "postgres container up"); const waitTimeMillis = 30000; @@ -81,7 +81,7 @@ async function proxyStart(dockerTag: string, synapse: SynapseInstance): Promise< "-e", "SYNCV3_SECRET=bwahahaha", "-e", - `SYNCV3_SERVER=http://${synapseIp}:8008`, + `SYNCV3_SERVER=http://${homeserverIp}:8008`, "-e", `SYNCV3_DB=user=postgres dbname=postgres password=${PG_PASSWORD} host=${postgresIp} sslmode=disable`, ], diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 4a864eb56d..3615e4d511 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -25,29 +25,18 @@ import PluginEvents = Cypress.PluginEvents; import PluginConfigOptions = Cypress.PluginConfigOptions; import { getFreePort } from "../utils/port"; import { dockerExec, dockerLogs, dockerRun, dockerStop } from "../docker"; +import { HomeserverConfig, HomeserverInstance } from "../utils/homeserver"; // A cypress plugins to add command to start & stop synapses in // docker with preset templates. -interface SynapseConfig { - configDir: string; - registrationSecret: string; - // Synapse must be configured with its public_baseurl so we have to allocate a port & url at this stage - baseUrl: string; - port: number; -} - -export interface SynapseInstance extends SynapseConfig { - synapseId: string; -} - -const synapses = new Map(); +const synapses = new Map(); function randB64Bytes(numBytes: number): string { return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, ""); } -async function cfgDirFromTemplate(template: string): Promise { +async function cfgDirFromTemplate(template: string): Promise { const templateDir = path.join(__dirname, "templates", template); const stats = await fse.stat(templateDir); @@ -94,7 +83,7 @@ async function cfgDirFromTemplate(template: string): Promise { // Start a synapse instance: the template must be the name of // one of the templates in the cypress/plugins/synapsedocker/templates // directory -async function synapseStart(template: string): Promise { +async function synapseStart(template: string): Promise { const synCfg = await cfgDirFromTemplate(template); console.log(`Starting synapse with config dir ${synCfg.configDir}...`); @@ -103,7 +92,7 @@ async function synapseStart(template: string): Promise { image: "matrixdotorg/synapse:develop", containerName: `react-sdk-cypress-synapse`, params: ["--rm", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`], - cmd: "run", + cmd: ["run"], }); console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); @@ -125,7 +114,7 @@ async function synapseStart(template: string): Promise { ], }); - const synapse: SynapseInstance = { synapseId, ...synCfg }; + const synapse: HomeserverInstance = { serverId: synapseId, ...synCfg }; synapses.set(synapseId, synapse); return synapse; } diff --git a/cypress/plugins/utils/homeserver.ts b/cypress/plugins/utils/homeserver.ts new file mode 100644 index 0000000000..d6a4de0411 --- /dev/null +++ b/cypress/plugins/utils/homeserver.ts @@ -0,0 +1,28 @@ +/* +Copyright 2023 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. +*/ + +/// + +export interface HomeserverConfig { + configDir: string; + registrationSecret: string; + baseUrl: string; + port: number; +} + +export interface HomeserverInstance extends HomeserverConfig { + serverId: string; +} diff --git a/cypress/support/bot.ts b/cypress/support/bot.ts index 750cd566bb..745ec4002c 100644 --- a/cypress/support/bot.ts +++ b/cypress/support/bot.ts @@ -17,8 +17,8 @@ limitations under the License. /// import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; -import { SynapseInstance } from "../plugins/synapsedocker"; -import { Credentials } from "./synapse"; +import { HomeserverInstance } from "../plugins/utils/homeserver"; +import { Credentials } from "./homeserver"; import Chainable = Cypress.Chainable; interface CreateBotOpts { @@ -61,19 +61,19 @@ declare global { interface Chainable { /** * Returns a new Bot instance - * @param synapse the instance on which to register the bot user + * @param homeserver the instance on which to register the bot user * @param opts create bot options */ - getBot(synapse: SynapseInstance, opts: CreateBotOpts): Chainable; + getBot(homeserver: HomeserverInstance, opts: CreateBotOpts): Chainable; /** * Returns a new Bot instance logged in as an existing user - * @param synapse the instance on which to register the bot user + * @param homeserver the instance on which to register the bot user * @param username the username for the bot to log in with * @param password the password for the bot to log in with * @param opts create bot options */ loginBot( - synapse: SynapseInstance, + homeserver: HomeserverInstance, username: string, password: string, opts: CreateBotOpts, @@ -102,7 +102,7 @@ declare global { } function setupBotClient( - synapse: SynapseInstance, + homeserver: HomeserverInstance, credentials: Credentials, opts: CreateBotOpts, ): Chainable { @@ -119,7 +119,7 @@ function setupBotClient( }; const cli = new win.matrixcs.MatrixClient({ - baseUrl: synapse.baseUrl, + baseUrl: homeserver.baseUrl, userId: credentials.userId, deviceId: credentials.deviceId, accessToken: credentials.accessToken, @@ -160,15 +160,15 @@ function setupBotClient( }); } -Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Chainable => { +Cypress.Commands.add("getBot", (homeserver: HomeserverInstance, opts: CreateBotOpts): Chainable => { opts = Object.assign({}, defaultCreateBotOptions, opts); const username = Cypress._.uniqueId(opts.userIdPrefix); const password = Cypress._.uniqueId("password_"); return cy - .registerUser(synapse, username, password, opts.displayName) + .registerUser(homeserver, username, password, opts.displayName) .then((credentials) => { cy.log(`Registered bot user ${username} with displayname ${opts.displayName}`); - return setupBotClient(synapse, credentials, opts); + return setupBotClient(homeserver, credentials, opts); }) .then((client): Chainable => { Object.assign(client, { __cypress_password: password }); @@ -178,10 +178,15 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts): Cypress.Commands.add( "loginBot", - (synapse: SynapseInstance, username: string, password: string, opts: CreateBotOpts): Chainable => { + ( + homeserver: HomeserverInstance, + username: string, + password: string, + opts: CreateBotOpts, + ): Chainable => { opts = Object.assign({}, defaultCreateBotOptions, { bootstrapCrossSigning: false }, opts); - return cy.loginUser(synapse, username, password).then((credentials) => { - return setupBotClient(synapse, credentials, opts); + return cy.loginUser(homeserver, username, password).then((credentials) => { + return setupBotClient(homeserver, credentials, opts); }); }, ); diff --git a/cypress/support/e2e.ts b/cypress/support/e2e.ts index 4470c2192e..10014a4bd6 100644 --- a/cypress/support/e2e.ts +++ b/cypress/support/e2e.ts @@ -19,7 +19,7 @@ limitations under the License. import "@percy/cypress"; import "cypress-real-events"; -import "./synapse"; +import "./homeserver"; import "./login"; import "./labs"; import "./client"; diff --git a/cypress/support/synapse.ts b/cypress/support/homeserver.ts similarity index 63% rename from cypress/support/synapse.ts rename to cypress/support/homeserver.ts index 69e05969cb..8510e3640a 100644 --- a/cypress/support/synapse.ts +++ b/cypress/support/homeserver.ts @@ -1,5 +1,5 @@ /* -Copyright 2022 The Matrix.org Foundation C.I.C. +Copyright 2023 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. @@ -20,34 +20,34 @@ import * as crypto from "crypto"; import Chainable = Cypress.Chainable; import AUTWindow = Cypress.AUTWindow; -import { SynapseInstance } from "../plugins/synapsedocker"; +import { HomeserverInstance } from "../plugins/utils/homeserver"; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace namespace Cypress { interface Chainable { /** - * Start a synapse instance with a given config template. - * @param template path to template within cypress/plugins/synapsedocker/template/ directory. + * Start a homeserver instance with a given config template. + * @param template path to template within cypress/plugins/{homeserver}docker/template/ directory. */ - startSynapse(template: string): Chainable; + startHomeserver(template: string): Chainable; /** - * Custom command wrapping task:synapseStop whilst preventing uncaught exceptions - * for if Synapse stopping races with the app's background sync loop. - * @param synapse the synapse instance returned by startSynapse + * Custom command wrapping task:{homeserver}Stop whilst preventing uncaught exceptions + * for if Homeserver stopping races with the app's background sync loop. + * @param homeserver the homeserver instance returned by start{Homeserver} */ - stopSynapse(synapse: SynapseInstance): Chainable; + stopHomeserver(homeserver: HomeserverInstance): Chainable; /** - * Register a user on the given Synapse using the shared registration secret. - * @param synapse the synapse instance returned by startSynapse + * Register a user on the given Homeserver using the shared registration secret. + * @param homeserver the homeserver instance returned by start{Homeserver} * @param username the username of the user to register * @param password the password of the user to register * @param displayName optional display name to set on the newly registered user */ registerUser( - synapse: SynapseInstance, + homeserver: HomeserverInstance, username: string, password: string, displayName?: string, @@ -56,16 +56,18 @@ declare global { } } -function startSynapse(template: string): Chainable { - return cy.task("synapseStart", template); +function startHomeserver(template: string): Chainable { + const homeserverName = Cypress.env("HOMESERVER"); + return cy.task(homeserverName + "Start", template); } -function stopSynapse(synapse?: SynapseInstance): Chainable { - if (!synapse) return; - // Navigate away from app to stop the background network requests which will race with Synapse shutting down +function stopHomeserver(homeserver?: HomeserverInstance): Chainable { + if (!homeserver) return; + // Navigate away from app to stop the background network requests which will race with Homeserver shutting down return cy.window({ log: false }).then((win) => { win.location.href = "about:blank"; - cy.task("synapseStop", synapse.synapseId); + const homeserverName = Cypress.env("HOMESERVER"); + cy.task(homeserverName + "Stop", homeserver.serverId); }); } @@ -77,12 +79,12 @@ export interface Credentials { } function registerUser( - synapse: SynapseInstance, + homeserver: HomeserverInstance, username: string, password: string, displayName?: string, ): Chainable { - const url = `${synapse.baseUrl}/_synapse/admin/v1/register`; + const url = `${homeserver.baseUrl}/_synapse/admin/v1/register`; return cy .then(() => { // get a nonce @@ -91,7 +93,7 @@ function registerUser( .then((response) => { const { nonce } = response.body; const mac = crypto - .createHmac("sha1", synapse.registrationSecret) + .createHmac("sha1", homeserver.registrationSecret) .update(`${nonce}\0${username}\0${password}\0notadmin`) .digest("hex"); @@ -121,6 +123,6 @@ function registerUser( })); } -Cypress.Commands.add("startSynapse", startSynapse); -Cypress.Commands.add("stopSynapse", stopSynapse); +Cypress.Commands.add("startHomeserver", startHomeserver); +Cypress.Commands.add("stopHomeserver", stopHomeserver); Cypress.Commands.add("registerUser", registerUser); diff --git a/cypress/support/login.ts b/cypress/support/login.ts index 338da2f9db..308c1ea639 100644 --- a/cypress/support/login.ts +++ b/cypress/support/login.ts @@ -17,7 +17,7 @@ limitations under the License. /// import Chainable = Cypress.Chainable; -import { SynapseInstance } from "../plugins/synapsedocker"; +import { HomeserverInstance } from "../plugins/utils/homeserver"; export interface UserCredentials { accessToken: string; @@ -41,7 +41,7 @@ declare global { * useed. */ initTestUser( - synapse: SynapseInstance, + homeserver: HomeserverInstance, displayName: string, prelaunchFn?: () => void, userIdPrefix?: string, @@ -52,7 +52,7 @@ declare global { * @param username login username * @param password login password */ - loginUser(synapse: SynapseInstance, username: string, password: string): Chainable; + loginUser(synapse: HomeserverInstance, username: string, password: string): Chainable; } } } @@ -60,8 +60,8 @@ declare global { // eslint-disable-next-line max-len Cypress.Commands.add( "loginUser", - (synapse: SynapseInstance, username: string, password: string): Chainable => { - const url = `${synapse.baseUrl}/_matrix/client/r0/login`; + (homeserver: HomeserverInstance, username: string, password: string): Chainable => { + const url = `${homeserver.baseUrl}/_matrix/client/r0/login`; return cy .request<{ access_token: string; @@ -95,7 +95,7 @@ Cypress.Commands.add( Cypress.Commands.add( "initTestUser", ( - synapse: SynapseInstance, + homeserver: HomeserverInstance, displayName: string, prelaunchFn?: () => void, userIdPrefix = "user_", @@ -112,15 +112,15 @@ Cypress.Commands.add( const username = Cypress._.uniqueId(userIdPrefix); const password = Cypress._.uniqueId("password_"); return cy - .registerUser(synapse, username, password, displayName) + .registerUser(homeserver, username, password, displayName) .then(() => { - return cy.loginUser(synapse, username, password); + return cy.loginUser(homeserver, username, password); }) .then((response) => { cy.log(`Registered test user ${username} with displayname ${displayName}`); cy.window({ log: false }).then((win) => { // Seed the localStorage with the required credentials - win.localStorage.setItem("mx_hs_url", synapse.baseUrl); + win.localStorage.setItem("mx_hs_url", homeserver.baseUrl); win.localStorage.setItem("mx_user_id", response.userId); win.localStorage.setItem("mx_access_token", response.accessToken); win.localStorage.setItem("mx_device_id", response.deviceId); diff --git a/cypress/support/proxy.ts b/cypress/support/proxy.ts index 97480bdbe1..b40584ec7f 100644 --- a/cypress/support/proxy.ts +++ b/cypress/support/proxy.ts @@ -19,7 +19,7 @@ limitations under the License. import Chainable = Cypress.Chainable; import AUTWindow = Cypress.AUTWindow; import { ProxyInstance } from "../plugins/sliding-sync"; -import { SynapseInstance } from "../plugins/synapsedocker"; +import { HomeserverInstance } from "../plugins/utils/homeserver"; declare global { // eslint-disable-next-line @typescript-eslint/no-namespace @@ -27,9 +27,9 @@ declare global { interface Chainable { /** * Start a sliding sync proxy instance. - * @param synapse the synapse instance returned by startSynapse + * @param homeserver the homeserver instance returned by startHomeserver */ - startProxy(synapse: SynapseInstance): Chainable; + startProxy(homeserver: HomeserverInstance): Chainable; /** * Custom command wrapping task:proxyStop whilst preventing uncaught exceptions @@ -41,13 +41,13 @@ declare global { } } -function startProxy(synapse: SynapseInstance): Chainable { - return cy.task("proxyStart", synapse); +function startProxy(homeserver: HomeserverInstance): Chainable { + return cy.task("proxyStart", homeserver); } function stopProxy(proxy?: ProxyInstance): Chainable { if (!proxy) return; - // Navigate away from app to stop the background network requests which will race with Synapse shutting down + // Navigate away from app to stop the background network requests which will race with Homeserver shutting down return cy.window({ log: false }).then((win) => { win.location.href = "about:blank"; cy.task("proxyStop", proxy); diff --git a/docs/cypress.md b/docs/cypress.md index 4abac65bdf..b354b693cc 100644 --- a/docs/cypress.md +++ b/docs/cypress.md @@ -21,7 +21,7 @@ be tested. When running Cypress tests yourself, the standard `yarn start` from t element-web project is fine: leave it running it a different terminal as you would when developing. -The tests use Docker to launch Synapse instances to test against, so you'll also +The tests use Docker to launch Homeserver (Synapse or Dendrite) instances to test against, so you'll also need to have Docker installed and working in order to run the Cypress tests. There are a few different ways to run the tests yourself. The simplest is to run: @@ -58,10 +58,10 @@ Synapse can be launched with different configurations in order to test element in different configurations. `cypress/plugins/synapsedocker/templates` contains template configuration files for each different configuration. -Each test suite can then launch whatever Synapse instances it needs it whatever +Each test suite can then launch whatever Synapse instances it needs in whatever configurations. -Note that although tests should stop the Synapse instances after running and the +Note that although tests should stop the Homeserver instances after running and the plugin also stop any remaining instances after all tests have run, it is possible to be left with some stray containers if, for example, you terminate a test such that the `after()` does not run and also exit Cypress uncleanly. All the containers @@ -82,29 +82,29 @@ a read. ### Getting a Synapse The key difference is in starting Synapse instances. Tests use this plugin via -`cy.startSynapse()` to provide a Synapse instance to log into: +`cy.startHomeserver()` to provide a Homeserver instance to log into: ```javascript -cy.startSynapse("consent").then((result) => { - synapse = result; +cy.startHomeserver("consent").then((result) => { + homeserver = result; }); ``` -This returns an object with information about the Synapse instance, including what port +This returns an object with information about the Homeserver instance, including what port it was started on and the ID that needs to be passed to shut it down again. It also returns the registration shared secret (`registrationSecret`) that can be used to -register users via the REST API. The Synapse has been ensured ready to go by awaiting +register users via the REST API. The Homeserver has been ensured ready to go by awaiting its internal health-check. -Synapse instances should be reasonably cheap to start (you may see the first one take a +Homeserver instances should be reasonably cheap to start (you may see the first one take a while as it pulls the Docker image), so it's generally expected that tests will start a -Synapse instance for each test suite, i.e. in `before()`, and then tear it down in `after()`. +Homeserver instance for each test suite, i.e. in `before()`, and then tear it down in `after()`. -To later destroy your Synapse you should call `stopSynapse`, passing the SynapseInstance +To later destroy your Homeserver you should call `stopHomeserver`, passing the HomeserverInstance object you received when starting it. ```javascript -cy.stopSynapse(synapse); +cy.stopHomeserver(homeserver); ``` ### Synapse Config Templates @@ -131,10 +131,10 @@ in a template can be referenced in the config as `/data/foo.html`. There exists a basic utility to start the app with a random user already logged in: ```javascript -cy.initTestUser(synapse, "Jeff"); +cy.initTestUser(homeserver, "Jeff"); ``` -It takes the SynapseInstance you received from `startSynapse` and a display name for your test user. +It takes the HomeserverInstance you received from `startHomeserver` and a display name for your test user. This custom command will register a random userId using the registrationSecret with a random password and the given display name. The returned Chainable will contain details about the credentials for if they are needed for User-Interactive Auth or similar but localStorage will already be seeded with them @@ -147,11 +147,11 @@ but the signature can be maintained for simpler maintenance. Many tests will also want to start with the client in a room, ready to send & receive messages. Best way to do this may be to get an access token for the user and use this to create a room with the REST -API before logging the user in. You can make use of `cy.getBot(synapse)` and `cy.getClient()` to do this. +API before logging the user in. You can make use of `cy.getBot(homeserver)` and `cy.getClient()` to do this. ### Convenience APIs -We should probably end up with convenience APIs that wrap the synapse creation, logging in and room +We should probably end up with convenience APIs that wrap the homeserver creation, logging in and room creation that can be called to set up tests. ### Using matrix-js-sdk From a691e634b02f21b875de6475cb1d464754ec853e Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Wed, 11 Jan 2023 11:10:55 +0100 Subject: [PATCH 17/29] Add edit and remove actions to link in RTE (#9864) Add edit and remove actions to link in RTE --- package.json | 2 +- .../components/_LinkModal.pcss | 28 ++++- src/components/views/elements/Field.tsx | 2 +- .../components/FormattingButtons.tsx | 2 +- .../wysiwyg_composer/components/LinkModal.tsx | 119 ++++++++++++------ src/i18n/strings/en_EN.json | 1 + test/components/views/elements/Field-test.tsx | 54 ++++++++ .../components/LinkModal-test.tsx | 51 ++++++-- yarn.lock | 8 +- 9 files changed, 209 insertions(+), 58 deletions(-) create mode 100644 test/components/views/elements/Field-test.tsx diff --git a/package.json b/package.json index 37f8a2d895..cf00952f23 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "dependencies": { "@babel/runtime": "^7.12.5", "@matrix-org/analytics-events": "^0.3.0", - "@matrix-org/matrix-wysiwyg": "^0.13.0", + "@matrix-org/matrix-wysiwyg": "^0.14.0", "@matrix-org/react-sdk-module-api": "^0.0.3", "@sentry/browser": "^7.0.0", "@sentry/tracing": "^7.0.0", diff --git a/res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss b/res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss index 5bd02cc245..b5c1c04d57 100644 --- a/res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss +++ b/res/css/views/rooms/wysiwyg_composer/components/_LinkModal.pcss @@ -16,14 +16,32 @@ limitations under the License. .mx_LinkModal { padding: $spacing-32; - - .mx_Dialog_content { - margin-top: 30px; - margin-bottom: 42px; - } + max-width: 600px; + height: 341px; + box-sizing: border-box; + display: flex; + flex-direction: column; .mx_LinkModal_content { display: flex; flex-direction: column; + flex: 1; + gap: $spacing-8; + margin-top: 7px; + + .mx_LinkModal_Field { + flex: initial; + height: 40px; + } + + .mx_LinkModal_buttons { + display: flex; + flex: 1; + align-items: flex-end; + + .mx_Dialog_buttons { + display: inline-block; + } + } } } diff --git a/src/components/views/elements/Field.tsx b/src/components/views/elements/Field.tsx index 31a6e88897..295cdf99a9 100644 --- a/src/components/views/elements/Field.tsx +++ b/src/components/views/elements/Field.tsx @@ -262,7 +262,7 @@ export default class Field extends React.PureComponent { this.inputRef = inputRef || React.createRef(); - inputProps.placeholder = inputProps.placeholder || inputProps.label; + inputProps.placeholder = inputProps.placeholder ?? inputProps.label; inputProps.id = this.id; // this overwrites the id from props inputProps.onFocus = this.onFocus; diff --git a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx index d2cafb1198..6f85328ba5 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/FormattingButtons.tsx @@ -120,7 +120,7 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
diff --git a/src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx b/src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx index 2dcfc43ead..0e41d8baa4 100644 --- a/src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx +++ b/src/components/views/rooms/wysiwyg_composer/components/LinkModal.tsx @@ -17,17 +17,28 @@ limitations under the License. import { FormattingFunctions } from "@matrix-org/matrix-wysiwyg"; import React, { ChangeEvent, useState } from "react"; -import { _td } from "../../../../../languageHandler"; +import { _t } from "../../../../../languageHandler"; import Modal from "../../../../../Modal"; -import QuestionDialog from "../../../dialogs/QuestionDialog"; import Field from "../../../elements/Field"; import { ComposerContextState } from "../ComposerContext"; import { isSelectionEmpty, setSelection } from "../utils/selection"; +import BaseDialog from "../../../dialogs/BaseDialog"; +import DialogButtons from "../../../elements/DialogButtons"; -export function openLinkModal(composer: FormattingFunctions, composerContext: ComposerContextState) { +export function openLinkModal( + composer: FormattingFunctions, + composerContext: ComposerContextState, + isEditing: boolean, +) { const modal = Modal.createDialog( LinkModal, - { composerContext, composer, onClose: () => modal.close(), isTextEnabled: isSelectionEmpty() }, + { + composerContext, + composer, + onClose: () => modal.close(), + isTextEnabled: isSelectionEmpty(), + isEditing, + }, "mx_CompoundDialog", false, true, @@ -43,48 +54,86 @@ interface LinkModalProps { isTextEnabled: boolean; onClose: () => void; composerContext: ComposerContextState; + isEditing: boolean; } -export function LinkModal({ composer, isTextEnabled, onClose, composerContext }: LinkModalProps) { - const [fields, setFields] = useState({ text: "", link: "" }); - const isSaveDisabled = (isTextEnabled && isEmpty(fields.text)) || isEmpty(fields.link); +export function LinkModal({ composer, isTextEnabled, onClose, composerContext, isEditing }: LinkModalProps) { + const [hasLinkChanged, setHasLinkChanged] = useState(false); + const [fields, setFields] = useState({ text: "", link: isEditing ? composer.getLink() : "" }); + const hasText = !isEditing && isTextEnabled; + const isSaveDisabled = !hasLinkChanged || (hasText && isEmpty(fields.text)) || isEmpty(fields.link); return ( - { - if (isClickOnSave) { + title={isEditing ? _t("Edit link") : _t("Create a link")} + hasCancel={true} + onFinished={onClose} + > +
{ + evt.preventDefault(); + evt.stopPropagation(); + + onClose(); + + // When submitting is done when pressing enter when the link field has the focus, + // The link field is getting back the focus (due to react-focus-lock) + // So we are waiting that the focus stuff is done to play with the composer selection + await new Promise((resolve) => setTimeout(resolve, 0)); + await setSelection(composerContext.selection); composer.link(fields.link, isTextEnabled ? fields.text : undefined); - } - onClose(); - }} - description={ -
- {isTextEnabled && ( - ) => - setFields((fields) => ({ ...fields, text: e.target.value })) - } - /> - )} + }} + > + {hasText && ( ) => - setFields((fields) => ({ ...fields, link: e.target.value })) + setFields((fields) => ({ ...fields, text: e.target.value })) } /> + )} + ) => { + setFields((fields) => ({ ...fields, link: e.target.value })); + setHasLinkChanged(true); + }} + /> + +
+ {isEditing && ( + + )} +
- } - /> + + ); } diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 16acf9b8dc..bc876ff668 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -2136,6 +2136,7 @@ "Underline": "Underline", "Code": "Code", "Link": "Link", + "Edit link": "Edit link", "Create a link": "Create a link", "Text": "Text", "Message Actions": "Message Actions", diff --git a/test/components/views/elements/Field-test.tsx b/test/components/views/elements/Field-test.tsx new file mode 100644 index 0000000000..199f1c2557 --- /dev/null +++ b/test/components/views/elements/Field-test.tsx @@ -0,0 +1,54 @@ +/* +Copyright 2023 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 Field from "../../../../src/components/views/elements/Field"; + +describe("Field", () => { + describe("Placeholder", () => { + it("Should display a placeholder", async () => { + // When + const { rerender } = render(); + + // Then + expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my placeholder"); + + // When + rerender(); + + // Then + expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", ""); + }); + + it("Should display label as placeholder", async () => { + // When + render(); + + // Then + expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my label"); + }); + + it("Should not display a placeholder", async () => { + // When + render(); + + // Then + expect(screen.getByRole("textbox")).not.toHaveAttribute("placeholder", "my placeholder"); + }); + }); +}); diff --git a/test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx b/test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx index c2fd1aeff2..527f7e7b3d 100644 --- a/test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx +++ b/test/components/views/rooms/wysiwyg_composer/components/LinkModal-test.tsx @@ -27,6 +27,8 @@ import { SubSelection } from "../../../../../../src/components/views/rooms/wysiw describe("LinkModal", () => { const formattingFunctions = { link: jest.fn(), + removeLinks: jest.fn(), + getLink: jest.fn().mockReturnValue("my initial content"), } as unknown as FormattingFunctions; const defaultValue: SubSelection = { focusNode: null, @@ -35,13 +37,14 @@ describe("LinkModal", () => { anchorOffset: 4, }; - const customRender = (isTextEnabled: boolean, onClose: () => void) => { + const customRender = (isTextEnabled: boolean, onClose: () => void, isEditing = false) => { return render( , ); }; @@ -75,13 +78,13 @@ describe("LinkModal", () => { // When jest.useFakeTimers(); screen.getByText("Save").click(); + jest.runAllTimers(); // Then - expect(selectionSpy).toHaveBeenCalledWith(defaultValue); - await waitFor(() => expect(onClose).toBeCalledTimes(1)); - - // When - jest.runAllTimers(); + await waitFor(() => { + expect(selectionSpy).toHaveBeenCalledWith(defaultValue); + expect(onClose).toBeCalledTimes(1); + }); // Then expect(formattingFunctions.link).toHaveBeenCalledWith("l", undefined); @@ -118,15 +121,41 @@ describe("LinkModal", () => { // When jest.useFakeTimers(); screen.getByText("Save").click(); + jest.runAllTimers(); // Then - expect(selectionSpy).toHaveBeenCalledWith(defaultValue); - await waitFor(() => expect(onClose).toBeCalledTimes(1)); - - // When - jest.runAllTimers(); + await waitFor(() => { + expect(selectionSpy).toHaveBeenCalledWith(defaultValue); + expect(onClose).toBeCalledTimes(1); + }); // Then expect(formattingFunctions.link).toHaveBeenCalledWith("l", "t"); }); + + it("Should remove the link", async () => { + // When + const onClose = jest.fn(); + customRender(true, onClose, true); + await userEvent.click(screen.getByText("Remove")); + + // Then + expect(formattingFunctions.removeLinks).toHaveBeenCalledTimes(1); + expect(onClose).toBeCalledTimes(1); + }); + + it("Should display the link in editing", async () => { + // When + customRender(true, jest.fn(), true); + + // Then + expect(screen.getByLabelText("Link")).toContainHTML("my initial content"); + expect(screen.getByText("Save")).toBeDisabled(); + + // When + await userEvent.type(screen.getByLabelText("Link"), "l"); + + // Then + await waitFor(() => expect(screen.getByText("Save")).toBeEnabled()); + }); }); diff --git a/yarn.lock b/yarn.lock index c9c01780cd..ee1874ea43 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1525,10 +1525,10 @@ resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.2.tgz#a09d0fea858e817da971a3c9f904632ef7b49eb6" integrity sha512-oVkBCh9YP7H9i4gAoQbZzswniczfo/aIptNa4dxRi4Ff9lSvUCFv6Hvzi7C+90c0/PWZLXjIDTIAWZYmwyd2fA== -"@matrix-org/matrix-wysiwyg@^0.13.0": - version "0.13.0" - resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.13.0.tgz#e643df4e13cdc5dbf9285740bc0ce2aef9873c16" - integrity sha512-MCeTj4hkl0snjlygd1v+mEEOgaN6agyjAVjJEbvEvP/BaYaDiPEXMTDaRQrcUt3OIY53UNhm1DDEn4yPTn83Jg== +"@matrix-org/matrix-wysiwyg@^0.14.0": + version "0.14.0" + resolved "https://registry.yarnpkg.com/@matrix-org/matrix-wysiwyg/-/matrix-wysiwyg-0.14.0.tgz#359fabf5af403b3f128fe6ede3bff9754a9e18c4" + integrity sha512-iSwIR7kS/zwAzy/8S5cUMv2aceoJl/vIGhqmY9hSU0gVyzmsyaVnx00uNMvVDBUFiiPT2gonN8R3+dxg58TPaQ== "@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz": version "3.2.14" From 880a86c8b177e4d9ab1f60b5386db6c80efb0bbf Mon Sep 17 00:00:00 2001 From: alunturner <56027671+alunturner@users.noreply.github.com> Date: Wed, 11 Jan 2023 10:46:35 +0000 Subject: [PATCH 18/29] Convert enzyme to rtl: UserInfo (#9846) * converts tests from Enzyme to Rtl * increases test coverage --- src/components/views/right_panel/BaseCard.tsx | 2 +- src/components/views/right_panel/UserInfo.tsx | 124 ++- .../views/right_panel/UserInfo-test.tsx | 978 ++++++++++++++++-- 3 files changed, 960 insertions(+), 144 deletions(-) diff --git a/src/components/views/right_panel/BaseCard.tsx b/src/components/views/right_panel/BaseCard.tsx index 186460b44d..643af55605 100644 --- a/src/components/views/right_panel/BaseCard.tsx +++ b/src/components/views/right_panel/BaseCard.tsx @@ -69,7 +69,7 @@ const BaseCard: React.FC = forwardRef( if (onClose) { closeButton = ( { +export const disambiguateDevices = (devices: IDevice[]) => { const names = Object.create(null); for (let i = 0; i < devices.length; i++) { const name = devices[i].getDisplayName(); @@ -94,7 +95,7 @@ const disambiguateDevices = (devices: IDevice[]) => { } for (const name in names) { if (names[name].length > 1) { - names[name].forEach((j) => { + names[name].forEach((j: number) => { devices[j].ambiguous = true; }); } @@ -149,7 +150,7 @@ function useHasCrossSigningKeys(cli: MatrixClient, member: User, canVerify: bool }, [cli, member, canVerify]); } -function DeviceItem({ userId, device }: { userId: string; device: IDevice }) { +export function DeviceItem({ userId, device }: { userId: string; device: IDevice }) { const cli = useContext(MatrixClientContext); const isMe = userId === cli.getUserId(); const deviceTrust = cli.checkDeviceTrust(userId, device.deviceId); @@ -172,7 +173,10 @@ function DeviceItem({ userId, device }: { userId: string; device: IDevice }) { }); const onDeviceClick = () => { - verifyDevice(cli.getUser(userId), device); + const user = cli.getUser(userId); + if (user) { + verifyDevice(user, device); + } }; let deviceName; @@ -315,7 +319,7 @@ const MessageButton = ({ member }: { member: RoomMember }) => { ); }; -const UserOptionsSection: React.FC<{ +export const UserOptionsSection: React.FC<{ member: RoomMember; isIgnored: boolean; canInvite: boolean; @@ -367,7 +371,8 @@ const UserOptionsSection: React.FC<{ dis.dispatch({ action: Action.ViewRoom, highlighted: true, - event_id: room.getEventReadUpTo(member.userId), + // this could return null, the default prevents a type error + event_id: room?.getEventReadUpTo(member.userId) || undefined, room_id: member.roomId, metricsTrigger: undefined, // room doesn't change }); @@ -402,16 +407,18 @@ const UserOptionsSection: React.FC<{ const onInviteUserButton = async (ev: ButtonEvent) => { try { // We use a MultiInviter to re-use the invite logic, even though we're only inviting one user. - const inviter = new MultiInviter(roomId); + const inviter = new MultiInviter(roomId || ""); await inviter.invite([member.userId]).then(() => { if (inviter.getCompletionState(member.userId) !== "invited") { throw new Error(inviter.getErrorText(member.userId)); } }); } catch (err) { + const description = err instanceof Error ? err.message : _t("Operation failed"); + Modal.createDialog(ErrorDialog, { title: _t("Failed to invite"), - description: err && err.message ? err.message : _t("Operation failed"), + description, }); } @@ -432,10 +439,7 @@ const UserOptionsSection: React.FC<{ ); - let directMessageButton: JSX.Element; - if (!isMe) { - directMessageButton = ; - } + const directMessageButton = isMe ? null : ; return (
@@ -499,16 +503,24 @@ interface IPowerLevelsContent { redact?: number; } -const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => { +export const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent) => { if (!powerLevelContent || !member) return false; const levelToSend = (powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) || powerLevelContent.events_default; + + // levelToSend could be undefined as .events_default is optional. Coercing in this case using + // Number() would always return false, so this preserves behaviour + // FIXME: per the spec, if `events_default` is unset, it defaults to zero. If + // the member has a negative powerlevel, this will give an incorrect result. + if (levelToSend === undefined) return false; + return member.powerLevel < levelToSend; }; -const getPowerLevels = (room) => room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; +export const getPowerLevels = (room: Room): IPowerLevelsContent => + room?.currentState?.getStateEvents(EventType.RoomPowerLevels, "")?.getContent() || {}; export const useRoomPowerLevels = (cli: MatrixClient, room: Room) => { const [powerLevels, setPowerLevels] = useState(getPowerLevels(room)); @@ -538,7 +550,7 @@ interface IBaseProps { stopUpdating(): void; } -const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit) => { +export const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit) => { const cli = useContext(MatrixClientContext); // check if user can be kicked/disinvited @@ -566,7 +578,7 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit { // Return true if the target member is not banned and we have sufficient PL to ban them - const myMember = child.getMember(cli.credentials.userId); + const myMember = child.getMember(cli.credentials.userId || ""); const theirMember = child.getMember(member.userId); return ( myMember && @@ -648,7 +660,7 @@ const RedactMessagesButton: React.FC = ({ member }) => { ); }; -const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit) => { +export const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit) => { const cli = useContext(MatrixClientContext); const isBanned = member.membership === "ban"; @@ -674,7 +686,7 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit { // Return true if the target member is banned and we have sufficient PL to unban - const myMember = child.getMember(cli.credentials.userId); + const myMember = child.getMember(cli.credentials.userId || ""); const theirMember = child.getMember(member.userId); return ( myMember && @@ -686,7 +698,7 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit { // Return true if the target member isn't banned and we have sufficient PL to ban - const myMember = child.getMember(cli.credentials.userId); + const myMember = child.getMember(cli.credentials.userId || ""); const theirMember = child.getMember(member.userId); return ( myMember && @@ -835,7 +847,7 @@ const MuteToggleButton: React.FC = ({ member, room, powerLevels, ); }; -const RoomAdminToolsContainer: React.FC = ({ +export const RoomAdminToolsContainer: React.FC = ({ room, children, member, @@ -855,7 +867,7 @@ const RoomAdminToolsContainer: React.FC = ({ // if these do not exist in the event then they should default to 50 as per the spec const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels; - const me = room.getMember(cli.getUserId()); + const me = room.getMember(cli.getUserId() || ""); if (!me) { // we aren't in the room, so return no admin tooling return
; @@ -879,7 +891,7 @@ const RoomAdminToolsContainer: React.FC = ({ ); } - if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) { + if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) { muteButton = ( { setSelectedPowerLevel(powerLevel); - const applyPowerChange = (roomId, target, powerLevel, powerLevelEvent) => { - return cli.setPowerLevel(roomId, target, parseInt(powerLevel), powerLevelEvent).then( + const applyPowerChange = ( + roomId: string, + target: string, + powerLevel: number, + powerLevelEvent: MatrixEvent, + ) => { + return cli.setPowerLevel(roomId, target, powerLevel, powerLevelEvent).then( function () { // NO-OP; rely on the m.room.member event coming down else we could // get out of sync if we force setState here! @@ -1046,7 +1063,7 @@ const PowerLevelEditor: React.FC<{ if (!powerLevelEvent) return; const myUserId = cli.getUserId(); - const myPower = powerLevelEvent.getContent().users[myUserId]; + const myPower = powerLevelEvent.getContent().users[myUserId || ""]; if (myPower && parseInt(myPower) <= powerLevel && myUserId !== target) { const { finished } = Modal.createDialog(QuestionDialog, { title: _t("Warning!"), @@ -1085,7 +1102,7 @@ const PowerLevelEditor: React.FC<{ return (
{ const cli = useContext(MatrixClientContext); // undefined means yet to be loaded, null means failed to load, otherwise list of devices - const [devices, setDevices] = useState(undefined); + const [devices, setDevices] = useState(undefined); // Download device lists useEffect(() => { setDevices(undefined); @@ -1116,8 +1133,8 @@ export const useDevices = (userId: string) => { return; } - disambiguateDevices(devices); - setDevices(devices); + disambiguateDevices(devices as IDevice[]); + setDevices(devices as IDevice[]); } catch (err) { setDevices(null); } @@ -1136,17 +1153,17 @@ export const useDevices = (userId: string) => { const updateDevices = async () => { const newDevices = cli.getStoredDevicesForUser(userId); if (cancel) return; - setDevices(newDevices); + setDevices(newDevices as IDevice[]); }; - const onDevicesUpdated = (users) => { + const onDevicesUpdated = (users: string[]) => { if (!users.includes(userId)) return; updateDevices(); }; - const onDeviceVerificationChanged = (_userId, device) => { + const onDeviceVerificationChanged = (_userId: string, deviceId: string) => { if (_userId !== userId) return; updateDevices(); }; - const onUserTrustStatusChanged = (_userId, trustStatus) => { + const onUserTrustStatusChanged = (_userId: string, trustLevel: UserTrustLevel) => { if (_userId !== userId) return; updateDevices(); }; @@ -1229,9 +1246,11 @@ const BasicUserInfo: React.FC<{ logger.error("Failed to deactivate user"); logger.error(err); + const description = err instanceof Error ? err.message : _t("Operation failed"); + Modal.createDialog(ErrorDialog, { title: _t("Failed to deactivate user"), - description: err && err.message ? err.message : _t("Operation failed"), + description, }); } }, [cli, member.userId]); @@ -1317,12 +1336,12 @@ const BasicUserInfo: React.FC<{ const homeserverSupportsCrossSigning = useHomeserverSupportsCrossSigning(cli); const userTrust = cryptoEnabled && cli.checkUserTrust(member.userId); - const userVerified = cryptoEnabled && userTrust.isCrossSigningVerified(); + const userVerified = cryptoEnabled && userTrust && userTrust.isCrossSigningVerified(); const isMe = member.userId === cli.getUserId(); const canVerify = cryptoEnabled && homeserverSupportsCrossSigning && !userVerified && !isMe && devices && devices.length > 0; - const setUpdating = (updating) => { + const setUpdating: SetUpdating = (updating) => { setPendingUpdateCount((count) => count + (updating ? 1 : -1)); }; const hasCrossSigningKeys = useHasCrossSigningKeys(cli, member as User, canVerify, setUpdating); @@ -1408,9 +1427,9 @@ const BasicUserInfo: React.FC<{ export type Member = User | RoomMember; -const UserInfoHeader: React.FC<{ +export const UserInfoHeader: React.FC<{ member: Member; - e2eStatus: E2EStatus; + e2eStatus?: E2EStatus; roomId?: string; }> = ({ member, e2eStatus, roomId }) => { const cli = useContext(MatrixClientContext); @@ -1427,9 +1446,11 @@ const UserInfoHeader: React.FC<{ name: (member as RoomMember).name || (member as User).displayName, }; - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", null, true); + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); }, [member]); + const avatarUrl = (member as User).avatarUrl; + const avatarElement = (
@@ -1442,7 +1463,7 @@ const UserInfoHeader: React.FC<{ resizeMethod="scale" fallbackUserId={member.userId} onClick={onMemberAvatarClick} - urls={(member as User).avatarUrl ? [(member as User).avatarUrl] : undefined} + urls={avatarUrl ? [avatarUrl] : undefined} />
@@ -1475,10 +1496,7 @@ const UserInfoHeader: React.FC<{ ); } - let e2eIcon; - if (e2eStatus) { - e2eIcon = ; - } + const e2eIcon = e2eStatus ? : null; const displayName = (member as RoomMember).rawDisplayName; return ( @@ -1496,7 +1514,7 @@ const UserInfoHeader: React.FC<{
- {UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, { + {UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, { roomId, withDisplayName: true, })} @@ -1533,7 +1551,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha const classes = ["mx_UserInfo"]; - let cardState: IRightPanelCardState; + let cardState: IRightPanelCardState = {}; // We have no previousPhase for when viewing a UserInfo without a Room at this time if (room && phase === RightPanelPhases.EncryptionPanel) { cardState = { member }; @@ -1551,10 +1569,10 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha case RightPanelPhases.SpaceMemberInfo: content = ( ); break; @@ -1565,7 +1583,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha {...(props as React.ComponentProps)} member={member as User | RoomMember} onClose={onEncryptionPanelClose} - isRoomEncrypted={isRoomEncrypted} + isRoomEncrypted={Boolean(isRoomEncrypted)} /> ); break; @@ -1582,7 +1600,7 @@ const UserInfo: React.FC = ({ user, room, onClose, phase = RightPanelPha let scopeHeader; if (room?.isSpaceRoom()) { scopeHeader = ( -
+
diff --git a/test/components/views/right_panel/UserInfo-test.tsx b/test/components/views/right_panel/UserInfo-test.tsx index 2998efe5b3..49a5b134bc 100644 --- a/test/components/views/right_panel/UserInfo-test.tsx +++ b/test/components/views/right_panel/UserInfo-test.tsx @@ -15,21 +15,42 @@ limitations under the License. */ import React from "react"; -// eslint-disable-next-line deprecate/import -import { mount } from "enzyme"; +import { fireEvent, render, screen, waitFor, cleanup } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; import { mocked } from "jest-mock"; -import { act } from "react-dom/test-utils"; -import { Room, User, MatrixClient } from "matrix-js-sdk/src/matrix"; +import { Room, User, MatrixClient, RoomMember, MatrixEvent, EventType } from "matrix-js-sdk/src/matrix"; import { Phase, VerificationRequest } from "matrix-js-sdk/src/crypto/verification/request/VerificationRequest"; +import { DeviceTrustLevel, UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning"; -import UserInfo from "../../../../src/components/views/right_panel/UserInfo"; +import UserInfo, { + BanToggleButton, + DeviceItem, + disambiguateDevices, + getPowerLevels, + IDevice, + isMuted, + PowerLevelEditor, + RoomAdminToolsContainer, + RoomKickButton, + UserInfoHeader, + UserOptionsSection, +} from "../../../../src/components/views/right_panel/UserInfo"; +import dis from "../../../../src/dispatcher/dispatcher"; import { RightPanelPhases } from "../../../../src/stores/right-panel/RightPanelStorePhases"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import MatrixClientContext from "../../../../src/contexts/MatrixClientContext"; -import VerificationPanel from "../../../../src/components/views/right_panel/VerificationPanel"; -import EncryptionInfo from "../../../../src/components/views/right_panel/EncryptionInfo"; +import MultiInviter from "../../../../src/utils/MultiInviter"; +import * as mockVerification from "../../../../src/verification"; +import Modal from "../../../../src/Modal"; +import { E2EStatus } from "../../../../src/utils/ShieldUtils"; -const findByTestId = (component, id) => component.find(`[data-test-id="${id}"]`); +jest.mock("../../../../src/dispatcher/dispatcher"); + +jest.mock("../../../../src/customisations/UserIdentifier", () => { + return { + getDisplayUserIdentifier: jest.fn().mockReturnValue("customUserIdentifier"), + }; +}); jest.mock("../../../../src/utils/DMRoomMap", () => { const mock = { @@ -43,33 +64,62 @@ jest.mock("../../../../src/utils/DMRoomMap", () => { }; }); -describe("", () => { - const defaultUserId = "@test:test"; - const defaultUser = new User(defaultUserId); - - const mockClient = mocked({ - getUser: jest.fn(), - isGuest: jest.fn().mockReturnValue(false), - isUserIgnored: jest.fn(), - isCryptoEnabled: jest.fn(), - getUserId: jest.fn(), +const mockRoom = mocked({ + roomId: "!fkfk", + getType: jest.fn().mockReturnValue(undefined), + isSpaceRoom: jest.fn().mockReturnValue(false), + getMember: jest.fn().mockReturnValue(undefined), + getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"), + name: "test room", + on: jest.fn(), + currentState: { + getStateEvents: jest.fn(), on: jest.fn(), - isSynapseAdministrator: jest.fn().mockResolvedValue(false), - isRoomEncrypted: jest.fn().mockReturnValue(false), - doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false), - mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"), - removeListener: jest.fn(), - currentState: { - on: jest.fn(), - }, - } as unknown as MatrixClient); + }, + getEventReadUpTo: jest.fn(), +} as unknown as Room); +const mockClient = mocked({ + getUser: jest.fn(), + isGuest: jest.fn().mockReturnValue(false), + isUserIgnored: jest.fn(), + isCryptoEnabled: jest.fn(), + getUserId: jest.fn(), + on: jest.fn(), + isSynapseAdministrator: jest.fn().mockResolvedValue(false), + isRoomEncrypted: jest.fn().mockReturnValue(false), + doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false), + mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"), + removeListener: jest.fn(), + currentState: { + on: jest.fn(), + }, + checkDeviceTrust: jest.fn(), + checkUserTrust: jest.fn(), + getRoom: jest.fn(), + credentials: {}, + setPowerLevel: jest.fn(), +} as unknown as MatrixClient); + +const defaultUserId = "@test:test"; +const defaultUser = new User(defaultUserId); + +beforeEach(() => { + jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); +}); + +afterEach(() => { + mockClient.getUser.mockClear().mockReturnValue({} as unknown as User); +}); + +describe("", () => { const verificationRequest = { pending: true, on: jest.fn(), phase: Phase.Ready, channel: { transactionId: 1 }, otherPartySupportsMethod: jest.fn(), + off: jest.fn(), } as unknown as VerificationRequest; const defaultProps = { @@ -79,111 +129,859 @@ describe("", () => { onClose: jest.fn(), }; - const getComponent = (props = {}) => - mount(, { - wrappingComponent: MatrixClientContext.Provider, - wrappingComponentProps: { value: mockClient }, + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, }); + }; - beforeAll(() => { - jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient); - }); + it("closes on close button click", async () => { + renderComponent(); - beforeEach(() => { - mockClient.getUser.mockClear().mockReturnValue({} as unknown as User); - }); + await userEvent.click(screen.getByTestId("base-card-close-button")); - it("closes on close button click", () => { - const onClose = jest.fn(); - const component = getComponent({ onClose }); - act(() => { - findByTestId(component, "base-card-close-button").at(0).simulate("click"); - }); - - expect(onClose).toHaveBeenCalled(); + expect(defaultProps.onClose).toHaveBeenCalled(); }); describe("without a room", () => { it("does not render space header", () => { - const component = getComponent(); - expect(findByTestId(component, "space-header").length).toBeFalsy(); + renderComponent(); + expect(screen.queryByTestId("space-header")).not.toBeInTheDocument(); }); it("renders user info", () => { - const component = getComponent(); - expect(component.find("BasicUserInfo").length).toBeTruthy(); + renderComponent(); + expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument(); }); it("renders encryption info panel without pending verification", () => { - const phase = RightPanelPhases.EncryptionPanel; - const component = getComponent({ phase }); - - expect(component.find(EncryptionInfo).length).toBeTruthy(); + renderComponent({ phase: RightPanelPhases.EncryptionPanel }); + expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument(); }); it("renders encryption verification panel with pending verification", () => { - const phase = RightPanelPhases.EncryptionPanel; - const component = getComponent({ phase, verificationRequest }); + renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest }); - expect(component.find(EncryptionInfo).length).toBeFalsy(); - expect(component.find(VerificationPanel).length).toBeTruthy(); + expect(screen.queryByRole("heading", { name: /encryption/i })).not.toBeInTheDocument(); + // the verificationRequest has phase of Phase.Ready but .otherPartySupportsMethod + // will not return true, so we expect to see the noCommonMethod error from VerificationPanel + expect(screen.getByText(/try with a different client/i)).toBeInTheDocument(); }); it("renders close button correctly when encryption panel with a pending verification request", () => { - const phase = RightPanelPhases.EncryptionPanel; - const component = getComponent({ phase, verificationRequest }); - - expect(findByTestId(component, "base-card-close-button").at(0).props().title).toEqual("Cancel"); + renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest }); + expect(screen.getByTestId("base-card-close-button")).toHaveAttribute("title", "Cancel"); }); }); describe("with a room", () => { - const room = { - roomId: "!fkfk", - getType: jest.fn().mockReturnValue(undefined), - isSpaceRoom: jest.fn().mockReturnValue(false), - getMember: jest.fn().mockReturnValue(undefined), - getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"), - name: "test room", - on: jest.fn(), - currentState: { - getStateEvents: jest.fn(), - on: jest.fn(), - }, - } as unknown as Room; - it("renders user info", () => { - const component = getComponent(); - expect(component.find("BasicUserInfo").length).toBeTruthy(); + renderComponent({ room: mockRoom }); + expect(screen.getByRole("heading", { name: defaultUserId })).toBeInTheDocument(); }); it("does not render space header when room is not a space room", () => { - const component = getComponent({ room }); - expect(findByTestId(component, "space-header").length).toBeFalsy(); + renderComponent({ room: mockRoom }); + expect(screen.queryByTestId("space-header")).not.toBeInTheDocument(); }); it("renders space header when room is a space room", () => { const spaceRoom = { - ...room, + ...mockRoom, isSpaceRoom: jest.fn().mockReturnValue(true), }; - const component = getComponent({ room: spaceRoom }); - expect(findByTestId(component, "space-header").length).toBeTruthy(); + renderComponent({ room: spaceRoom }); + expect(screen.getByTestId("space-header")).toBeInTheDocument(); }); it("renders encryption info panel without pending verification", () => { - const phase = RightPanelPhases.EncryptionPanel; - const component = getComponent({ phase, room }); - - expect(component.find(EncryptionInfo).length).toBeTruthy(); + renderComponent({ phase: RightPanelPhases.EncryptionPanel, room: mockRoom }); + expect(screen.getByRole("heading", { name: /encryption/i })).toBeInTheDocument(); }); it("renders encryption verification panel with pending verification", () => { - const phase = RightPanelPhases.EncryptionPanel; - const component = getComponent({ phase, verificationRequest, room }); + renderComponent({ phase: RightPanelPhases.EncryptionPanel, verificationRequest, room: mockRoom }); - expect(component.find(EncryptionInfo).length).toBeFalsy(); - expect(component.find(VerificationPanel).length).toBeTruthy(); + expect(screen.queryByRole("heading", { name: /encryption/i })).not.toBeInTheDocument(); + // the verificationRequest has phase of Phase.Ready but .otherPartySupportsMethod + // will not return true, so we expect to see the noCommonMethod error from VerificationPanel + expect(screen.getByText(/try with a different client/i)).toBeInTheDocument(); }); }); }); + +describe("", () => { + const defaultMember = new RoomMember(mockRoom.roomId, defaultUserId); + + const defaultProps = { + member: defaultMember, + roomId: mockRoom.roomId, + }; + + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, + }); + }; + + it("does not render an e2e icon in the header if e2eStatus prop is undefined", () => { + renderComponent(); + const header = screen.getByRole("heading", { name: defaultUserId }); + + expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(0); + }); + + it("renders an e2e icon in the header if e2eStatus prop is defined", () => { + renderComponent({ e2eStatus: E2EStatus.Normal }); + const header = screen.getByRole("heading", { name: defaultUserId }); + + expect(header.getElementsByClassName("mx_E2EIcon")).toHaveLength(1); + }); + + it("renders custom user identifiers in the header", () => { + renderComponent(); + + expect(screen.getByText("customUserIdentifier")).toBeInTheDocument(); + }); +}); + +describe("", () => { + const device: IDevice = { deviceId: "deviceId", getDisplayName: () => "deviceName" }; + const defaultProps = { + userId: defaultUserId, + device, + }; + + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, + }); + }; + + const setMockUserTrust = (isVerified = false) => { + mockClient.checkUserTrust.mockReturnValue({ isVerified: () => isVerified } as UserTrustLevel); + }; + const setMockDeviceTrust = (isVerified = false, isCrossSigningVerified = false) => { + mockClient.checkDeviceTrust.mockReturnValue({ + isVerified: () => isVerified, + isCrossSigningVerified: () => isCrossSigningVerified, + } as DeviceTrustLevel); + }; + + const mockVerifyDevice = jest.spyOn(mockVerification, "verifyDevice"); + + beforeEach(() => { + setMockUserTrust(); + setMockDeviceTrust(); + }); + + afterEach(() => { + mockClient.checkDeviceTrust.mockReset(); + mockClient.checkUserTrust.mockReset(); + mockVerifyDevice.mockClear(); + }); + + afterAll(() => { + mockVerifyDevice.mockRestore(); + }); + + it("with unverified user and device, displays button without a label", () => { + renderComponent(); + + expect(screen.getByRole("button", { name: device.getDisplayName() })).toBeInTheDocument; + expect(screen.queryByText(/trusted/i)).not.toBeInTheDocument(); + }); + + it("with verified user only, displays button with a 'Not trusted' label", () => { + setMockUserTrust(true); + renderComponent(); + + expect(screen.getByRole("button", { name: `${device.getDisplayName()} Not trusted` })).toBeInTheDocument; + }); + + it("with verified device only, displays no button without a label", () => { + setMockDeviceTrust(true); + renderComponent(); + + expect(screen.getByText(device.getDisplayName())).toBeInTheDocument(); + expect(screen.queryByText(/trusted/)).not.toBeInTheDocument(); + }); + + it("when userId is the same as userId from client, uses isCrossSigningVerified to determine if button is shown", () => { + mockClient.getUserId.mockReturnValueOnce(defaultUserId); + renderComponent(); + + // set trust to be false for isVerified, true for isCrossSigningVerified + setMockDeviceTrust(false, true); + + // expect to see no button in this case + expect(screen.queryByRole("button")).not.toBeInTheDocument; + expect(screen.getByText(device.getDisplayName())).toBeInTheDocument(); + }); + + it("with verified user and device, displays no button and a 'Trusted' label", () => { + setMockUserTrust(true); + setMockDeviceTrust(true); + renderComponent(); + + expect(screen.queryByRole("button")).not.toBeInTheDocument; + expect(screen.getByText(device.getDisplayName())).toBeInTheDocument(); + expect(screen.getByText("Trusted")).toBeInTheDocument(); + }); + + it("does not call verifyDevice if client.getUser returns null", async () => { + mockClient.getUser.mockReturnValueOnce(null); + renderComponent(); + + const button = screen.getByRole("button", { name: device.getDisplayName() }); + expect(button).toBeInTheDocument; + await userEvent.click(button); + + expect(mockVerifyDevice).not.toHaveBeenCalled(); + }); + + it("calls verifyDevice if client.getUser returns an object", async () => { + mockClient.getUser.mockReturnValueOnce(defaultUser); + // set mock return of isGuest to short circuit verifyDevice call to avoid + // even more mocking + mockClient.isGuest.mockReturnValueOnce(true); + renderComponent(); + + const button = screen.getByRole("button", { name: device.getDisplayName() }); + expect(button).toBeInTheDocument; + await userEvent.click(button); + + expect(mockVerifyDevice).toHaveBeenCalledTimes(1); + expect(mockVerifyDevice).toHaveBeenCalledWith(defaultUser, device); + }); +}); + +describe("", () => { + const member = new RoomMember(mockRoom.roomId, defaultUserId); + const defaultProps = { member, isIgnored: false, canInvite: false, isSpace: false }; + + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, + }); + }; + + const inviteSpy = jest.spyOn(MultiInviter.prototype, "invite"); + + beforeEach(() => { + inviteSpy.mockReset(); + }); + + afterAll(() => { + inviteSpy.mockRestore(); + }); + + it("always shows share user button", () => { + renderComponent(); + expect(screen.getByRole("button", { name: /share link to user/i })).toBeInTheDocument(); + }); + + it("does not show ignore or direct message buttons when member userId matches client userId ", () => { + mockClient.getUserId.mockReturnValueOnce(member.userId); + renderComponent(); + + expect(screen.queryByRole("button", { name: /ignore/i })).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: /message/i })).not.toBeInTheDocument(); + }); + + it("shows ignore, direct message and mention buttons when member userId does not match client userId ", () => { + // call to client.getUserId returns undefined, which will not match member.userId + renderComponent(); + + expect(screen.getByRole("button", { name: /ignore/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /message/i })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /mention/i })).toBeInTheDocument(); + }); + + it("when call to client.getRoom is null, does not show read receipt button", () => { + mockClient.getRoom.mockReturnValueOnce(null); + renderComponent(); + + expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument(); + }); + + it("when call to client.getRoom is non-null and room.getEventReadUpTo is null, does not show read receipt button", () => { + mockRoom.getEventReadUpTo.mockReturnValueOnce(null); + mockClient.getRoom.mockReturnValueOnce(mockRoom); + renderComponent(); + + expect(screen.queryByRole("button", { name: /jump to read receipt/i })).not.toBeInTheDocument(); + }); + + it("when calls to client.getRoom and room.getEventReadUpTo are non-null, shows read receipt button", () => { + mockRoom.getEventReadUpTo.mockReturnValueOnce("1234"); + mockClient.getRoom.mockReturnValueOnce(mockRoom); + renderComponent(); + + expect(screen.getByRole("button", { name: /jump to read receipt/i })).toBeInTheDocument(); + }); + + it("clicking the read receipt button calls dispatch with correct event_id", async () => { + const mockEventId = "1234"; + mockRoom.getEventReadUpTo.mockReturnValue(mockEventId); + mockClient.getRoom.mockReturnValue(mockRoom); + renderComponent(); + + const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i }); + + expect(readReceiptButton).toBeInTheDocument(); + await userEvent.click(readReceiptButton); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: "view_room", + event_id: mockEventId, + highlighted: true, + metricsTrigger: undefined, + room_id: "!fkfk", + }); + + mockRoom.getEventReadUpTo.mockReset(); + mockClient.getRoom.mockReset(); + }); + + it("firing the read receipt event handler with a null event_id calls dispatch with undefined not null", async () => { + const mockEventId = "1234"; + // the first call is the check to see if we should render the button, second call is + // when the button is clicked + mockRoom.getEventReadUpTo.mockReturnValueOnce(mockEventId).mockReturnValueOnce(null); + mockClient.getRoom.mockReturnValue(mockRoom); + renderComponent(); + + const readReceiptButton = screen.getByRole("button", { name: /jump to read receipt/i }); + + expect(readReceiptButton).toBeInTheDocument(); + await userEvent.click(readReceiptButton); + expect(dis.dispatch).toHaveBeenCalledWith({ + action: "view_room", + event_id: undefined, + highlighted: true, + metricsTrigger: undefined, + room_id: "!fkfk", + }); + + mockClient.getRoom.mockReset(); + }); + + it("does not show the invite button when canInvite is false", () => { + renderComponent(); + expect(screen.queryByRole("button", { name: /invite/i })).not.toBeInTheDocument(); + }); + + it("shows the invite button when canInvite is true", () => { + renderComponent({ canInvite: true }); + expect(screen.getByRole("button", { name: /invite/i })).toBeInTheDocument(); + }); + + it("clicking the invite button will call MultiInviter.invite", async () => { + // to save mocking, we will reject the call to .invite + const mockErrorMessage = new Error("test error message"); + inviteSpy.mockRejectedValue(mockErrorMessage); + + // render the component and click the button + renderComponent({ canInvite: true }); + const inviteButton = screen.getByRole("button", { name: /invite/i }); + expect(inviteButton).toBeInTheDocument(); + await userEvent.click(inviteButton); + + // check that we have called .invite + expect(inviteSpy).toHaveBeenCalledWith([member.userId]); + + // check that the test error message is displayed + await waitFor(() => { + expect(screen.getByText(mockErrorMessage.message)).toBeInTheDocument(); + }); + }); + + it("if calling .invite throws something strange, show default error message", async () => { + inviteSpy.mockRejectedValue({ this: "could be anything" }); + + // render the component and click the button + renderComponent({ canInvite: true }); + const inviteButton = screen.getByRole("button", { name: /invite/i }); + expect(inviteButton).toBeInTheDocument(); + await userEvent.click(inviteButton); + + // check that the default test error message is displayed + await waitFor(() => { + expect(screen.getByText(/operation failed/i)).toBeInTheDocument(); + }); + }); + + it("calling .invite with a null roomId still calls .invite and shows default error message", async () => { + inviteSpy.mockRejectedValue({ this: "could be anything" }); + + // render the component and click the button + renderComponent({ canInvite: true, member: { ...member, roomId: null } }); + const inviteButton = screen.getByRole("button", { name: /invite/i }); + expect(inviteButton).toBeInTheDocument(); + await userEvent.click(inviteButton); + + expect(inviteSpy).toHaveBeenCalledTimes(1); + + // check that the default test error message is displayed + await waitFor(() => { + expect(screen.getByText(/operation failed/i)).toBeInTheDocument(); + }); + }); +}); + +describe("", () => { + const defaultMember = new RoomMember(mockRoom.roomId, defaultUserId); + + const defaultProps = { + user: defaultMember, + room: mockRoom, + roomPermissions: { + modifyLevelMax: 100, + canEdit: false, + canInvite: false, + }, + }; + + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, + }); + }; + + it("renders a power level combobox", () => { + renderComponent(); + + expect(screen.getByRole("combobox", { name: "Power level" })).toBeInTheDocument(); + }); + + it("renders a combobox and attempts to change power level on change of the combobox", async () => { + const startPowerLevel = 999; + const powerLevelEvent = new MatrixEvent({ + type: EventType.RoomPowerLevels, + content: { users: { [defaultUserId]: startPowerLevel }, users_default: 1 }, + }); + mockRoom.currentState.getStateEvents.mockReturnValue(powerLevelEvent); + mockClient.getUserId.mockReturnValueOnce(defaultUserId); + mockClient.setPowerLevel.mockResolvedValueOnce({ event_id: "123" }); + renderComponent(); + + const changedPowerLevel = 100; + + fireEvent.change(screen.getByRole("combobox", { name: "Power level" }), { + target: { value: changedPowerLevel }, + }); + + await screen.findByText("Demote", { exact: true }); + + // firing the event will raise a dialog warning about self demotion, wait for this to appear then click on it + await userEvent.click(await screen.findByText("Demote", { exact: true })); + expect(mockClient.setPowerLevel).toHaveBeenCalledTimes(1); + expect(mockClient.setPowerLevel).toHaveBeenCalledWith( + mockRoom.roomId, + defaultMember.userId, + changedPowerLevel, + powerLevelEvent, + ); + }); +}); + +describe("", () => { + const defaultMember = new RoomMember(mockRoom.roomId, defaultUserId); + const memberWithInviteMembership = { ...defaultMember, membership: "invite" }; + const memberWithJoinMembership = { ...defaultMember, membership: "join" }; + + const defaultProps = { room: mockRoom, member: defaultMember, startUpdating: jest.fn(), stopUpdating: jest.fn() }; + + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, + }); + }; + + const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog"); + + afterEach(() => { + createDialogSpy.mockReset(); + }); + + it("renders nothing if member.membership is undefined", () => { + // .membership is undefined in our member by default + const { container } = renderComponent(); + expect(container).toBeEmptyDOMElement(); + }); + + it("renders something if member.membership is 'invite' or 'join'", () => { + let result = renderComponent({ member: memberWithInviteMembership }); + expect(result.container).not.toBeEmptyDOMElement(); + + cleanup(); + + result = renderComponent({ member: memberWithJoinMembership }); + expect(result.container).not.toBeEmptyDOMElement(); + }); + + it("renders the correct label", () => { + // test for room + renderComponent({ member: memberWithJoinMembership }); + expect(screen.getByText(/remove from room/i)).toBeInTheDocument(); + cleanup(); + + renderComponent({ member: memberWithInviteMembership }); + expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument(); + cleanup(); + + // test for space + mockRoom.isSpaceRoom.mockReturnValue(true); + renderComponent({ member: memberWithJoinMembership }); + expect(screen.getByText(/remove from space/i)).toBeInTheDocument(); + cleanup(); + + renderComponent({ member: memberWithInviteMembership }); + expect(screen.getByText(/disinvite from space/i)).toBeInTheDocument(); + cleanup(); + mockRoom.isSpaceRoom.mockReturnValue(false); + }); + + it("clicking the kick button calls Modal.createDialog with the correct arguments", async () => { + createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() }); + + renderComponent({ member: memberWithInviteMembership }); + await userEvent.click(screen.getByText(/disinvite from/i)); + + // check the last call arguments and the presence of the spaceChildFilter callback + expect(createDialogSpy).toHaveBeenLastCalledWith( + expect.any(Function), + expect.objectContaining({ spaceChildFilter: expect.any(Function) }), + undefined, + ); + + // test the spaceChildFilter callback + const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter; + + // make dummy values for myMember and theirMember, then we will test + // null vs their member followed by + // my member vs their member + const mockMyMember = { powerLevel: 1 }; + const mockTheirMember = { membership: "invite", powerLevel: 0 }; + + const mockRoom = { + getMember: jest + .fn() + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockTheirMember) + .mockReturnValueOnce(mockMyMember) + .mockReturnValueOnce(mockTheirMember), + currentState: { + hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true), + }, + }; + + expect(callback(mockRoom)).toBe(null); + expect(callback(mockRoom)).toBe(true); + }); +}); + +describe("", () => { + const defaultMember = new RoomMember(mockRoom.roomId, defaultUserId); + const memberWithBanMembership = { ...defaultMember, membership: "ban" }; + + const defaultProps = { room: mockRoom, member: defaultMember, startUpdating: jest.fn(), stopUpdating: jest.fn() }; + + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, + }); + }; + + const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog"); + + afterEach(() => { + createDialogSpy.mockReset(); + }); + + it("renders the correct labels for banned and unbanned members", () => { + // test for room + // defaultMember is not banned + renderComponent(); + expect(screen.getByText("Ban from room")).toBeInTheDocument(); + cleanup(); + + renderComponent({ member: memberWithBanMembership }); + expect(screen.getByText("Unban from room")).toBeInTheDocument(); + cleanup(); + + // test for space + mockRoom.isSpaceRoom.mockReturnValue(true); + renderComponent(); + expect(screen.getByText("Ban from space")).toBeInTheDocument(); + cleanup(); + + renderComponent({ member: memberWithBanMembership }); + expect(screen.getByText("Unban from space")).toBeInTheDocument(); + cleanup(); + mockRoom.isSpaceRoom.mockReturnValue(false); + }); + + it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user is not banned", async () => { + createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() }); + + renderComponent(); + await userEvent.click(screen.getByText(/ban from/i)); + + // check the last call arguments and the presence of the spaceChildFilter callback + expect(createDialogSpy).toHaveBeenLastCalledWith( + expect.any(Function), + expect.objectContaining({ spaceChildFilter: expect.any(Function) }), + undefined, + ); + + // test the spaceChildFilter callback + const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter; + + // make dummy values for myMember and theirMember, then we will test + // null vs their member followed by + // truthy my member vs their member + const mockMyMember = { powerLevel: 1 }; + const mockTheirMember = { membership: "is not ban", powerLevel: 0 }; + + const mockRoom = { + getMember: jest + .fn() + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockTheirMember) + .mockReturnValueOnce(mockMyMember) + .mockReturnValueOnce(mockTheirMember), + currentState: { + hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true), + }, + }; + + expect(callback(mockRoom)).toBe(null); + expect(callback(mockRoom)).toBe(true); + }); + + it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user _is_ banned", async () => { + createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() }); + + renderComponent({ member: memberWithBanMembership }); + await userEvent.click(screen.getByText(/ban from/i)); + + // check the last call arguments and the presence of the spaceChildFilter callback + expect(createDialogSpy).toHaveBeenLastCalledWith( + expect.any(Function), + expect.objectContaining({ spaceChildFilter: expect.any(Function) }), + undefined, + ); + + // test the spaceChildFilter callback + const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter; + + // make dummy values for myMember and theirMember, then we will test + // null vs their member followed by + // my member vs their member + const mockMyMember = { powerLevel: 1 }; + const mockTheirMember = { membership: "ban", powerLevel: 0 }; + + const mockRoom = { + getMember: jest + .fn() + .mockReturnValueOnce(null) + .mockReturnValueOnce(mockTheirMember) + .mockReturnValueOnce(mockMyMember) + .mockReturnValueOnce(mockTheirMember), + currentState: { + hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true), + }, + }; + + expect(callback(mockRoom)).toBe(null); + expect(callback(mockRoom)).toBe(true); + }); +}); + +describe("", () => { + const defaultMember = new RoomMember(mockRoom.roomId, defaultUserId); + defaultMember.membership = "invite"; + + const defaultProps = { + room: mockRoom, + member: defaultMember, + startUpdating: jest.fn(), + stopUpdating: jest.fn(), + powerLevels: {}, + }; + + const renderComponent = (props = {}) => { + const Wrapper = (wrapperProps = {}) => { + return ; + }; + + return render(, { + wrapper: Wrapper, + }); + }; + + it("returns a single empty div if room.getMember is falsy", () => { + const { asFragment } = renderComponent(); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ + `); + }); + + it("can return a single empty div in case where room.getMember is not falsy", () => { + mockRoom.getMember.mockReturnValueOnce(defaultMember); + const { asFragment } = renderComponent(); + expect(asFragment()).toMatchInlineSnapshot(` + +
+ + `); + }); + + it("returns kick, redact messages, ban buttons if conditions met", () => { + const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId"); + mockMeMember.powerLevel = 51; // defaults to 50 + mockRoom.getMember.mockReturnValueOnce(mockMeMember); + + const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 }; + + renderComponent({ member: defaultMemberWithPowerLevel }); + + expect(screen.getByRole("heading", { name: /admin tools/i })).toBeInTheDocument(); + expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument(); + expect(screen.getByText(/ban from room/i)).toBeInTheDocument(); + expect(screen.getByText(/remove recent messages/i)).toBeInTheDocument(); + }); + + it("returns mute toggle button if conditions met", () => { + const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId"); + mockMeMember.powerLevel = 51; // defaults to 50 + mockRoom.getMember.mockReturnValueOnce(mockMeMember); + + const defaultMemberWithPowerLevelAndJoinMembership = { ...defaultMember, powerLevel: 0, membership: "join" }; + + renderComponent({ + member: defaultMemberWithPowerLevelAndJoinMembership, + powerLevels: { events: { "m.room.power_levels": 1 } }, + }); + + expect(screen.getByText(/mute/i)).toBeInTheDocument(); + }); +}); + +describe("disambiguateDevices", () => { + it("does not add ambiguous key to unique names", () => { + const initialDevices = [ + { deviceId: "id1", getDisplayName: () => "name1" }, + { deviceId: "id2", getDisplayName: () => "name2" }, + { deviceId: "id3", getDisplayName: () => "name3" }, + ]; + disambiguateDevices(initialDevices); + + // mutates input so assert against initialDevices + initialDevices.forEach((device) => { + expect(device).not.toHaveProperty("ambiguous"); + }); + }); + + it("adds ambiguous key to all ids with non-unique names", () => { + const uniqueNameDevices = [ + { deviceId: "id3", getDisplayName: () => "name3" }, + { deviceId: "id4", getDisplayName: () => "name4" }, + { deviceId: "id6", getDisplayName: () => "name6" }, + ]; + const nonUniqueNameDevices = [ + { deviceId: "id1", getDisplayName: () => "nonUnique" }, + { deviceId: "id2", getDisplayName: () => "nonUnique" }, + { deviceId: "id5", getDisplayName: () => "nonUnique" }, + ]; + const initialDevices = [...uniqueNameDevices, ...nonUniqueNameDevices]; + disambiguateDevices(initialDevices); + + // mutates input so assert against initialDevices + uniqueNameDevices.forEach((device) => { + expect(device).not.toHaveProperty("ambiguous"); + }); + nonUniqueNameDevices.forEach((device) => { + expect(device).toHaveProperty("ambiguous", true); + }); + }); +}); + +describe("isMuted", () => { + // this member has a power level of 0 + const isMutedMember = new RoomMember(mockRoom.roomId, defaultUserId); + + it("returns false if either argument is falsy", () => { + // @ts-ignore to let us purposely pass incorrect args + expect(isMuted(isMutedMember, null)).toBe(false); + // @ts-ignore to let us purposely pass incorrect args + expect(isMuted(null, {})).toBe(false); + }); + + it("when powerLevelContent.events and .events_default are undefined, returns false", () => { + const powerLevelContents = {}; + expect(isMuted(isMutedMember, powerLevelContents)).toBe(false); + }); + + it("when powerLevelContent.events is undefined, uses .events_default", () => { + const higherPowerLevelContents = { events_default: 10 }; + expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true); + + const lowerPowerLevelContents = { events_default: -10 }; + expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false); + }); + + it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => { + const higherPowerLevelContents = { events: {}, events_default: 10 }; + expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true); + + const lowerPowerLevelContents = { events: {}, events_default: -10 }; + expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false); + }); + + it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => { + const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 }; + expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(false); + + const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 }; + expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(true); + }); +}); + +describe("getPowerLevels", () => { + it("returns an empty object when room.currentState.getStateEvents return null", () => { + mockRoom.currentState.getStateEvents.mockReturnValueOnce(null); + expect(getPowerLevels(mockRoom)).toEqual({}); + }); +}); From df03112a3ba76e58ca2cf710aee233fef69c9492 Mon Sep 17 00:00:00 2001 From: Germain Date: Wed, 11 Jan 2023 11:33:06 +0000 Subject: [PATCH 19/29] Fix mock client missing user functions (#9887) --- test/components/views/settings/Notifications-test.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/test/components/views/settings/Notifications-test.tsx b/test/components/views/settings/Notifications-test.tsx index f3bb4abc30..b33f72838a 100644 --- a/test/components/views/settings/Notifications-test.tsx +++ b/test/components/views/settings/Notifications-test.tsx @@ -30,7 +30,7 @@ import { fireEvent, getByTestId, render, screen, waitFor } from "@testing-librar import Notifications from "../../../../src/components/views/settings/Notifications"; import SettingsStore from "../../../../src/settings/SettingsStore"; import { StandardActions } from "../../../../src/notifications/StandardActions"; -import { getMockClientWithEventEmitter, mkMessage } from "../../../test-utils"; +import { getMockClientWithEventEmitter, mkMessage, mockClientMethodsUser } from "../../../test-utils"; // don't pollute test output with error logs from mock rejections jest.mock("matrix-js-sdk/src/logger"); @@ -205,6 +205,7 @@ describe("", () => { }; const mockClient = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(), getPushRules: jest.fn(), getPushers: jest.fn(), getThreePids: jest.fn(), From da2640b7ba5b183a080d880d19665bf962ba3b72 Mon Sep 17 00:00:00 2001 From: Patrick Cloke Date: Wed, 11 Jan 2023 06:49:03 -0500 Subject: [PATCH 20/29] Display rooms & threads as unread (bold) if threads have unread messages. (#9763) Co-authored-by: Germain Co-authored-by: Germain Fixes https://github.com/vector-im/element-web/issues/23907 --- src/Unread.ts | 57 +-- .../views/right_panel/RoomHeaderButtons.tsx | 31 +- src/hooks/useUnreadNotifications.ts | 13 +- test/Unread-test.ts | 370 ++++++++++++++---- .../RoomGeneralContextMenu-test.tsx.snap | 44 +-- .../right_panel/RoomHeaderButtons-test.tsx | 93 ++++- .../components/views/rooms/EventTile-test.tsx | 3 +- .../UnreadNotificationBadge-test.tsx | 79 +++- .../__snapshots__/RoomTile-test.tsx.snap | 14 +- 9 files changed, 521 insertions(+), 183 deletions(-) diff --git a/src/Unread.ts b/src/Unread.ts index 17fe76f03f..cbd30b2bb8 100644 --- a/src/Unread.ts +++ b/src/Unread.ts @@ -15,6 +15,7 @@ limitations under the License. */ import { Room } from "matrix-js-sdk/src/models/room"; +import { Thread } from "matrix-js-sdk/src/models/thread"; import { MatrixEvent } from "matrix-js-sdk/src/models/event"; import { EventType } from "matrix-js-sdk/src/@types/event"; import { M_BEACON } from "matrix-js-sdk/src/@types/beacon"; @@ -59,35 +60,39 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { return false; } + for (const timeline of [room, ...room.getThreads()]) { + // If the current timeline has unread messages, we're done. + if (doesRoomOrThreadHaveUnreadMessages(timeline)) { + return true; + } + } + // If we got here then no timelines were found with unread messages. + return false; +} + +export function doesRoomOrThreadHaveUnreadMessages(roomOrThread: Room | Thread): boolean { + // If there are no messages yet in the timeline then it isn't fully initialised + // and cannot be unread. + if (!roomOrThread || roomOrThread.timeline.length === 0) { + return false; + } + const myUserId = MatrixClientPeg.get().getUserId(); + // as we don't send RRs for our own messages, make sure we special case that + // if *we* sent the last message into the room, we consider it not unread! + // Should fix: https://github.com/vector-im/element-web/issues/3263 + // https://github.com/vector-im/element-web/issues/2427 + // ...and possibly some of the others at + // https://github.com/vector-im/element-web/issues/3363 + if (roomOrThread.timeline.at(-1)?.getSender() === myUserId) { + return false; + } + // get the most recent read receipt sent by our account. // N.B. this is NOT a read marker (RM, aka "read up to marker"), // despite the name of the method :(( - const readUpToId = room.getEventReadUpTo(myUserId!); - - if (!SettingsStore.getValue("feature_threadstable")) { - // as we don't send RRs for our own messages, make sure we special case that - // if *we* sent the last message into the room, we consider it not unread! - // Should fix: https://github.com/vector-im/element-web/issues/3263 - // https://github.com/vector-im/element-web/issues/2427 - // ...and possibly some of the others at - // https://github.com/vector-im/element-web/issues/3363 - if (room.timeline.length && room.timeline[room.timeline.length - 1].getSender() === myUserId) { - return false; - } - } - - // if the read receipt relates to an event is that part of a thread - // we consider that there are no unread messages - // This might be a false negative, but probably the best we can do until - // the read receipts have evolved to cater for threads - if (readUpToId) { - const event = room.findEventById(readUpToId); - if (event?.getThread()) { - return false; - } - } + const readUpToId = roomOrThread.getEventReadUpTo(myUserId!); // this just looks at whatever history we have, which if we've only just started // up probably won't be very much, so if the last couple of events are ones that @@ -96,8 +101,8 @@ export function doesRoomHaveUnreadMessages(room: Room): boolean { // but currently we just guess. // Loop through messages, starting with the most recent... - for (let i = room.timeline.length - 1; i >= 0; --i) { - const ev = room.timeline[i]; + for (let i = roomOrThread.timeline.length - 1; i >= 0; --i) { + const ev = roomOrThread.timeline[i]; if (ev.getId() == readUpToId) { // If we've read up to this event, there's nothing more recent diff --git a/src/components/views/right_panel/RoomHeaderButtons.tsx b/src/components/views/right_panel/RoomHeaderButtons.tsx index d74ce4ba5c..f8a107dc5f 100644 --- a/src/components/views/right_panel/RoomHeaderButtons.tsx +++ b/src/components/views/right_panel/RoomHeaderButtons.tsx @@ -21,6 +21,7 @@ limitations under the License. import React from "react"; import classNames from "classnames"; import { NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { ThreadEvent } from "matrix-js-sdk/src/models/thread"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { _t } from "../../../languageHandler"; @@ -44,6 +45,7 @@ import { NotificationStateEvents } from "../../../stores/notifications/Notificat import PosthogTrackers from "../../../PosthogTrackers"; import { ButtonEvent } from "../elements/AccessibleButton"; import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import { doesRoomOrThreadHaveUnreadMessages } from "../../../Unread"; const ROOM_INFO_PHASES = [ RightPanelPhases.RoomSummary, @@ -154,7 +156,17 @@ export default class RoomHeaderButtons extends HeaderButtons { if (!this.supportsThreadNotifications) { this.threadNotificationState?.on(NotificationStateEvents.Update, this.onNotificationUpdate); } else { + // Notification badge may change if the notification counts from the + // server change, if a new thread is created or updated, or if a + // receipt is sent in the thread. this.props.room?.on(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Receipt, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Timeline, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.Redaction, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); + this.props.room?.on(RoomEvent.MyMembership, this.onNotificationUpdate); + this.props.room?.on(ThreadEvent.New, this.onNotificationUpdate); + this.props.room?.on(ThreadEvent.Update, this.onNotificationUpdate); } this.onNotificationUpdate(); RoomNotificationStateStore.instance.on(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); @@ -166,6 +178,13 @@ export default class RoomHeaderButtons extends HeaderButtons { this.threadNotificationState?.off(NotificationStateEvents.Update, this.onNotificationUpdate); } else { this.props.room?.off(RoomEvent.UnreadNotifications, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Receipt, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Timeline, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.Redaction, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.LocalEchoUpdated, this.onNotificationUpdate); + this.props.room?.off(RoomEvent.MyMembership, this.onNotificationUpdate); + this.props.room?.off(ThreadEvent.New, this.onNotificationUpdate); + this.props.room?.off(ThreadEvent.Update, this.onNotificationUpdate); } RoomNotificationStateStore.instance.off(UPDATE_STATUS_INDICATOR, this.onUpdateStatus); } @@ -191,9 +210,17 @@ export default class RoomHeaderButtons extends HeaderButtons { return NotificationColor.Red; case NotificationCountType.Total: return NotificationColor.Grey; - default: - return NotificationColor.None; } + // We don't have any notified messages, but we might have unread messages. Let's + // find out. + for (const thread of this.props.room!.getThreads()) { + // If the current thread has unread messages, we're done. + if (doesRoomOrThreadHaveUnreadMessages(thread)) { + return NotificationColor.Bold; + } + } + // Otherwise, no notification color. + return NotificationColor.None; } private onUpdateStatus = (notificationState: SummarizedNotificationState): void => { diff --git a/src/hooks/useUnreadNotifications.ts b/src/hooks/useUnreadNotifications.ts index bca2b0c2d4..22236d832f 100644 --- a/src/hooks/useUnreadNotifications.ts +++ b/src/hooks/useUnreadNotifications.ts @@ -15,12 +15,13 @@ limitations under the License. */ import { NotificationCount, NotificationCountType, Room, RoomEvent } from "matrix-js-sdk/src/models/room"; +import { Thread } from "matrix-js-sdk/src/models/thread"; import { useCallback, useEffect, useState } from "react"; import { getUnsentMessages } from "../components/structures/RoomStatusBar"; import { getRoomNotifsState, getUnreadNotificationCount, RoomNotifState } from "../RoomNotifs"; import { NotificationColor } from "../stores/notifications/NotificationColor"; -import { doesRoomHaveUnreadMessages } from "../Unread"; +import { doesRoomOrThreadHaveUnreadMessages } from "../Unread"; import { EffectiveMembership, getEffectiveMembership } from "../utils/membership"; import { useEventEmitter } from "./useEventEmitter"; @@ -75,12 +76,14 @@ export const useUnreadNotifications = ( setColor(NotificationColor.Red); } else if (greyNotifs > 0) { setColor(NotificationColor.Grey); - } else if (!threadId) { - // TODO: No support for `Bold` on threads at the moment - + } else { // We don't have any notified messages, but we might have unread messages. Let's // find out. - const hasUnread = doesRoomHaveUnreadMessages(room); + let roomOrThread: Room | Thread = room; + if (threadId) { + roomOrThread = room.getThread(threadId)!; + } + const hasUnread = doesRoomOrThreadHaveUnreadMessages(roomOrThread); setColor(hasUnread ? NotificationColor.Bold : NotificationColor.None); } } diff --git a/test/Unread-test.ts b/test/Unread-test.ts index 7a271354de..8ff759b142 100644 --- a/test/Unread-test.ts +++ b/test/Unread-test.ts @@ -15,100 +15,306 @@ limitations under the License. */ import { mocked } from "jest-mock"; -import { MatrixEvent, EventType, MsgType } from "matrix-js-sdk/src/matrix"; +import { MatrixEvent, EventType, MsgType, Room } from "matrix-js-sdk/src/matrix"; +import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import { haveRendererForEvent } from "../src/events/EventTileFactory"; -import { getMockClientWithEventEmitter, makeBeaconEvent, mockClientMethodsUser } from "./test-utils"; -import { eventTriggersUnreadCount } from "../src/Unread"; +import { makeBeaconEvent, mkEvent, stubClient } from "./test-utils"; +import { mkThread } from "./test-utils/threads"; +import { doesRoomHaveUnreadMessages, eventTriggersUnreadCount } from "../src/Unread"; +import { MatrixClientPeg } from "../src/MatrixClientPeg"; jest.mock("../src/events/EventTileFactory", () => ({ haveRendererForEvent: jest.fn(), })); -describe("eventTriggersUnreadCount()", () => { +describe("Unread", () => { + // A different user. const aliceId = "@alice:server.org"; - const bobId = "@bob:server.org"; + stubClient(); + const client = MatrixClientPeg.get(); - // mock user credentials - getMockClientWithEventEmitter({ - ...mockClientMethodsUser(bobId), - }); + describe("eventTriggersUnreadCount()", () => { + // setup events + const alicesMessage = new MatrixEvent({ + type: EventType.RoomMessage, + sender: aliceId, + content: { + msgtype: MsgType.Text, + body: "Hello from Alice", + }, + }); - // setup events - const alicesMessage = new MatrixEvent({ - type: EventType.RoomMessage, - sender: aliceId, - content: { - msgtype: MsgType.Text, - body: "Hello from Alice", - }, - }); + const ourMessage = new MatrixEvent({ + type: EventType.RoomMessage, + sender: client.getUserId()!, + content: { + msgtype: MsgType.Text, + body: "Hello from Bob", + }, + }); - const bobsMessage = new MatrixEvent({ - type: EventType.RoomMessage, - sender: bobId, - content: { - msgtype: MsgType.Text, - body: "Hello from Bob", - }, - }); - - const redactedEvent = new MatrixEvent({ - type: EventType.RoomMessage, - sender: aliceId, - }); - redactedEvent.makeRedacted(redactedEvent); - - beforeEach(() => { - jest.clearAllMocks(); - mocked(haveRendererForEvent).mockClear().mockReturnValue(false); - }); - - it("returns false when the event was sent by the current user", () => { - expect(eventTriggersUnreadCount(bobsMessage)).toBe(false); - // returned early before checking renderer - expect(haveRendererForEvent).not.toHaveBeenCalled(); - }); - - it("returns false for a redacted event", () => { - expect(eventTriggersUnreadCount(redactedEvent)).toBe(false); - // returned early before checking renderer - expect(haveRendererForEvent).not.toHaveBeenCalled(); - }); - - it("returns false for an event without a renderer", () => { - mocked(haveRendererForEvent).mockReturnValue(false); - expect(eventTriggersUnreadCount(alicesMessage)).toBe(false); - expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); - }); - - it("returns true for an event with a renderer", () => { - mocked(haveRendererForEvent).mockReturnValue(true); - expect(eventTriggersUnreadCount(alicesMessage)).toBe(true); - expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); - }); - - it("returns false for beacon locations", () => { - const beaconLocationEvent = makeBeaconEvent(aliceId); - expect(eventTriggersUnreadCount(beaconLocationEvent)).toBe(false); - expect(haveRendererForEvent).not.toHaveBeenCalled(); - }); - - const noUnreadEventTypes = [ - EventType.RoomMember, - EventType.RoomThirdPartyInvite, - EventType.CallAnswer, - EventType.CallHangup, - EventType.RoomCanonicalAlias, - EventType.RoomServerAcl, - ]; - - it.each(noUnreadEventTypes)("returns false without checking for renderer for events with type %s", (eventType) => { - const event = new MatrixEvent({ - type: eventType, + const redactedEvent = new MatrixEvent({ + type: EventType.RoomMessage, sender: aliceId, }); - expect(eventTriggersUnreadCount(event)).toBe(false); - expect(haveRendererForEvent).not.toHaveBeenCalled(); + redactedEvent.makeRedacted(redactedEvent); + + beforeEach(() => { + jest.clearAllMocks(); + mocked(haveRendererForEvent).mockClear().mockReturnValue(false); + }); + + it("returns false when the event was sent by the current user", () => { + expect(eventTriggersUnreadCount(ourMessage)).toBe(false); + // returned early before checking renderer + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }); + + it("returns false for a redacted event", () => { + expect(eventTriggersUnreadCount(redactedEvent)).toBe(false); + // returned early before checking renderer + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }); + + it("returns false for an event without a renderer", () => { + mocked(haveRendererForEvent).mockReturnValue(false); + expect(eventTriggersUnreadCount(alicesMessage)).toBe(false); + expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); + }); + + it("returns true for an event with a renderer", () => { + mocked(haveRendererForEvent).mockReturnValue(true); + expect(eventTriggersUnreadCount(alicesMessage)).toBe(true); + expect(haveRendererForEvent).toHaveBeenCalledWith(alicesMessage, false); + }); + + it("returns false for beacon locations", () => { + const beaconLocationEvent = makeBeaconEvent(aliceId); + expect(eventTriggersUnreadCount(beaconLocationEvent)).toBe(false); + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }); + + const noUnreadEventTypes = [ + EventType.RoomMember, + EventType.RoomThirdPartyInvite, + EventType.CallAnswer, + EventType.CallHangup, + EventType.RoomCanonicalAlias, + EventType.RoomServerAcl, + ]; + + it.each(noUnreadEventTypes)( + "returns false without checking for renderer for events with type %s", + (eventType) => { + const event = new MatrixEvent({ + type: eventType, + sender: aliceId, + }); + expect(eventTriggersUnreadCount(event)).toBe(false); + expect(haveRendererForEvent).not.toHaveBeenCalled(); + }, + ); + }); + + describe("doesRoomHaveUnreadMessages()", () => { + let room: Room; + let event: MatrixEvent; + const roomId = "!abc:server.org"; + const myId = client.getUserId()!; + + beforeAll(() => { + client.supportsExperimentalThreads = () => true; + }); + + beforeEach(() => { + // Create a room and initial event in it. + room = new Room(roomId, client, myId); + event = mkEvent({ + event: true, + type: "m.room.message", + user: aliceId, + room: roomId, + content: {}, + }); + room.addLiveEvents([event]); + + // Don't care about the code path of hidden events. + mocked(haveRendererForEvent).mockClear().mockReturnValue(true); + }); + + it("returns true for a room with no receipts", () => { + expect(doesRoomHaveUnreadMessages(room)).toBe(true); + }); + + it("returns false for a room when the latest event was sent by the current user", () => { + event = mkEvent({ + event: true, + type: "m.room.message", + user: myId, + room: roomId, + content: {}, + }); + // Only for timeline events. + room.addLiveEvents([event]); + + expect(doesRoomHaveUnreadMessages(room)).toBe(false); + }); + + it("returns false for a room when the read receipt is at the latest event", () => { + const receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + expect(doesRoomHaveUnreadMessages(room)).toBe(false); + }); + + it("returns true for a room when the read receipt is earlier than the latest event", () => { + const receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + const event2 = mkEvent({ + event: true, + type: "m.room.message", + user: aliceId, + room: roomId, + content: {}, + }); + // Only for timeline events. + room.addLiveEvents([event2]); + + expect(doesRoomHaveUnreadMessages(room)).toBe(true); + }); + + it("returns true for a room with an unread message in a thread", () => { + // Mark the main timeline as read. + const receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // Create a thread as a different user. + mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); + + expect(doesRoomHaveUnreadMessages(room)).toBe(true); + }); + + it("returns false for a room when the latest thread event was sent by the current user", () => { + // Mark the main timeline as read. + const receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // Create a thread as the current user. + mkThread({ room, client, authorId: myId, participantUserIds: [myId] }); + + expect(doesRoomHaveUnreadMessages(room)).toBe(false); + }); + + it("returns false for a room with read thread messages", () => { + // Mark the main timeline as read. + let receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // Create threads. + const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); + + // Mark the thread as read. + receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [events[events.length - 1].getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1, thread_id: rootEvent.getId()! }, + }, + }, + }, + }); + room.addReceipt(receipt); + + expect(doesRoomHaveUnreadMessages(room)).toBe(false); + }); + + it("returns true for a room when read receipt is not on the latest thread messages", () => { + // Mark the main timeline as read. + let receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + + // Create threads. + const { rootEvent, events } = mkThread({ room, client, authorId: myId, participantUserIds: [aliceId] }); + + // Mark the thread as read. + receipt = new MatrixEvent({ + type: "m.receipt", + room_id: "!foo:bar", + content: { + [events[0].getId()!]: { + [ReceiptType.Read]: { + [myId]: { ts: 1, threadId: rootEvent.getId()! }, + }, + }, + }, + }); + room.addReceipt(receipt); + + expect(doesRoomHaveUnreadMessages(room)).toBe(true); + }); }); }); diff --git a/test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap b/test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap index 8594b2f205..c1c49e5666 100644 --- a/test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap +++ b/test/components/views/context_menus/__snapshots__/RoomGeneralContextMenu-test.tsx.snap @@ -21,27 +21,7 @@ exports[`RoomGeneralContextMenu renders an empty context menu for archived rooms >
-
- - - Mark as read - - -
-
+ />
@@ -88,27 +68,7 @@ exports[`RoomGeneralContextMenu renders the default context menu 1`] = ` >
-
- - - Mark as read - - -
-
+ />
diff --git a/test/components/views/right_panel/RoomHeaderButtons-test.tsx b/test/components/views/right_panel/RoomHeaderButtons-test.tsx index f9a3572aa8..7d898904d8 100644 --- a/test/components/views/right_panel/RoomHeaderButtons-test.tsx +++ b/test/components/views/right_panel/RoomHeaderButtons-test.tsx @@ -15,15 +15,18 @@ limitations under the License. */ import { render } from "@testing-library/react"; +import { MatrixEvent, MsgType, RelationType } from "matrix-js-sdk/src/matrix"; import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; import { Feature, ServerSupport } from "matrix-js-sdk/src/feature"; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; +import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; import React from "react"; import RoomHeaderButtons from "../../../../src/components/views/right_panel/RoomHeaderButtons"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import SettingsStore from "../../../../src/settings/SettingsStore"; -import { stubClient } from "../../../test-utils"; +import { mkEvent, stubClient } from "../../../test-utils"; +import { mkThread } from "../../../test-utils/threads"; describe("RoomHeaderButtons-test.tsx", function () { const ROOM_ID = "!roomId:example.org"; @@ -35,6 +38,7 @@ describe("RoomHeaderButtons-test.tsx", function () { stubClient(); client = MatrixClientPeg.get(); + client.supportsExperimentalThreads = () => true; room = new Room(ROOM_ID, client, client.getUserId() ?? "", { pendingEventOrdering: PendingEventOrdering.Detached, }); @@ -48,12 +52,12 @@ describe("RoomHeaderButtons-test.tsx", function () { return render(); } - function getThreadButton(container) { + function getThreadButton(container: HTMLElement) { return container.querySelector(".mx_RightPanel_threadsButton"); } - function isIndicatorOfType(container, type: "red" | "gray") { - return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator").className.includes(type); + function isIndicatorOfType(container: HTMLElement, type: "red" | "gray" | "bold") { + return container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")!.className.includes(type); } it("shows the thread button", () => { @@ -76,7 +80,7 @@ describe("RoomHeaderButtons-test.tsx", function () { expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); }); - it("room wide notification does not change the thread button", () => { + it("thread notification does change the thread button", () => { const { container } = getComponent(room); room.setThreadUnreadNotificationCount("$123", NotificationCountType.Total, 1); @@ -91,6 +95,85 @@ describe("RoomHeaderButtons-test.tsx", function () { expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); }); + it("thread activity does change the thread button", async () => { + const { container } = getComponent(room); + + // Thread activity should appear on the icon. + const { rootEvent, events } = mkThread({ + room, + client, + authorId: client.getUserId()!, + participantUserIds: ["@alice:example.org"], + }); + expect(isIndicatorOfType(container, "bold")).toBe(true); + + // Sending the last event should clear the notification. + let event = mkEvent({ + event: true, + type: "m.room.message", + user: client.getUserId()!, + room: room.roomId, + content: { + "msgtype": MsgType.Text, + "body": "Test", + "m.relates_to": { + event_id: rootEvent.getId(), + rel_type: RelationType.Thread, + }, + }, + }); + room.addLiveEvents([event]); + await expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); + + // Mark it as unread again. + event = mkEvent({ + event: true, + type: "m.room.message", + user: "@alice:example.org", + room: room.roomId, + content: { + "msgtype": MsgType.Text, + "body": "Test", + "m.relates_to": { + event_id: rootEvent.getId(), + rel_type: RelationType.Thread, + }, + }, + }); + room.addLiveEvents([event]); + expect(isIndicatorOfType(container, "bold")).toBe(true); + + // Sending a read receipt on an earlier event shouldn't do anything. + let receipt = new MatrixEvent({ + type: "m.receipt", + room_id: room.roomId, + content: { + [events.at(-1)!.getId()!]: { + [ReceiptType.Read]: { + [client.getUserId()!]: { ts: 1, thread_id: rootEvent.getId() }, + }, + }, + }, + }); + room.addReceipt(receipt); + expect(isIndicatorOfType(container, "bold")).toBe(true); + + // Sending a receipt on the latest event should clear the notification. + receipt = new MatrixEvent({ + type: "m.receipt", + room_id: room.roomId, + content: { + [event.getId()!]: { + [ReceiptType.Read]: { + [client.getUserId()!]: { ts: 1, thread_id: rootEvent.getId() }, + }, + }, + }, + }); + room.addReceipt(receipt); + expect(container.querySelector(".mx_RightPanel_threadsButton .mx_Indicator")).toBeNull(); + }); + it("does not explode without a room", () => { client.canSupport.set(Feature.ThreadUnreadNotifications, ServerSupport.Unsupported); expect(() => getComponent()).not.toThrow(); diff --git a/test/components/views/rooms/EventTile-test.tsx b/test/components/views/rooms/EventTile-test.tsx index f425bc5aa5..7c46f6aa3a 100644 --- a/test/components/views/rooms/EventTile-test.tsx +++ b/test/components/views/rooms/EventTile-test.tsx @@ -141,9 +141,10 @@ describe("EventTile", () => { mxEvent = rootEvent; }); - it("shows an unread notification bage", () => { + it("shows an unread notification badge", () => { const { container } = getComponent({}, TimelineRenderingType.ThreadsList); + // By default, the thread will assume it is read. expect(container.getElementsByClassName("mx_NotificationBadge")).toHaveLength(0); act(() => { diff --git a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx index acffe31ff3..cfa4416576 100644 --- a/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx +++ b/test/components/views/rooms/NotificationBadge/UnreadNotificationBadge-test.tsx @@ -17,13 +17,15 @@ limitations under the License. import React from "react"; import "jest-mock"; import { screen, act, render } from "@testing-library/react"; -import { MatrixClient, PendingEventOrdering } from "matrix-js-sdk/src/client"; +import { MatrixEvent, MsgType, RelationType } from "matrix-js-sdk/src/matrix"; +import { PendingEventOrdering } from "matrix-js-sdk/src/client"; import { NotificationCountType, Room } from "matrix-js-sdk/src/models/room"; -import { mocked } from "jest-mock"; import { EventStatus } from "matrix-js-sdk/src/models/event-status"; +import { ReceiptType } from "matrix-js-sdk/src/@types/read_receipts"; +import { mkThread } from "../../../../test-utils/threads"; import { UnreadNotificationBadge } from "../../../../../src/components/views/rooms/NotificationBadge/UnreadNotificationBadge"; -import { mkMessage, stubClient } from "../../../../test-utils/test-utils"; +import { mkEvent, mkMessage, stubClient } from "../../../../test-utils/test-utils"; import { MatrixClientPeg } from "../../../../../src/MatrixClientPeg"; import * as RoomNotifs from "../../../../../src/RoomNotifs"; @@ -34,28 +36,57 @@ jest.mock("../../../../../src/RoomNotifs", () => ({ })); const ROOM_ID = "!roomId:example.org"; -let THREAD_ID; +let THREAD_ID: string; describe("UnreadNotificationBadge", () => { - let mockClient: MatrixClient; + stubClient(); + const client = MatrixClientPeg.get(); let room: Room; function getComponent(threadId?: string) { return ; } + beforeAll(() => { + client.supportsExperimentalThreads = () => true; + }); + beforeEach(() => { jest.clearAllMocks(); - stubClient(); - mockClient = mocked(MatrixClientPeg.get()); - - room = new Room(ROOM_ID, mockClient, mockClient.getUserId() ?? "", { + room = new Room(ROOM_ID, client, client.getUserId()!, { pendingEventOrdering: PendingEventOrdering.Detached, }); + + const receipt = new MatrixEvent({ + type: "m.receipt", + room_id: room.roomId, + content: { + "$event0:localhost": { + [ReceiptType.Read]: { + [client.getUserId()!]: { ts: 1, thread_id: "$otherthread:localhost" }, + }, + }, + "$event1:localhost": { + [ReceiptType.Read]: { + [client.getUserId()!]: { ts: 1 }, + }, + }, + }, + }); + room.addReceipt(receipt); + room.setUnreadNotificationCount(NotificationCountType.Total, 1); room.setUnreadNotificationCount(NotificationCountType.Highlight, 0); + const { rootEvent } = mkThread({ + room, + client, + authorId: client.getUserId()!, + participantUserIds: [client.getUserId()!], + }); + THREAD_ID = rootEvent.getId()!; + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 1); room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); @@ -125,4 +156,34 @@ describe("UnreadNotificationBadge", () => { const { container } = render(getComponent()); expect(container.querySelector(".mx_NotificationBadge")).toBeNull(); }); + + it("activity renders unread notification badge", () => { + act(() => { + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Total, 0); + room.setThreadUnreadNotificationCount(THREAD_ID, NotificationCountType.Highlight, 0); + + // Add another event on the thread which is not sent by us. + const event = mkEvent({ + event: true, + type: "m.room.message", + user: "@alice:server.org", + room: room.roomId, + content: { + "msgtype": MsgType.Text, + "body": "Hello from Bob", + "m.relates_to": { + event_id: THREAD_ID, + rel_type: RelationType.Thread, + }, + }, + ts: 5, + }); + room.addLiveEvents([event]); + }); + + const { container } = render(getComponent(THREAD_ID)); + expect(container.querySelector(".mx_NotificationBadge_dot")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_visible")).toBeTruthy(); + expect(container.querySelector(".mx_NotificationBadge_highlighted")).toBeFalsy(); + }); }); diff --git a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap index b4114bcb53..bcbb7932c6 100644 --- a/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap +++ b/test/components/views/rooms/__snapshots__/RoomTile-test.tsx.snap @@ -3,7 +3,7 @@ exports[`RoomTile should render the room 1`] = `
@@ -51,15 +51,7 @@ exports[`RoomTile should render the room 1`] = ` + />