diff --git a/.github/workflows/element-build-and-test.yaml b/.github/workflows/element-build-and-test.yaml index a104709415..9ed06bd8ad 100644 --- a/.github/workflows/element-build-and-test.yaml +++ b/.github/workflows/element-build-and-test.yaml @@ -71,6 +71,7 @@ jobs: # to run the tests, so use chrome. browser: chrome start: npx serve -p 8080 webapp + wait-on: 'http://localhost:8080' record: true command-prefix: 'yarn percy exec --' env: @@ -83,6 +84,8 @@ jobs: PERCY_BROWSER_EXECUTABLE: /usr/bin/chromium-browser # pass GitHub token to allow accurately detecting a build vs a re-run build GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # make Node's os.tmpdir() return something where we actually have permissions + TMPDIR: ${{ runner.temp }} - name: Upload Artifact if: failure() diff --git a/.gitignore b/.gitignore index e360df7767..7d257d7e9a 100644 --- a/.gitignore +++ b/.gitignore @@ -26,5 +26,4 @@ package-lock.json /cypress/synapselogs # These could have files in them but don't currently # Cypress will still auto-create them though... -/cypress/fixtures /cypress/performance diff --git a/cypress/fixtures/riot.png b/cypress/fixtures/riot.png new file mode 100644 index 0000000000..ee42954c78 Binary files /dev/null and b/cypress/fixtures/riot.png differ diff --git a/cypress/integration/5-threads/threads.spec.ts b/cypress/integration/5-threads/threads.spec.ts index 43b0058bb1..226e63576d 100644 --- a/cypress/integration/5-threads/threads.spec.ts +++ b/cypress/integration/5-threads/threads.spec.ts @@ -87,8 +87,8 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_BasicMessageComposer_input").type("Hello Mr. Bot{enter}"); // Wait for message to send, get its ID and save as @threadId - cy.get(".mx_RoomView_body .mx_EventTile").contains("Hello Mr. Bot") - .closest(".mx_EventTile[data-scroll-tokens]").invoke("attr", "data-scroll-tokens").as("threadId"); + cy.get(".mx_RoomView_body .mx_EventTile").contains(".mx_EventTile[data-scroll-tokens]", "Hello Mr. Bot") + .invoke("attr", "data-scroll-tokens").as("threadId"); // Bot starts thread cy.get("@threadId").then(threadId => { @@ -111,7 +111,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Test"); // User reacts to message instead - cy.get(".mx_ThreadView .mx_EventTile").contains("Hello there").closest(".mx_EventTile_line") + cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Hello there") .find('[aria-label="React"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_EmojiPicker").within(() => { cy.get('input[type="text"]').type("wave"); @@ -119,7 +119,7 @@ describe("Threads", () => { }); // User redacts their prior response - cy.get(".mx_ThreadView .mx_EventTile").contains("Test").closest(".mx_EventTile_line") + cy.get(".mx_ThreadView .mx_EventTile").contains(".mx_EventTile_line", "Test") .find('[aria-label="Options"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_IconizedContextMenu").within(() => { cy.get('[role="menuitem"]').contains("Remove").click(); @@ -166,7 +166,7 @@ describe("Threads", () => { cy.get(".mx_RoomView_body .mx_ThreadSummary .mx_ThreadSummary_content").should("contain", "Great!"); // User edits & asserts - cy.get(".mx_ThreadView .mx_EventTile_last").contains("Great!").closest(".mx_EventTile_line").within(() => { + cy.get(".mx_ThreadView .mx_EventTile_last").contains(".mx_EventTile_line", "Great!").within(() => { cy.get('[aria-label="Edit"]').click({ force: true }); // Cypress has no ability to hover cy.get(".mx_BasicMessageComposer_input").type(" How about yourself?{enter}"); }); diff --git a/cypress/integration/6-spaces/spaces.spec.ts b/cypress/integration/6-spaces/spaces.spec.ts new file mode 100644 index 0000000000..e5c03229bf --- /dev/null +++ b/cypress/integration/6-spaces/spaces.spec.ts @@ -0,0 +1,244 @@ +/* +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 type { MatrixClient } from "matrix-js-sdk/src/client"; +import type { ICreateRoomOpts } from "matrix-js-sdk/src/@types/requests"; +import { SynapseInstance } from "../../plugins/synapsedocker"; +import Chainable = Cypress.Chainable; +import { UserCredentials } from "../../support/login"; + +function openSpaceCreateMenu(): Chainable { + cy.get(".mx_SpaceButton_new").click(); + return cy.get(".mx_SpaceCreateMenu_wrapper .mx_ContextualMenu"); +} + +function getSpacePanelButton(spaceName: string): Chainable { + return cy.get(`.mx_SpaceButton[aria-label="${spaceName}"]`); +} + +function openSpaceContextMenu(spaceName: string): Chainable { + getSpacePanelButton(spaceName).rightclick(); + return cy.get(".mx_SpacePanel_contextMenu"); +} + +function spaceCreateOptions(spaceName: string): ICreateRoomOpts { + return { + creation_content: { + type: "m.space", + }, + initial_state: [{ + type: "m.room.name", + content: { + name: spaceName, + }, + }], + }; +} + +function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] { + return { + type: "m.space.child", + state_key: roomId, + content: { + via: [roomId.split(":")[1]], + }, + }; +} + +describe("Spaces", () => { + let synapse: SynapseInstance; + let user: UserCredentials; + + beforeEach(() => { + cy.startSynapse("default").then(data => { + synapse = data; + + cy.initTestUser(synapse, "Sue").then(_user => { + user = _user; + cy.mockClipboard(); + }); + }); + }); + + afterEach(() => { + cy.stopSynapse(synapse); + }); + + it("should allow user to create public space", () => { + openSpaceCreateMenu().within(() => { + cy.get(".mx_SpaceCreateMenuType_public").click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .selectFile("cypress/fixtures/riot.png", { force: true }); + cy.get('input[label="Name"]').type("Let's have a Riot"); + cy.get('input[label="Address"]').should("have.value", "lets-have-a-riot"); + cy.get('textarea[label="Description"]').type("This is a space to reminisce Riot.im!"); + cy.get(".mx_AccessibleButton").contains("Create").click(); + }); + + // Create the default General & Random rooms, as well as a custom "Jokes" room + cy.get('input[label="Room name"][value="General"]').should("exist"); + cy.get('input[label="Room name"][value="Random"]').should("exist"); + cy.get('input[placeholder="Support"]').type("Jokes"); + cy.get(".mx_AccessibleButton").contains("Continue").click(); + + // Copy matrix.to link + cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + cy.getClipboardText().should("eq", "https://matrix.to/#/#lets-have-a-riot:localhost"); + + // Go to space home + cy.get(".mx_AccessibleButton").contains("Go to my first room").click(); + + // Assert rooms exist in the room list + cy.get(".mx_RoomTile").contains("General").should("exist"); + cy.get(".mx_RoomTile").contains("Random").should("exist"); + cy.get(".mx_RoomTile").contains("Jokes").should("exist"); + }); + + it("should allow user to create private space", () => { + openSpaceCreateMenu().within(() => { + cy.get(".mx_SpaceCreateMenuType_private").click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .selectFile("cypress/fixtures/riot.png", { force: true }); + cy.get('input[label="Name"]').type("This is not a Riot"); + cy.get('input[label="Address"]').should("not.exist"); + cy.get('textarea[label="Description"]').type("This is a private space of mourning Riot.im..."); + cy.get(".mx_AccessibleButton").contains("Create").click(); + }); + + cy.get(".mx_SpaceRoomView_privateScope_meAndMyTeammatesButton").click(); + + // Create the default General & Random rooms, as well as a custom "Projects" room + cy.get('input[label="Room name"][value="General"]').should("exist"); + cy.get('input[label="Room name"][value="Random"]').should("exist"); + cy.get('input[placeholder="Support"]').type("Projects"); + cy.get(".mx_AccessibleButton").contains("Continue").click(); + + cy.get(".mx_SpaceRoomView").should("contain", "Invite your teammates"); + cy.get(".mx_AccessibleButton").contains("Skip for now").click(); + + // Assert rooms exist in the room list + cy.get(".mx_RoomTile").contains("General").should("exist"); + cy.get(".mx_RoomTile").contains("Random").should("exist"); + cy.get(".mx_RoomTile").contains("Projects").should("exist"); + + // Assert rooms exist in the space explorer + cy.get(".mx_SpaceHierarchy_roomTile").contains("General").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Random").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Projects").should("exist"); + }); + + it("should allow user to create just-me space", () => { + cy.createRoom({ + name: "Sample Room", + }); + + openSpaceCreateMenu().within(() => { + cy.get(".mx_SpaceCreateMenuType_private").click(); + cy.get('.mx_SpaceBasicSettings_avatarContainer input[type="file"]') + .selectFile("cypress/fixtures/riot.png", { force: true }); + cy.get('input[label="Address"]').should("not.exist"); + cy.get('textarea[label="Description"]').type("This is a personal space to mourn Riot.im..."); + cy.get('input[label="Name"]').type("This is my Riot{enter}"); + }); + + cy.get(".mx_SpaceRoomView_privateScope_justMeButton").click(); + + cy.get(".mx_AddExistingToSpace_entry").click(); + cy.get(".mx_AccessibleButton").contains("Add").click(); + + cy.get(".mx_RoomTile").contains("Sample Room").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Sample Room").should("exist"); + }); + + it("should allow user to invite another to a space", () => { + let bot: MatrixClient; + cy.getBot(synapse, "BotBob").then(_bot => { + bot = _bot; + }); + + cy.createSpace({ + visibility: "public" as any, + room_alias_name: "space", + }).as("spaceId"); + + openSpaceContextMenu("#space:localhost").within(() => { + cy.get('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]').click(); + }); + + cy.get(".mx_SpacePublicShare").within(() => { + // Copy link first + cy.get(".mx_SpacePublicShare_shareButton").focus().realClick(); + cy.getClipboardText().should("eq", "https://matrix.to/#/#space:localhost"); + // Start Matrix invite flow + cy.get(".mx_SpacePublicShare_inviteButton").click(); + }); + + cy.get(".mx_InviteDialog_other").within(() => { + cy.get('input[type="text"]').type(bot.getUserId()); + cy.get(".mx_AccessibleButton").contains("Invite").click(); + }); + + cy.get(".mx_InviteDialog_other").should("not.exist"); + }); + + it("should show space invites at the top of the space panel", () => { + cy.createSpace({ + name: "My Space", + }); + getSpacePanelButton("My Space").should("exist"); + + cy.getBot(synapse, "BotBob").then({ timeout: 10000 }, async bot => { + const { room_id: roomId } = await bot.createRoom(spaceCreateOptions("Space Space")); + await bot.invite(roomId, user.userId); + }); + // Assert that `Space Space` is above `My Space` due to it being an invite + getSpacePanelButton("Space Space").should("exist") + .parent().next().find('.mx_SpaceButton[aria-label="My Space"]').should("exist"); + }); + + it("should include rooms in space home", () => { + cy.createRoom({ + name: "Music", + }).as("roomId1"); + cy.createRoom({ + name: "Gaming", + }).as("roomId2"); + + const spaceName = "Spacey Mc. Space Space"; + cy.all([ + cy.get("@roomId1"), + cy.get("@roomId2"), + ]).then(([roomId1, roomId2]) => { + cy.createSpace({ + name: spaceName, + initial_state: [ + spaceChildInitialState(roomId1), + spaceChildInitialState(roomId2), + ], + }).as("spaceId"); + }); + + cy.get("@spaceId").then(() => { + getSpacePanelButton(spaceName).dblclick(); // Open space home + }); + cy.get(".mx_SpaceRoomView .mx_SpaceHierarchy_list").within(() => { + cy.get(".mx_SpaceHierarchy_roomTile").contains("Music").should("exist"); + cy.get(".mx_SpaceHierarchy_roomTile").contains("Gaming").should("exist"); + }); + }); +}); diff --git a/cypress/plugins/synapsedocker/index.ts b/cypress/plugins/synapsedocker/index.ts index 292c74ee67..7108ade904 100644 --- a/cypress/plugins/synapsedocker/index.ts +++ b/cypress/plugins/synapsedocker/index.ts @@ -66,9 +66,6 @@ async function cfgDirFromTemplate(template: string): Promise { } const tempDir = await fse.mkdtemp(path.join(os.tmpdir(), 'react-sdk-synapsedocker-')); - // change permissions on the temp directory so the docker container can see its contents - await fse.chmod(tempDir, 0o777); - // 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) !== 'homeserver.yaml' }); @@ -113,6 +110,7 @@ async function synapseStart(template: string): Promise { console.log(`Starting synapse with config dir ${synCfg.configDir}...`); const containerName = `react-sdk-cypress-synapse-${crypto.randomBytes(4).toString("hex")}`; + const userInfo = os.userInfo(); const synapseId = await new Promise((resolve, reject) => { childProcess.execFile('docker', [ @@ -121,6 +119,8 @@ async function synapseStart(template: string): Promise { "-d", "-v", `${synCfg.configDir}:/data`, "-p", `${synCfg.port}:8008/tcp`, + // We run the docker container as our uid:gid otherwise cleaning it up its media_store can be difficult + "-u", `${userInfo.uid}:${userInfo.gid}`, "matrixdotorg/synapse:develop", "run", ], (err, stdout) => { @@ -129,8 +129,6 @@ async function synapseStart(template: string): Promise { }); }); - synapses.set(synapseId, { synapseId, ...synCfg }); - console.log(`Started synapse with id ${synapseId} on port ${synCfg.port}.`); // Await Synapse healthcheck @@ -150,7 +148,9 @@ async function synapseStart(template: string): Promise { }); }); - return synapses.get(synapseId); + const synapse: SynapseInstance = { synapseId, ...synCfg }; + synapses.set(synapseId, synapse); + return synapse; } async function synapseStop(id: string): Promise { diff --git a/cypress/support/client.ts b/cypress/support/client.ts index 682f3ee426..6a6a393271 100644 --- a/cypress/support/client.ts +++ b/cypress/support/client.ts @@ -35,6 +35,12 @@ declare global { * @return the ID of the newly created room */ createRoom(options: ICreateRoomOpts): Chainable; + /** + * Create a space with given options. + * @param options the options to apply when creating the space + * @return the ID of the newly created space (room) + */ + createSpace(options: ICreateRoomOpts): Chainable; /** * Invites the given user to the given room. * @param roomId the id of the room to invite to @@ -71,6 +77,15 @@ Cypress.Commands.add("createRoom", (options: ICreateRoomOpts): Chainable }); }); +Cypress.Commands.add("createSpace", (options: ICreateRoomOpts): Chainable => { + return cy.createRoom({ + ...options, + creation_content: { + "type": "m.space", + }, + }); +}); + Cypress.Commands.add("inviteUser", (roomId: string, userId: string): Chainable<{}> => { return cy.getClient().then(async (cli: MatrixClient) => { return cli.invite(roomId, userId); diff --git a/cypress/support/clipboard.ts b/cypress/support/clipboard.ts new file mode 100644 index 0000000000..5e80ed8361 --- /dev/null +++ b/cypress/support/clipboard.ts @@ -0,0 +1,57 @@ +/* +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 Chainable = Cypress.Chainable; + +// Mock the clipboard, as only Electron gives the app permission to the clipboard API by default +// Virtual clipboard +let copyText: string; + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + interface Chainable { + /** + * Mock the clipboard on the current window, ready for calling `getClipboardText`. + * Irreversible, refresh the window to restore mock. + */ + mockClipboard(): Chainable; + /** + * Read text from the mocked clipboard. + * @return {string} the clipboard text + */ + getClipboardText(): Chainable; + } + } +} + +Cypress.Commands.add("mockClipboard", () => { + cy.window({ log: false }).then(win => { + win.navigator.clipboard.writeText = (text) => { + copyText = text; + return Promise.resolve(); + }; + }); +}); + +Cypress.Commands.add("getClipboardText", (): Chainable => { + return cy.wrap(copyText); +}); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/index.ts b/cypress/support/index.ts index dd8e5cab99..3f40ca198c 100644 --- a/cypress/support/index.ts +++ b/cypress/support/index.ts @@ -17,6 +17,7 @@ limitations under the License. /// import "@percy/cypress"; +import "cypress-real-events"; import "./performance"; import "./synapse"; @@ -24,3 +25,5 @@ import "./login"; import "./client"; import "./settings"; import "./bot"; +import "./clipboard"; +import "./util"; diff --git a/cypress/support/settings.ts b/cypress/support/settings.ts index 11f48c2db2..4be44e2711 100644 --- a/cypress/support/settings.ts +++ b/cypress/support/settings.ts @@ -16,7 +16,6 @@ limitations under the License. /// -import "./client"; // XXX: without an (any) import here, types break down import Chainable = Cypress.Chainable; declare global { @@ -99,3 +98,6 @@ Cypress.Commands.add("leaveBeta", (name: string): Chainable> return cy.get(".mx_BetaCard_buttons").contains("Leave the beta").click(); }); }); + +// Needed to make this file a module +export { }; diff --git a/cypress/support/util.ts b/cypress/support/util.ts new file mode 100644 index 0000000000..e8f48b4bcc --- /dev/null +++ b/cypress/support/util.ts @@ -0,0 +1,82 @@ +/* +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. +*/ + +/// + +// @see https://github.com/cypress-io/cypress/issues/915#issuecomment-475862672 +// Modified due to changes to `cy.queue` https://github.com/cypress-io/cypress/pull/17448 +// Note: this DOES NOT run Promises in parallel like `Promise.all` due to the nature +// of Cypress promise-like objects and command queue. This only makes it convenient to use the same +// API but runs the commands sequentially. + +declare global { + // eslint-disable-next-line @typescript-eslint/no-namespace + namespace Cypress { + type ChainableValue = T extends Cypress.Chainable ? V : T; + + interface cy { + all( + commands: T + ): Cypress.Chainable<{ [P in keyof T]: ChainableValue }>; + queue: any; + } + + interface Chainable { + chainerId: string; + } + } +} + +const chainStart = Symbol("chainStart"); + +/** + * @description Returns a single Chainable that resolves when all of the Chainables pass. + * @param {Cypress.Chainable[]} commands - List of Cypress.Chainable to resolve. + * @returns {Cypress.Chainable} Cypress when all Chainables are resolved. + */ +cy.all = function all(commands): Cypress.Chainable { + const chain = cy.wrap(null, { log: false }); + const stopCommand = Cypress._.find(cy.queue.get(), { + attributes: { chainerId: chain.chainerId }, + }); + const startCommand = Cypress._.find(cy.queue.get(), { + attributes: { chainerId: commands[0].chainerId }, + }); + const p = chain.then(() => { + return cy.wrap( + // @see https://lodash.com/docs/4.17.15#lodash + Cypress._(commands) + .map(cmd => { + return cmd[chainStart] + ? cmd[chainStart].attributes + : Cypress._.find(cy.queue.get(), { + attributes: { chainerId: cmd.chainerId }, + }).attributes; + }) + .concat(stopCommand.attributes) + .slice(1) + .map(cmd => { + return cmd.prev.get("subject"); + }) + .value(), + ); + }); + p[chainStart] = startCommand; + return p; +}; + +// Needed to make this file a module +export { }; diff --git a/package.json b/package.json index 6c964a0c64..c49d83bd25 100644 --- a/package.json +++ b/package.json @@ -169,6 +169,7 @@ "blob-polyfill": "^6.0.20211015", "chokidar": "^3.5.1", "cypress": "^9.6.1", + "cypress-real-events": "^1.7.0", "enzyme": "^3.11.0", "enzyme-to-json": "^3.6.2", "eslint": "8.9.0", diff --git a/src/actions/MatrixActionCreators.ts b/src/actions/MatrixActionCreators.ts index b889376336..a307e5b25e 100644 --- a/src/actions/MatrixActionCreators.ts +++ b/src/actions/MatrixActionCreators.ts @@ -18,6 +18,7 @@ import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/models/event"; import { Room, RoomEvent } from "matrix-js-sdk/src/models/room"; import { IRoomTimelineData } from "matrix-js-sdk/src/models/event-timeline-set"; +import { RoomState, RoomStateEvent } from "matrix-js-sdk/src/models/room-state"; import dis from "../dispatcher/dispatcher"; import { ActionPayload } from "../dispatcher/payloads"; @@ -175,6 +176,21 @@ export interface IRoomTimelineActionPayload extends Pick { + action: 'MatrixActions.RoomState.events'; + event: MatrixEvent; + state: RoomState; + lastStateEvent: MatrixEvent | null; +} + /** * Create a MatrixActions.Room.timeline action that represents a * MatrixClient `Room.timeline` matrix event, emitted when an event @@ -210,6 +226,31 @@ function createRoomTimelineAction( }; } +/** + * Create a MatrixActions.Room.timeline action that represents a + * MatrixClient `Room.timeline` matrix event, emitted when an event + * is added to or removed from a timeline of a room. + * + * @param {MatrixClient} matrixClient the matrix client. + * @param {MatrixEvent} event the state event received + * @param {RoomState} state the room state into which the event was applied + * @param {MatrixEvent | null} lastStateEvent the previous value for this (event-type, state-key) tuple in room state + * @returns {IRoomStateEventsActionPayload} an action of type `MatrixActions.RoomState.events`. + */ +function createRoomStateEventsAction( + matrixClient: MatrixClient, + event: MatrixEvent, + state: RoomState, + lastStateEvent: MatrixEvent | null, +): IRoomStateEventsActionPayload { + return { + action: 'MatrixActions.RoomState.events', + event, + state, + lastStateEvent, + }; +} + /** * @typedef RoomMembershipAction * @type {Object} @@ -312,6 +353,7 @@ export default { addMatrixClientListener(matrixClient, RoomEvent.Timeline, createRoomTimelineAction); addMatrixClientListener(matrixClient, RoomEvent.MyMembership, createSelfMembershipAction); addMatrixClientListener(matrixClient, MatrixEventEvent.Decrypted, createEventDecryptedAction); + addMatrixClientListener(matrixClient, RoomStateEvent.Events, createRoomStateEventsAction); }, /** diff --git a/src/components/structures/MatrixChat.tsx b/src/components/structures/MatrixChat.tsx index a41f8000a6..b143fc7448 100644 --- a/src/components/structures/MatrixChat.tsx +++ b/src/components/structures/MatrixChat.tsx @@ -131,6 +131,7 @@ import { IConfigOptions } from "../../IConfigOptions"; import { SnakedObject } from "../../utils/SnakedObject"; import { leaveRoomBehaviour } from "../../utils/leave-behaviour"; import VideoChannelStore from "../../stores/VideoChannelStore"; +import { IRoomStateEventsActionPayload } from "../../actions/MatrixActionCreators"; // legacy export export { default as Views } from "../../Views"; @@ -651,6 +652,20 @@ export default class MatrixChat extends React.PureComponent { case 'view_user_info': this.viewUser(payload.userId, payload.subAction); break; + case "MatrixActions.RoomState.events": { + const event = (payload as IRoomStateEventsActionPayload).event; + if (event.getType() === EventType.RoomCanonicalAlias && + event.getRoomId() === this.state.currentRoomId + ) { + // re-view the current room so we can update alias/id in the URL properly + this.viewRoom({ + action: Action.ViewRoom, + room_id: this.state.currentRoomId, + metricsTrigger: undefined, // room doesn't change + }); + } + break; + } case Action.ViewRoom: { // Takes either a room ID or room alias: if switching to a room the client is already // known to be in (eg. user clicks on a room in the recents panel), supply the ID @@ -891,9 +906,7 @@ export default class MatrixChat extends React.PureComponent { // Store this as the ID of the last room accessed. This is so that we can // persist which room is being stored across refreshes and browser quits. - if (localStorage) { - localStorage.setItem('mx_last_room_id', room.roomId); - } + localStorage?.setItem('mx_last_room_id', room.roomId); } // If we are redirecting to a Room Alias and it is for the room we already showing then replace history item diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 86f581512c..614bbee387 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -1137,15 +1137,6 @@ export class RoomView extends React.Component { if (!this.state.room || this.state.room.roomId !== state.roomId) return; switch (ev.getType()) { - case EventType.RoomCanonicalAlias: - // re-view the room so MatrixChat can manage the alias in the URL properly - dis.dispatch({ - action: Action.ViewRoom, - room_id: this.state.room.roomId, - metricsTrigger: undefined, // room doesn't change - }); - break; - case EventType.RoomTombstone: this.setState({ tombstone: this.getRoomTombstone() }); break; diff --git a/src/components/structures/SpaceHierarchy.tsx b/src/components/structures/SpaceHierarchy.tsx index 0d0dd7ba34..9d7c737c5f 100644 --- a/src/components/structures/SpaceHierarchy.tsx +++ b/src/components/structures/SpaceHierarchy.tsx @@ -524,8 +524,13 @@ export const useRoomHierarchy = (space: Room): { setRooms(hierarchy.rooms); }, [error, hierarchy]); - const loading = hierarchy?.loading ?? true; - return { loading, rooms, hierarchy, loadMore, error }; + return { + loading: hierarchy?.loading ?? true, + rooms, + hierarchy: hierarchy?.root === space ? hierarchy : undefined, + loadMore, + error, + }; }; const useIntersectionObserver = (callback: () => void) => { diff --git a/src/components/structures/SpaceRoomView.tsx b/src/components/structures/SpaceRoomView.tsx index ff77789802..4f7c801924 100644 --- a/src/components/structures/SpaceRoomView.tsx +++ b/src/components/structures/SpaceRoomView.tsx @@ -60,7 +60,7 @@ import { defaultDmsRenderer, defaultRoomsRenderer, } from "../views/dialogs/AddExistingToSpaceDialog"; -import AccessibleButton from "../views/elements/AccessibleButton"; +import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import AccessibleTooltipButton from "../views/elements/AccessibleTooltipButton"; import ErrorBoundary from "../views/elements/ErrorBoundary"; import Field from "../views/elements/Field"; @@ -295,7 +295,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { />; }); - const onNextClick = async (ev) => { + const onNextClick = async (ev: ButtonEvent) => { ev.preventDefault(); if (busy) return; setError(""); @@ -326,7 +326,7 @@ const SpaceSetupFirstRooms = ({ space, title, description, onFinished }) => { setBusy(false); }; - let onClick = (ev) => { + let onClick = (ev: ButtonEvent) => { ev.preventDefault(); onFinished(); }; diff --git a/test/end-to-end-tests/src/scenario.ts b/test/end-to-end-tests/src/scenario.ts index dc6e1309d7..b0ead39167 100644 --- a/test/end-to-end-tests/src/scenario.ts +++ b/test/end-to-end-tests/src/scenario.ts @@ -24,7 +24,6 @@ import { e2eEncryptionScenarios } from './scenarios/e2e-encryption'; import { ElementSession } from "./session"; import { RestSessionCreator } from "./rest/creator"; import { RestMultiSession } from "./rest/multi"; -import { spacesScenarios } from './scenarios/spaces'; import { RestSession } from "./rest/session"; import { stickerScenarios } from './scenarios/sticker'; import { userViewScenarios } from "./scenarios/user-view"; @@ -56,8 +55,6 @@ export async function scenario(createSession: (s: string) => Promise { - console.log(" creating a space for spaces scenarios:"); - - await alice.delay(1000); // wait for dialogs to close - await setupSpaceUsingAliceAndInviteBob(alice, bob); -} - -const space = "Test Space"; - -async function setupSpaceUsingAliceAndInviteBob(alice: ElementSession, bob: ElementSession): Promise { - await createSpace(alice, space); - await inviteSpace(alice, space, "@bob:localhost"); - await bob.query(`.mx_SpaceButton[aria-label="${space}"]`); // assert invite received -} diff --git a/test/end-to-end-tests/src/usecases/create-space.ts b/test/end-to-end-tests/src/usecases/create-space.ts deleted file mode 100644 index 3fa2730f57..0000000000 --- a/test/end-to-end-tests/src/usecases/create-space.ts +++ /dev/null @@ -1,82 +0,0 @@ -/* -Copyright 2021 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 { ElementSession } from "../session"; - -export async function openSpaceCreateMenu(session: ElementSession): Promise { - const spaceCreateButton = await session.query('.mx_SpaceButton_new'); - await spaceCreateButton.click(); -} - -export async function createSpace(session: ElementSession, name: string, isPublic = false): Promise { - session.log.step(`creates space "${name}"`); - - await openSpaceCreateMenu(session); - const className = isPublic ? ".mx_SpaceCreateMenuType_public" : ".mx_SpaceCreateMenuType_private"; - const visibilityButton = await session.query(className); - await visibilityButton.click(); - - const nameInput = await session.query('input[name="spaceName"]'); - await session.replaceInputText(nameInput, name); - - await session.delay(100); - - const createButton = await session.query('.mx_SpaceCreateMenu_wrapper .mx_AccessibleButton_kind_primary'); - await createButton.click(); - - if (!isPublic) { - const justMeButton = await session.query('.mx_SpaceRoomView_privateScope_justMeButton'); - await justMeButton.click(); - const continueButton = await session.query('.mx_AddExistingToSpace_footer .mx_AccessibleButton_kind_primary'); - await continueButton.click(); - } else { - for (let i = 0; i < 2; i++) { - const continueButton = await session.query('.mx_SpaceRoomView_buttons .mx_AccessibleButton_kind_primary'); - await continueButton.click(); - } - } - - session.log.done(); -} - -export async function inviteSpace(session: ElementSession, spaceName: string, userId: string): Promise { - session.log.step(`invites "${userId}" to space "${spaceName}"`); - - const spaceButton = await session.query(`.mx_SpaceButton[aria-label="${spaceName}"]`); - await spaceButton.click({ - button: 'right', - }); - - const inviteButton = await session.query('.mx_SpacePanel_contextMenu_inviteButton[aria-label="Invite"]'); - await inviteButton.click(); - - try { - // You only get this interstitial if it's a public space, so give up after 200ms - // if it hasn't appeared - const button = await session.query('.mx_SpacePublicShare_inviteButton', 200); - await button.click(); - } catch (e) { - // ignore - } - - const inviteTextArea = await session.query(".mx_InviteDialog_editor input"); - await inviteTextArea.type(userId); - const selectUserItem = await session.query(".mx_InviteDialog_roomTile"); - await selectUserItem.click(); - const confirmButton = await session.query(".mx_InviteDialog_goButton"); - await confirmButton.click(); - session.log.done(); -} diff --git a/yarn.lock b/yarn.lock index 9522c92cab..3959d6c847 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3492,6 +3492,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== +cypress-real-events@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/cypress-real-events/-/cypress-real-events-1.7.0.tgz#ad6a78de33af3af0e6437f5c713e30691c44472c" + integrity sha512-iyXp07j0V9sG3YClVDcvHN2DAQDgr+EjTID82uWDw6OZBlU3pXEBqTMNYqroz3bxlb0k+F74U81aZwzMNaKyew== + cypress@^9.6.1: version "9.6.1" resolved "https://registry.yarnpkg.com/cypress/-/cypress-9.6.1.tgz#a7d6b5a53325b3dc4960181f5800a5ade0f085eb"