Merge branch 'develop' into gsouquet/threads-forceenablelabsflag

This commit is contained in:
Germain 2023-01-11 11:51:57 +00:00
commit d4f247d1fe
97 changed files with 3280 additions and 1325 deletions

1
.gitignore vendored
View file

@ -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

View file

@ -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,

View file

@ -16,25 +16,25 @@ limitations under the License.
/// <reference types="cypress" />
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");

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import { HomeserverInstance } from "../../plugins/utils/homeserver";
import Chainable = Cypress.Chainable;
function openCreateRoomDialog(): Chainable<JQuery<HTMLElement>> {
@ -26,18 +26,18 @@ function openCreateRoomDialog(): Chainable<JQuery<HTMLElement>> {
}
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", () => {

View file

@ -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);

View file

@ -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<Emoj
};
describe("Decryption Failure Bar", () => {
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({});

View file

@ -16,24 +16,26 @@ limitations under the License.
/// <reference types="cypress" />
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 { HomeserverInstance } from "../../plugins/utils/homeserver";
import Chainable = Cypress.Chainable;
const sendEvent = (roomId: string): Chainable<ISendEventResponse> => {
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", () => {
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");
});
@ -41,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", () => {

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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();
});

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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();
});

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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();
});

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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();
});

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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";

View file

@ -16,11 +16,11 @@ limitations under the License.
/// <reference types="cypress" />
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<JQuery> => {
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", () => {

View file

@ -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<SinonStub>("@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();

View file

@ -16,17 +16,17 @@ limitations under the License.
/// <reference types="cypress" />
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");
});
});

View file

@ -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;
});

View file

@ -16,21 +16,21 @@ limitations under the License.
/// <reference types="cypress" />
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");

View file

@ -16,21 +16,21 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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<JQuery<HTMLElement>> => {
};
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", () => {

View file

@ -16,23 +16,23 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -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;

View file

@ -16,34 +16,34 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -16,10 +16,10 @@ limitations under the License.
/// <reference types="cypress" />
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);
});
});

View file

@ -20,20 +20,21 @@ 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<SynapseInstance>("@synapse"), cy.get<ProxyInstance>("@proxy")]).then(([synapse, proxy]) => {
cy.all([cy.get<HomeserverInstance>("@homeserver"), cy.get<ProxyInstance>("@proxy")]).then(
([homeserver, proxy]) => {
cy.enableLabsFeature("feature_sliding_sync");
cy.intercept("/config.json?cachebuster=*", (req) => {
@ -47,16 +48,17 @@ describe("Sliding Sync", () => {
});
});
cy.initTestUser(synapse, "Sloth").then(() => {
cy.initTestUser(homeserver, "Sloth").then(() => {
return cy.window({ log: false }).then(() => {
cy.createRoom({ name: "Test Room" }).as("roomId");
});
});
});
},
);
});
afterEach(() => {
cy.get<SynapseInstance>("@synapse").then(cy.stopSynapse);
cy.get<HomeserverInstance>("@homeserver").then(cy.stopHomeserver);
cy.get<ProxyInstance>("@proxy").then(cy.stopProxy);
});
@ -84,9 +86,9 @@ describe("Sliding Sync", () => {
};
const createAndJoinBob = () => {
// create a Bob user
cy.get<SynapseInstance>("@synapse").then((synapse) => {
cy.get<HomeserverInstance>("@homeserver").then((homeserver) => {
return cy
.getBot(synapse, {
.getBot(homeserver, {
displayName: "Bob",
})
.as("bob");

View file

@ -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);
});

View file

@ -17,7 +17,7 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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) => {

View file

@ -16,11 +16,9 @@ limitations under the License.
/// <reference types="cypress" />
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 type { EventType, MsgType } from "matrix-js-sdk/src/@types/event";
import { HomeserverInstance } from "../../plugins/utils/homeserver";
import { SettingLevel } from "../../../src/settings/SettingLevel";
import { Layout } from "../../../src/settings/enums/Layout";
import Chainable = Cypress.Chainable;
@ -55,16 +53,21 @@ const expectAvatar = (e: JQuery<HTMLElement>, avatarUrl: string): void => {
};
const sendEvent = (roomId: string, html = false): Chainable<ISendEventResponse> => {
return cy.sendEvent(
roomId,
null,
"m.room.message" as EventType,
MessageEvent.from("Message", html ? "<b>Message</b>" : 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 = "<b>Message</b>";
}
return cy.sendEvent(roomId, null, "m.room.message" as EventType, content);
};
describe("Timeline", () => {
let synapse: SynapseInstance;
let homeserver: HomeserverInstance;
let roomId: string;
@ -72,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;
}),
@ -83,7 +86,7 @@ describe("Timeline", () => {
});
afterEach(() => {
cy.stopSynapse(synapse);
cy.stopHomeserver(homeserver);
});
describe("useOnlyCurrentProfiles", () => {
@ -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");

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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");
});
});

View file

@ -16,19 +16,19 @@ limitations under the License.
/// <reference types="cypress" />
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()

View file

@ -16,25 +16,25 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -17,18 +17,18 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -16,15 +16,15 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -16,23 +16,23 @@ limitations under the License.
/// <reference types="cypress" />
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", () => {

View file

@ -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();
});

View file

@ -16,7 +16,7 @@ limitations under the License.
/// <reference types="cypress" />
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();
});

View file

@ -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();
});

View file

@ -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.
*/
/// <reference types="cypress" />
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<string, HomeserverInstance>();
function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}
async function cfgDirFromTemplate(template: string): Promise<HomeserverConfig> {
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<HomeserverInstance> {
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<void> {
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"));
});
}

View file

@ -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

View file

@ -30,7 +30,7 @@ export function dockerRun(opts: {
image: string;
containerName: string;
params?: string[];
cmd?: string;
cmd?: string[];
}): Promise<string> {
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<string>((resolve, reject) => {
childProcess.execFile("docker", args, (err, stdout) => {

View file

@ -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);

View file

@ -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<string, ProxyInstance>();
const PG_PASSWORD = "p4S5w0rD";
async function proxyStart(dockerTag: string, synapse: SynapseInstance): Promise<ProxyInstance> {
async function proxyStart(dockerTag: string, homeserver: HomeserverInstance): Promise<ProxyInstance> {
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`,
],

View file

@ -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<string, SynapseInstance>();
const synapses = new Map<string, HomeserverInstance>();
function randB64Bytes(numBytes: number): string {
return crypto.randomBytes(numBytes).toString("base64").replace(/=*$/, "");
}
async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
async function cfgDirFromTemplate(template: string): Promise<HomeserverConfig> {
const templateDir = path.join(__dirname, "templates", template);
const stats = await fse.stat(templateDir);
@ -94,7 +83,7 @@ async function cfgDirFromTemplate(template: string): Promise<SynapseConfig> {
// 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<SynapseInstance> {
async function synapseStart(template: string): Promise<HomeserverInstance> {
const synCfg = await cfgDirFromTemplate(template);
console.log(`Starting synapse with config dir ${synCfg.configDir}...`);
@ -103,7 +92,7 @@ async function synapseStart(template: string): Promise<SynapseInstance> {
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<SynapseInstance> {
],
});
const synapse: SynapseInstance = { synapseId, ...synCfg };
const synapse: HomeserverInstance = { serverId: synapseId, ...synCfg };
synapses.set(synapseId, synapse);
return synapse;
}

View file

@ -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.
*/
/// <reference types="cypress" />
export interface HomeserverConfig {
configDir: string;
registrationSecret: string;
baseUrl: string;
port: number;
}
export interface HomeserverInstance extends HomeserverConfig {
serverId: string;
}

View file

@ -17,8 +17,8 @@ limitations under the License.
/// <reference types="cypress" />
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<CypressBot>;
getBot(homeserver: HomeserverInstance, opts: CreateBotOpts): Chainable<CypressBot>;
/**
* 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<MatrixClient> {
@ -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<CypressBot> => {
Cypress.Commands.add("getBot", (homeserver: HomeserverInstance, opts: CreateBotOpts): Chainable<CypressBot> => {
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<CypressBot> => {
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<MatrixClient> => {
(
homeserver: HomeserverInstance,
username: string,
password: string,
opts: CreateBotOpts,
): Chainable<MatrixClient> => {
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);
});
},
);

View file

@ -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";

View file

@ -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<SynapseInstance>;
startHomeserver(template: string): Chainable<HomeserverInstance>;
/**
* 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<AUTWindow>;
stopHomeserver(homeserver: HomeserverInstance): Chainable<AUTWindow>;
/**
* 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<SynapseInstance> {
return cy.task<SynapseInstance>("synapseStart", template);
function startHomeserver(template: string): Chainable<HomeserverInstance> {
const homeserverName = Cypress.env("HOMESERVER");
return cy.task<HomeserverInstance>(homeserverName + "Start", template);
}
function stopSynapse(synapse?: SynapseInstance): Chainable<AUTWindow> {
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<AUTWindow> {
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<Credentials> {
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);

View file

@ -17,7 +17,7 @@ limitations under the License.
/// <reference types="cypress" />
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<UserCredentials>;
loginUser(synapse: HomeserverInstance, username: string, password: string): Chainable<UserCredentials>;
}
}
}
@ -60,8 +60,8 @@ declare global {
// eslint-disable-next-line max-len
Cypress.Commands.add(
"loginUser",
(synapse: SynapseInstance, username: string, password: string): Chainable<UserCredentials> => {
const url = `${synapse.baseUrl}/_matrix/client/r0/login`;
(homeserver: HomeserverInstance, username: string, password: string): Chainable<UserCredentials> => {
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);

View file

@ -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<ProxyInstance>;
startProxy(homeserver: HomeserverInstance): Chainable<ProxyInstance>;
/**
* Custom command wrapping task:proxyStop whilst preventing uncaught exceptions
@ -41,13 +41,13 @@ declare global {
}
}
function startProxy(synapse: SynapseInstance): Chainable<ProxyInstance> {
return cy.task<ProxyInstance>("proxyStart", synapse);
function startProxy(homeserver: HomeserverInstance): Chainable<ProxyInstance> {
return cy.task<ProxyInstance>("proxyStart", homeserver);
}
function stopProxy(proxy?: ProxyInstance): Chainable<AUTWindow> {
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);

View file

@ -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

View file

@ -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",

View file

@ -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; /* needed to make footer & shadow appear above dialog content */
}
}

View file

@ -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;
}
}
}
}

View file

@ -4,31 +4,14 @@
height="20"
viewBox="0 0 20 20"
fill="none"
xmlns="http://www.w3.org/2000/svg"
>
<g
clip-path="url(#clip0_1456_146365)"
id="g53">
<path
d="M7.00042 13.7333H13.6671L14.5471 15.8667C14.7471 16.3467 15.2137 16.6667 15.7337 16.6667C16.6537 16.6667 17.2671 15.72 16.9071 14.88L11.7337 2.92C11.4937 2.36 10.9471 2 10.3337 2C9.72042 2 9.17375 2.36 8.93375 2.92L3.76042 14.88C3.40042 15.72 4.02708 16.6667 4.94708 16.6667C5.46708 16.6667 5.93375 16.3467 6.13375 15.8667L7.00042 13.7333ZM10.3337 4.64L12.8271 11.3333H7.84042L10.3337 4.64Z"
fill="#C1C6CD"
id="path49" />
<path
d="m 1.497495,8.96927 c 0,0.793654 0.7402877,1.441437 1.6473569,1.441437 H 17.521786 c 0.907096,0 1.647419,-0.647783 1.647419,-1.441437 0,-0.7936857 -0.740323,-1.4414375 -1.647419,-1.4414375 H 11.127487 3.1448519 c -0.4734211,0 -0.9014103,0.1764504 -1.2024293,0.4580061 C 1.7722258,8.1450309 1.6426187,8.3378225 1.568339,8.5513189 1.522281,8.6837006 1.497495,8.8240421 1.497495,8.96927 Z"
fill="#c1c6cd"
stroke="#ffffff"
id="path51"
style="stroke:none;stroke-width:0.840525;stroke-opacity:1" />
xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_2168_154906)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M14.5467 15.8667L13.6667 13.7333H7.00005L6.13338 15.8667C5.93338 16.3467 5.46672 16.6667 4.94672 16.6667C4.02672 16.6667 3.40005 15.72 3.76005 14.88L5.00323 12.0059H15.6635L16.9067 14.88C17.2667 15.72 16.6534 16.6667 15.7334 16.6667C15.2134 16.6667 14.7467 16.3467 14.5467 15.8667ZM13.6435 7.33594L11.7334 2.92C11.4934 2.36 10.9467 2 10.3334 2C9.72005 2 9.17338 2.36 8.93338 2.92L7.02326 7.33594H9.32912L10.3334 4.64L11.3376 7.33594H13.6435Z" fill="currentColor"/>
<path d="M1 9.67708C1 10.4104 1.6 11.0104 2.33333 11.0104H18.3333C19.0667 11.0104 19.6667 10.4104 19.6667 9.67708C19.6667 8.94375 19.0667 8.34375 18.3333 8.34375H2.33333C1.6 8.34375 1 8.94375 1 9.67708Z" fill="currentColor"/>
</g>
<defs
id="defs58">
<clipPath
id="clip0_1456_146365">
<rect
width="20"
height="20"
fill="white"
id="rect55" />
<defs>
<clipPath id="clip0_2168_154906">
<rect width="20" height="20" fill="white"/>
</clipPath>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 1.4 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View file

@ -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) {

View file

@ -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();
// 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_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
// 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) {
if (roomOrThread.timeline.at(-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;
}
}
// 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 = 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

View file

@ -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: (
<p>
{_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.",
)}
</p>
),
hasCloseButton: true,
});
};

View file

@ -96,7 +96,7 @@ export default abstract class ScrollableBaseModal<
aria-label={_t("Close dialog")}
/>
</div>
<form onSubmit={this.onSubmit}>
<form onSubmit={this.onSubmit} className="mx_CompoundDialog_form">
<div className="mx_CompoundDialog_content">{this.renderContent()}</div>
<div className="mx_CompoundDialog_footer">
<AccessibleButton onClick={this.onCancel} kind="primary_outline">

View file

@ -262,7 +262,7 @@ export default class Field extends React.PureComponent<PropShapes, IState> {
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;

View file

@ -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<IBodyProps, IState> {
// 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<boolean>("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;

View file

@ -69,7 +69,7 @@ const BaseCard: React.FC<IProps> = forwardRef<HTMLDivElement, IProps>(
if (onClose) {
closeButton = (
<AccessibleButton
data-test-id="base-card-close-button"
data-testid="base-card-close-button"
className="mx_BaseCard_close"
onClick={onClose}
title={closeLabel || _t("Close")}

View file

@ -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<IProps> {
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<IProps> {
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<IProps> {
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 => {

View file

@ -29,6 +29,7 @@ import { EventType } from "matrix-js-sdk/src/@types/event";
import { logger } from "matrix-js-sdk/src/logger";
import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import { RoomStateEvent } from "matrix-js-sdk/src/models/room-state";
import { UserTrustLevel } from "matrix-js-sdk/src/crypto/CrossSigning";
import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
@ -84,7 +85,7 @@ export interface IDevice {
getDisplayName(): string;
}
const disambiguateDevices = (devices: IDevice[]) => {
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<ViewRoomPayload>({
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<{
</AccessibleButton>
);
let directMessageButton: JSX.Element;
if (!isMe) {
directMessageButton = <MessageButton member={member} />;
}
const directMessageButton = isMe ? null : <MessageButton member={member} />;
return (
<div className="mx_UserInfo_container">
@ -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<IPowerLevelsContent>(getPowerLevels(room));
@ -538,7 +550,7 @@ interface IBaseProps {
stopUpdating(): void;
}
const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
export const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext);
// check if user can be kicked/disinvited
@ -566,7 +578,7 @@ const RoomKickButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBas
space: room,
spaceChildFilter: (child: Room) => {
// 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<IBaseProps> = ({ member }) => {
);
};
const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
export const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBaseRoomProps, "powerLevels">) => {
const cli = useContext(MatrixClientContext);
const isBanned = member.membership === "ban";
@ -674,7 +686,7 @@ const BanToggleButton = ({ room, member, startUpdating, stopUpdating }: Omit<IBa
spaceChildFilter: isBanned
? (child: Room) => {
// 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<IBa
}
: (child: Room) => {
// 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<IBaseRoomProps> = ({ member, room, powerLevels,
);
};
const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
room,
children,
member,
@ -855,7 +867,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
// 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 <div />;
@ -879,7 +891,7 @@ const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
<BanToggleButton room={room} member={member} startUpdating={startUpdating} stopUpdating={stopUpdating} />
);
}
if (!isMe && canAffectUser && me.powerLevel >= editPowerLevel && !room.isSpaceRoom()) {
if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}
@ -949,7 +961,7 @@ function useRoomPermissions(cli: MatrixClient, room: Room, user: RoomMember): IR
const powerLevels = room?.currentState.getStateEvents(EventType.RoomPowerLevels, "")?.getContent();
if (!powerLevels) return;
const me = room.getMember(cli.getUserId());
const me = room.getMember(cli.getUserId() || "");
if (!me) return;
const them = user;
@ -1006,7 +1018,7 @@ const PowerLevelSection: React.FC<{
}
};
const PowerLevelEditor: React.FC<{
export const PowerLevelEditor: React.FC<{
user: RoomMember;
room: Room;
roomPermissions: IRoomPermissions;
@ -1022,8 +1034,13 @@ const PowerLevelEditor: React.FC<{
async (powerLevel: number) => {
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 (
<div className="mx_UserInfo_profileField">
<PowerSelector
label={null}
label={undefined}
value={selectedPowerLevel}
maxValue={roomPermissions.modifyLevelMax}
usersDefault={powerLevelUsersDefault}
@ -1099,7 +1116,7 @@ export const useDevices = (userId: string) => {
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 | null | IDevice[]>(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 = (
<div className="mx_UserInfo_avatar">
<div className="mx_UserInfo_avatar_transition">
@ -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}
/>
</div>
</div>
@ -1475,10 +1496,7 @@ const UserInfoHeader: React.FC<{
);
}
let e2eIcon;
if (e2eStatus) {
e2eIcon = <E2EIcon size={18} status={e2eStatus} isUser={true} />;
}
const e2eIcon = e2eStatus ? <E2EIcon size={18} status={e2eStatus} isUser={true} /> : null;
const displayName = (member as RoomMember).rawDisplayName;
return (
@ -1496,7 +1514,7 @@ const UserInfoHeader: React.FC<{
</h2>
</div>
<div className="mx_UserInfo_profile_mxid">
{UserIdentifierCustomisations.getDisplayUserIdentifier(member.userId, {
{UserIdentifierCustomisations.getDisplayUserIdentifier?.(member.userId, {
roomId,
withDisplayName: true,
})}
@ -1533,7 +1551,7 @@ const UserInfo: React.FC<IProps> = ({ 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<IProps> = ({ user, room, onClose, phase = RightPanelPha
case RightPanelPhases.SpaceMemberInfo:
content = (
<BasicUserInfo
room={room}
room={room as Room}
member={member as User}
devices={devices}
isRoomEncrypted={isRoomEncrypted}
devices={devices as IDevice[]}
isRoomEncrypted={Boolean(isRoomEncrypted)}
/>
);
break;
@ -1565,7 +1583,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
{...(props as React.ComponentProps<typeof EncryptionPanel>)}
member={member as User | RoomMember}
onClose={onEncryptionPanelClose}
isRoomEncrypted={isRoomEncrypted}
isRoomEncrypted={Boolean(isRoomEncrypted)}
/>
);
break;
@ -1582,7 +1600,7 @@ const UserInfo: React.FC<IProps> = ({ user, room, onClose, phase = RightPanelPha
let scopeHeader;
if (room?.isSpaceRoom()) {
scopeHeader = (
<div data-test-id="space-header" className="mx_RightPanel_scopeHeader">
<div data-testid="space-header" className="mx_RightPanel_scopeHeader">
<RoomAvatar room={room} height={32} width={32} />
<RoomName room={room} />
</div>

View file

@ -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<IProps, IState> {
}
}
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<IProps, IState> {
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}

View file

@ -376,8 +376,8 @@ function ComposerModeButton({ isRichTextEnabled, onClick }: WysiwygToggleButtonP
<CollapsibleButton
className="mx_MessageComposer_button"
iconClassName={classNames({
mx_MessageComposer_plain_text: isRichTextEnabled,
mx_MessageComposer_rich_text: !isRichTextEnabled,
mx_MessageComposer_plain_text: !isRichTextEnabled,
mx_MessageComposer_rich_text: isRichTextEnabled,
})}
onClick={onClick}
title={title}

View file

@ -120,7 +120,7 @@ export function FormattingButtons({ composer, actionStates }: FormattingButtonsP
<Button
isActive={actionStates.link === "reversed"}
label={_td("Link")}
onClick={() => openLinkModal(composer, composerContext)}
onClick={() => openLinkModal(composer, composerContext, actionStates.link === "reversed")}
icon={<LinkIcon className="mx_FormattingButtons_Icon" />}
/>
</div>

View file

@ -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 (
<QuestionDialog
<BaseDialog
className="mx_LinkModal"
title={_td("Create a link")}
button={_td("Save")}
buttonDisabled={isSaveDisabled}
hasCancelButton={true}
onFinished={async (isClickOnSave: boolean) => {
if (isClickOnSave) {
title={isEditing ? _t("Edit link") : _t("Create a link")}
hasCancel={true}
onFinished={onClose}
>
<form
className="mx_LinkModal_content"
onSubmit={async (evt) => {
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={
<div className="mx_LinkModal_content">
{isTextEnabled && (
>
{hasText && (
<Field
required={true}
autoFocus={true}
label={_td("Text")}
label={_t("Text")}
value={fields.text}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFields((fields) => ({ ...fields, text: e.target.value }))
}
/>
)}
<Field
autoFocus={!isTextEnabled}
label={_td("Link")}
required={true}
autoFocus={!hasText}
label={_t("Link")}
value={fields.link}
onChange={(e: ChangeEvent<HTMLInputElement>) =>
setFields((fields) => ({ ...fields, link: e.target.value }))
}
className="mx_LinkModal_Field"
placeholder=""
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setFields((fields) => ({ ...fields, link: e.target.value }));
setHasLinkChanged(true);
}}
/>
<div className="mx_LinkModal_buttons">
{isEditing && (
<button
type="button"
className="danger"
onClick={() => {
composer.removeLinks();
onClose();
}}
>
{_t("Remove")}
</button>
)}
<DialogButtons
primaryButton={_t("Save")}
primaryDisabled={isSaveDisabled}
primaryIsSubmit={true}
onCancel={onClose}
/>
</div>
}
/>
</form>
</BaseDialog>
);
}

View file

@ -35,7 +35,7 @@ import { CryptoEvent } from "matrix-js-sdk/src/crypto";
import MatrixClientContext from "../../../../contexts/MatrixClientContext";
import { _t } from "../../../../languageHandler";
import { getDeviceClientInformation } from "../../../../utils/device/clientInformation";
import { getDeviceClientInformation, pruneClientInformation } from "../../../../utils/device/clientInformation";
import { DevicesDictionary, ExtendedDevice, ExtendedDeviceAppInfo } from "./types";
import { useEventEmitter } from "../../../../hooks/useEventEmitter";
import { parseUserAgent } from "../../../../utils/device/parseUserAgent";
@ -116,8 +116,8 @@ export type DevicesState = {
export const useOwnDevices = (): DevicesState => {
const matrixClient = useContext(MatrixClientContext);
const currentDeviceId = matrixClient.getDeviceId();
const userId = matrixClient.getUserId();
const currentDeviceId = matrixClient.getDeviceId()!;
const userId = matrixClient.getSafeUserId();
const [devices, setDevices] = useState<DevicesState["devices"]>({});
const [pushers, setPushers] = useState<DevicesState["pushers"]>([]);
@ -138,11 +138,6 @@ export const useOwnDevices = (): DevicesState => {
const refreshDevices = useCallback(async () => {
setIsLoadingDeviceList(true);
try {
// realistically we should never hit this
// but it satisfies types
if (!userId) {
throw new Error("Cannot fetch devices without user id");
}
const devices = await fetchDevicesWithVerification(matrixClient, userId);
setDevices(devices);
@ -176,6 +171,15 @@ export const useOwnDevices = (): DevicesState => {
refreshDevices();
}, [refreshDevices]);
useEffect(() => {
const deviceIds = Object.keys(devices);
// empty devices means devices have not been fetched yet
// as there is always at least the current device
if (deviceIds.length) {
pruneClientInformation(deviceIds, matrixClient);
}
}, [devices, matrixClient]);
useEventEmitter(matrixClient, CryptoEvent.DevicesUpdated, (users: string[]): void => {
if (users.includes(userId)) {
refreshDevices();

View file

@ -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);
}
}

View file

@ -650,6 +650,8 @@
"You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.": "You are already recording a voice broadcast. Please end your current voice broadcast to start a new one.",
"You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.": "You don't have the required permissions to start a voice broadcast in this room. Contact a room administrator to upgrade your permissions.",
"Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.": "Someone else is already recording a voice broadcast. Wait for their voice broadcast to end to start a new one.",
"Connection error": "Connection error",
"Unfortunately we're unable to start a recording right now. Please try again later.": "Unfortunately we're unable to start a recording right now. Please try again later.",
"Cant start a call": "Cant start a call",
"You cant start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.": "You cant start a call as you are currently recording a live broadcast. Please end your live broadcast in order to start a call.",
"You ended a <a>voice broadcast</a>": "You ended a <a>voice broadcast</a>",
@ -936,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",
@ -2135,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",
@ -2686,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",

View file

@ -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"),

View file

@ -40,8 +40,8 @@ const formatUrl = (): string | undefined => {
].join("");
};
export const getClientInformationEventType = (deviceId: string): string =>
`io.element.matrix_client_information.${deviceId}`;
const clientInformationEventPrefix = "io.element.matrix_client_information.";
export const getClientInformationEventType = (deviceId: string): string => `${clientInformationEventPrefix}${deviceId}`;
/**
* Record extra client information for the current device
@ -52,7 +52,7 @@ export const recordClientInformation = async (
sdkConfig: IConfigOptions,
platform: BasePlatform,
): Promise<void> => {
const deviceId = matrixClient.getDeviceId();
const deviceId = matrixClient.getDeviceId()!;
const { brand } = sdkConfig;
const version = await platform.getAppVersion();
const type = getClientInformationEventType(deviceId);
@ -66,12 +66,27 @@ export const recordClientInformation = async (
};
/**
* Remove extra client information
* @todo(kerrya) revisit after MSC3391: account data deletion is done
* (PSBE-12)
* Remove client information events for devices that no longer exist
* @param validDeviceIds - ids of current devices,
* client information for devices NOT in this list will be removed
*/
export const pruneClientInformation = (validDeviceIds: string[], matrixClient: MatrixClient): void => {
Object.values(matrixClient.store.accountData).forEach((event) => {
if (!event.getType().startsWith(clientInformationEventPrefix)) {
return;
}
const [, deviceId] = event.getType().split(clientInformationEventPrefix);
if (deviceId && !validDeviceIds.includes(deviceId)) {
matrixClient.deleteAccountData(event.getType());
}
});
};
/**
* Remove extra client information for current device
*/
export const removeClientInformation = async (matrixClient: MatrixClient): Promise<void> => {
const deviceId = matrixClient.getDeviceId();
const deviceId = matrixClient.getDeviceId()!;
const type = getClientInformationEventType(deviceId);
const clientInformation = getDeviceClientInformation(matrixClient, deviceId);

View file

@ -60,13 +60,20 @@ export class VoiceBroadcastRecording
{
private state: VoiceBroadcastInfoState;
private recorder: VoiceBroadcastRecorder;
private sequence = 1;
private dispatcherRef: string;
private chunkEvents = new VoiceBroadcastChunkEvents();
private chunkRelationHelper: RelationsHelper;
private maxLength: number;
private timeLeft: number;
/**
* Broadcast chunks have a sequence number to bring them in the correct order and to know if a message is missing.
* This variable holds the last sequence number.
* Starts with 0 because there is no chunk at the beginning of a broadcast.
* Will be incremented when a chunk message is created.
*/
private sequence = 0;
public constructor(
public readonly infoEvent: MatrixEvent,
private client: MatrixClient,
@ -268,7 +275,8 @@ export class VoiceBroadcastRecording
event_id: this.infoEvent.getId(),
};
content["io.element.voice_broadcast_chunk"] = {
sequence: this.sequence++,
/** Increment the last sequence number and use it for this message. Also see {@link sequence}. */
sequence: ++this.sequence,
};
await this.client.sendMessage(this.infoEvent.getRoomId(), content);

View file

@ -16,6 +16,7 @@ limitations under the License.
import React from "react";
import { MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { SyncState } from "matrix-js-sdk/src/sync";
import { hasRoomLiveVoiceBroadcast, VoiceBroadcastInfoEventType, VoiceBroadcastRecordingsStore } from "..";
import InfoDialog from "../../components/views/dialogs/InfoDialog";
@ -67,6 +68,14 @@ const showOthersAlreadyRecordingDialog = () => {
});
};
const showNoConnectionDialog = (): void => {
Modal.createDialog(InfoDialog, {
title: _t("Connection error"),
description: <p>{_t("Unfortunately we're unable to start a recording right now. Please try again later.")}</p>,
hasCloseButton: true,
});
};
export const checkVoiceBroadcastPreConditions = async (
room: Room,
client: MatrixClient,
@ -86,6 +95,11 @@ export const checkVoiceBroadcastPreConditions = async (
return false;
}
if (client.getSyncState() === SyncState.Error) {
showNoConnectionDialog();
return false;
}
const { hasBroadcast, startedByUser } = await hasRoomLiveVoiceBroadcast(client, room, currentUserId);
if (hasBroadcast && startedByUser) {

View file

@ -15,25 +15,26 @@ 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";
// mock user credentials
getMockClientWithEventEmitter({
...mockClientMethodsUser(bobId),
});
stubClient();
const client = MatrixClientPeg.get();
describe("eventTriggersUnreadCount()", () => {
// setup events
const alicesMessage = new MatrixEvent({
type: EventType.RoomMessage,
@ -44,9 +45,9 @@ describe("eventTriggersUnreadCount()", () => {
},
});
const bobsMessage = new MatrixEvent({
const ourMessage = new MatrixEvent({
type: EventType.RoomMessage,
sender: bobId,
sender: client.getUserId()!,
content: {
msgtype: MsgType.Text,
body: "Hello from Bob",
@ -65,7 +66,7 @@ describe("eventTriggersUnreadCount()", () => {
});
it("returns false when the event was sent by the current user", () => {
expect(eventTriggersUnreadCount(bobsMessage)).toBe(false);
expect(eventTriggersUnreadCount(ourMessage)).toBe(false);
// returned early before checking renderer
expect(haveRendererForEvent).not.toHaveBeenCalled();
});
@ -103,12 +104,217 @@ describe("eventTriggersUnreadCount()", () => {
EventType.RoomServerAcl,
];
it.each(noUnreadEventTypes)("returns false without checking for renderer for events with type %s", (eventType) => {
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);
});
});
});

View file

@ -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 } = {

View file

@ -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<IRoomState>): ReactWrapper {
function createRightClickMenuWithContent(eventContent: object, context?: Partial<IRoomState>): ReactWrapper {
return createMenuWithContent(eventContent, { rightClick: true }, context);
}
@ -522,11 +539,13 @@ function createRightClickMenu(mxEvent: MatrixEvent, context?: Partial<IRoomState
}
function createMenuWithContent(
eventContent: ExtensibleEvent,
eventContent: object,
props?: Partial<React.ComponentProps<typeof MessageContextMenu>>,
context?: Partial<IRoomState>,
): 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);
}

View file

@ -21,27 +21,7 @@ exports[`RoomGeneralContextMenu renders an empty context menu for archived rooms
>
<div
class="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst"
>
<div
aria-checked="false"
aria-label="Mark as read"
class="mx_AccessibleButton mx_IconizedContextMenu_item"
role="menuitemcheckbox"
tabindex="0"
>
<span
class="mx_IconizedContextMenu_icon mx_RoomGeneralContextMenu_iconMarkAsRead"
/>
<span
class="mx_IconizedContextMenu_label"
>
Mark as read
</span>
<span
class="mx_IconizedContextMenu_icon mx_IconizedContextMenu_unchecked"
/>
</div>
</div>
<div
class="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst mx_IconizedContextMenu_optionList_red"
>
@ -88,27 +68,7 @@ exports[`RoomGeneralContextMenu renders the default context menu 1`] = `
>
<div
class="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst"
>
<div
aria-checked="false"
aria-label="Mark as read"
class="mx_AccessibleButton mx_IconizedContextMenu_item"
role="menuitemcheckbox"
tabindex="0"
>
<span
class="mx_IconizedContextMenu_icon mx_RoomGeneralContextMenu_iconMarkAsRead"
/>
<span
class="mx_IconizedContextMenu_label"
>
Mark as read
</span>
<span
class="mx_IconizedContextMenu_icon mx_IconizedContextMenu_unchecked"
/>
</div>
</div>
<div
class="mx_IconizedContextMenu_optionList mx_IconizedContextMenu_optionList_notFirst mx_IconizedContextMenu_optionList_red"
>

View file

@ -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(<Field value="" placeholder="my placeholder" />);
// Then
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my placeholder");
// When
rerender(<Field value="" placeholder="" />);
// Then
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "");
});
it("Should display label as placeholder", async () => {
// When
render(<Field value="" label="my label" />);
// Then
expect(screen.getByRole("textbox")).toHaveAttribute("placeholder", "my label");
});
it("Should not display a placeholder", async () => {
// When
render(<Field value="" />);
// Then
expect(screen.getByRole("textbox")).not.toHaveAttribute("placeholder", "my placeholder");
});
});
});

View file

@ -1,7 +1,7 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`PollCreateDialog renders a blank poll 1`] = `"<div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div><div data-focus-lock-disabled="false" role="dialog" aria-labelledby="mx_CompoundDialog_title" aria-describedby="mx_CompoundDialog_content" class="mx_CompoundDialog mx_ScrollableBaseDialog"><div class="mx_CompoundDialog_header"><h1>Create poll</h1><div aria-label="Close dialog" role="button" tabindex="0" class="mx_AccessibleButton mx_CompoundDialog_cancelButton"></div></div><form><div class="mx_CompoundDialog_content"><div class="mx_PollCreateDialog"><h2>Poll type</h2><div class="mx_Field mx_Field_select"><select type="text" id="mx_Field_1"><option value="org.matrix.msc3381.poll.disclosed">Open poll</option><option value="org.matrix.msc3381.poll.undisclosed">Closed poll</option></select><label for="mx_Field_1"></label></div><p>Voters see results as soon as they have voted</p><h2>What is your poll question or topic?</h2><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="poll-topic-input" maxlength="340" label="Question or topic" placeholder="Write something..." type="text" value=""><label for="poll-topic-input">Question or topic</label></div><h2>Create options</h2><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_0" maxlength="340" label="Option 1" placeholder="Write an option" type="text" value=""><label for="pollcreate_option_0">Option 1</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_1" maxlength="340" label="Option 2" placeholder="Write an option" type="text" value=""><label for="pollcreate_option_1">Option 2</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_addOption mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary">Add option</div></div></div><div class="mx_CompoundDialog_footer"><div role="button" tabindex="0" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">Cancel</div><button type="submit" role="button" tabindex="0" aria-disabled="true" disabled="" class="mx_AccessibleButton mx_Dialog_nonDialogButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary mx_AccessibleButton_disabled">Create Poll</button></div></form></div><div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div>"`;
exports[`PollCreateDialog renders a blank poll 1`] = `"<div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div><div data-focus-lock-disabled="false" role="dialog" aria-labelledby="mx_CompoundDialog_title" aria-describedby="mx_CompoundDialog_content" class="mx_CompoundDialog mx_ScrollableBaseDialog"><div class="mx_CompoundDialog_header"><h1>Create poll</h1><div aria-label="Close dialog" role="button" tabindex="0" class="mx_AccessibleButton mx_CompoundDialog_cancelButton"></div></div><form class="mx_CompoundDialog_form"><div class="mx_CompoundDialog_content"><div class="mx_PollCreateDialog"><h2>Poll type</h2><div class="mx_Field mx_Field_select"><select type="text" id="mx_Field_1"><option value="org.matrix.msc3381.poll.disclosed">Open poll</option><option value="org.matrix.msc3381.poll.undisclosed">Closed poll</option></select><label for="mx_Field_1"></label></div><p>Voters see results as soon as they have voted</p><h2>What is your poll question or topic?</h2><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="poll-topic-input" maxlength="340" label="Question or topic" placeholder="Write something..." type="text" value=""><label for="poll-topic-input">Question or topic</label></div><h2>Create options</h2><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_0" maxlength="340" label="Option 1" placeholder="Write an option" type="text" value=""><label for="pollcreate_option_0">Option 1</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_1" maxlength="340" label="Option 2" placeholder="Write an option" type="text" value=""><label for="pollcreate_option_1">Option 2</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_addOption mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary">Add option</div></div></div><div class="mx_CompoundDialog_footer"><div role="button" tabindex="0" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">Cancel</div><button type="submit" role="button" tabindex="0" aria-disabled="true" disabled="" class="mx_AccessibleButton mx_Dialog_nonDialogButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary mx_AccessibleButton_disabled">Create Poll</button></div></form></div><div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div>"`;
exports[`PollCreateDialog renders a question and some options 1`] = `"<div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div><div data-focus-lock-disabled="false" role="dialog" aria-labelledby="mx_CompoundDialog_title" aria-describedby="mx_CompoundDialog_content" class="mx_CompoundDialog mx_ScrollableBaseDialog"><div class="mx_CompoundDialog_header"><h1>Create poll</h1><div aria-label="Close dialog" role="button" tabindex="0" class="mx_AccessibleButton mx_CompoundDialog_cancelButton"></div></div><form><div class="mx_CompoundDialog_content"><div class="mx_PollCreateDialog"><h2>Poll type</h2><div class="mx_Field mx_Field_select"><select type="text" id="mx_Field_4"><option value="org.matrix.msc3381.poll.disclosed">Open poll</option><option value="org.matrix.msc3381.poll.undisclosed">Closed poll</option></select><label for="mx_Field_4"></label></div><p>Voters see results as soon as they have voted</p><h2>What is your poll question or topic?</h2><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="poll-topic-input" maxlength="340" label="Question or topic" placeholder="Write something..." type="text" value="How many turnips is the optimal number?"><label for="poll-topic-input">Question or topic</label></div><h2>Create options</h2><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_0" maxlength="340" label="Option 1" placeholder="Write an option" type="text" value="As many as my neighbour"><label for="pollcreate_option_0">Option 1</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_1" maxlength="340" label="Option 2" placeholder="Write an option" type="text" value="The question is meaningless"><label for="pollcreate_option_1">Option 2</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_2" maxlength="340" label="Option 3" placeholder="Write an option" type="text" value="Mu"><label for="pollcreate_option_2">Option 3</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_addOption mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary">Add option</div></div></div><div class="mx_CompoundDialog_footer"><div role="button" tabindex="0" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">Cancel</div><button type="submit" role="button" tabindex="0" class="mx_AccessibleButton mx_Dialog_nonDialogButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary">Create Poll</button></div></form></div><div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div>"`;
exports[`PollCreateDialog renders a question and some options 1`] = `"<div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div><div data-focus-lock-disabled="false" role="dialog" aria-labelledby="mx_CompoundDialog_title" aria-describedby="mx_CompoundDialog_content" class="mx_CompoundDialog mx_ScrollableBaseDialog"><div class="mx_CompoundDialog_header"><h1>Create poll</h1><div aria-label="Close dialog" role="button" tabindex="0" class="mx_AccessibleButton mx_CompoundDialog_cancelButton"></div></div><form class="mx_CompoundDialog_form"><div class="mx_CompoundDialog_content"><div class="mx_PollCreateDialog"><h2>Poll type</h2><div class="mx_Field mx_Field_select"><select type="text" id="mx_Field_4"><option value="org.matrix.msc3381.poll.disclosed">Open poll</option><option value="org.matrix.msc3381.poll.undisclosed">Closed poll</option></select><label for="mx_Field_4"></label></div><p>Voters see results as soon as they have voted</p><h2>What is your poll question or topic?</h2><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="poll-topic-input" maxlength="340" label="Question or topic" placeholder="Write something..." type="text" value="How many turnips is the optimal number?"><label for="poll-topic-input">Question or topic</label></div><h2>Create options</h2><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_0" maxlength="340" label="Option 1" placeholder="Write an option" type="text" value="As many as my neighbour"><label for="pollcreate_option_0">Option 1</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_1" maxlength="340" label="Option 2" placeholder="Write an option" type="text" value="The question is meaningless"><label for="pollcreate_option_1">Option 2</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_2" maxlength="340" label="Option 3" placeholder="Write an option" type="text" value="Mu"><label for="pollcreate_option_2">Option 3</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_addOption mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary">Add option</div></div></div><div class="mx_CompoundDialog_footer"><div role="button" tabindex="0" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">Cancel</div><button type="submit" role="button" tabindex="0" class="mx_AccessibleButton mx_Dialog_nonDialogButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary">Create Poll</button></div></form></div><div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div>"`;
exports[`PollCreateDialog renders info from a previous event 1`] = `"<div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div><div data-focus-lock-disabled="false" role="dialog" aria-labelledby="mx_CompoundDialog_title" aria-describedby="mx_CompoundDialog_content" class="mx_CompoundDialog mx_ScrollableBaseDialog"><div class="mx_CompoundDialog_header"><h1>Edit poll</h1><div aria-label="Close dialog" role="button" tabindex="0" class="mx_AccessibleButton mx_CompoundDialog_cancelButton"></div></div><form><div class="mx_CompoundDialog_content"><div class="mx_PollCreateDialog"><h2>Poll type</h2><div class="mx_Field mx_Field_select"><select type="text" id="mx_Field_5"><option value="org.matrix.msc3381.poll.disclosed">Open poll</option><option value="org.matrix.msc3381.poll.undisclosed">Closed poll</option></select><label for="mx_Field_5"></label></div><p>Voters see results as soon as they have voted</p><h2>What is your poll question or topic?</h2><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="poll-topic-input" maxlength="340" label="Question or topic" placeholder="Write something..." type="text" value="Poll Q"><label for="poll-topic-input">Question or topic</label></div><h2>Create options</h2><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_0" maxlength="340" label="Option 1" placeholder="Write an option" type="text" value="Answer 1"><label for="pollcreate_option_0">Option 1</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_1" maxlength="340" label="Option 2" placeholder="Write an option" type="text" value="Answer 2"><label for="pollcreate_option_1">Option 2</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_addOption mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary">Add option</div></div></div><div class="mx_CompoundDialog_footer"><div role="button" tabindex="0" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">Cancel</div><button type="submit" role="button" tabindex="0" class="mx_AccessibleButton mx_Dialog_nonDialogButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary">Done</button></div></form></div><div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div>"`;
exports[`PollCreateDialog renders info from a previous event 1`] = `"<div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div><div data-focus-lock-disabled="false" role="dialog" aria-labelledby="mx_CompoundDialog_title" aria-describedby="mx_CompoundDialog_content" class="mx_CompoundDialog mx_ScrollableBaseDialog"><div class="mx_CompoundDialog_header"><h1>Edit poll</h1><div aria-label="Close dialog" role="button" tabindex="0" class="mx_AccessibleButton mx_CompoundDialog_cancelButton"></div></div><form class="mx_CompoundDialog_form"><div class="mx_CompoundDialog_content"><div class="mx_PollCreateDialog"><h2>Poll type</h2><div class="mx_Field mx_Field_select"><select type="text" id="mx_Field_5"><option value="org.matrix.msc3381.poll.disclosed">Open poll</option><option value="org.matrix.msc3381.poll.undisclosed">Closed poll</option></select><label for="mx_Field_5"></label></div><p>Voters see results as soon as they have voted</p><h2>What is your poll question or topic?</h2><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="poll-topic-input" maxlength="340" label="Question or topic" placeholder="Write something..." type="text" value="Poll Q"><label for="poll-topic-input">Question or topic</label></div><h2>Create options</h2><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_0" maxlength="340" label="Option 1" placeholder="Write an option" type="text" value="Answer 1"><label for="pollcreate_option_0">Option 1</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div class="mx_PollCreateDialog_option"><div class="mx_Field mx_Field_input mx_Field_labelAlwaysTopLeft mx_Field_placeholderIsHint"><input id="pollcreate_option_1" maxlength="340" label="Option 2" placeholder="Write an option" type="text" value="Answer 2"><label for="pollcreate_option_1">Option 2</label></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_removeOption"></div></div><div role="button" tabindex="0" class="mx_AccessibleButton mx_PollCreateDialog_addOption mx_AccessibleButton_hasKind mx_AccessibleButton_kind_secondary">Add option</div></div></div><div class="mx_CompoundDialog_footer"><div role="button" tabindex="0" class="mx_AccessibleButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary_outline">Cancel</div><button type="submit" role="button" tabindex="0" class="mx_AccessibleButton mx_Dialog_nonDialogButton mx_AccessibleButton_hasKind mx_AccessibleButton_kind_primary">Done</button></div></form></div><div data-focus-guard="true" tabindex="0" style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"></div>"`;

View file

@ -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(<RoomHeaderButtons room={room} excludedRightPanelPhaseButtons={[]} />);
}
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();

File diff suppressed because it is too large Load diff

View file

@ -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(() => {

View file

@ -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(() => <div data-testid="wysiwyg-composer" />),
}));
const openStickerPicker = async (): Promise<void> => {
await act(async () => {
await userEvent.click(screen.getByLabelText("More options"));
await userEvent.click(screen.getByLabelText("Sticker"));
});
};
const startVoiceMessage = async (): Promise<void> => {
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<void> => {
await flushPromises();
await flushPromises();
};
const shouldClearModal = async (): Promise<void> => {
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(
<MatrixClientContext.Provider value={mockClient}>
<RoomContext.Provider value={roomState}>
<RoomContext.Provider value={roomContext}>
<MessageComposer {...defaultProps} {...props} />
</RoomContext.Provider>
</MatrixClientContext.Provider>,
);
),
roomContext,
};
}

View file

@ -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 <UnreadNotificationBadge room={room} threadId={threadId} />;
}
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();
});
});

View file

@ -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(<WrappedRoomList {...defaultProps} />, 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();
});

View file

@ -3,7 +3,7 @@
exports[`RoomTile should render the room 1`] = `
<div>
<div
aria-label="!1:example.org Unread messages."
aria-label="!1:example.org"
aria-selected="false"
class="mx_AccessibleButton mx_RoomTile"
role="treeitem"
@ -37,7 +37,7 @@ exports[`RoomTile should render the room 1`] = `
class="mx_RoomTile_titleContainer"
>
<div
class="mx_RoomTile_title mx_RoomTile_titleHasUnreadEvents"
class="mx_RoomTile_title"
tabindex="-1"
title="!1:example.org"
>
@ -51,15 +51,7 @@ exports[`RoomTile should render the room 1`] = `
<div
aria-hidden="true"
class="mx_RoomTile_badgeContainer"
>
<div
class="mx_NotificationBadge mx_NotificationBadge_visible mx_NotificationBadge_dot"
>
<span
class="mx_NotificationBadge_count"
/>
</div>
</div>
<div
aria-expanded="false"
aria-haspopup="true"

View file

@ -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(
<LinkModal
composer={formattingFunctions}
isTextEnabled={isTextEnabled}
onClose={onClose}
composerContext={{ selection: defaultValue }}
isEditing={isEditing}
/>,
);
};
@ -75,13 +78,13 @@ describe("LinkModal", () => {
// When
jest.useFakeTimers();
screen.getByText("Save").click();
jest.runAllTimers();
// Then
await waitFor(() => {
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
await waitFor(() => expect(onClose).toBeCalledTimes(1));
// When
jest.runAllTimers();
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
await waitFor(() => {
expect(selectionSpy).toHaveBeenCalledWith(defaultValue);
await waitFor(() => expect(onClose).toBeCalledTimes(1));
// When
jest.runAllTimers();
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());
});
});

View file

@ -46,6 +46,7 @@ import LogoutDialog from "../../../../../../src/components/views/dialogs/LogoutD
import { DeviceSecurityVariation, ExtendedDevice } from "../../../../../../src/components/views/settings/devices/types";
import { INACTIVE_DEVICE_AGE_MS } from "../../../../../../src/components/views/settings/devices/filter";
import SettingsStore from "../../../../../../src/settings/SettingsStore";
import { getClientInformationEventType } from "../../../../../../src/utils/device/clientInformation";
mockPlatformPeg();
@ -87,6 +88,7 @@ describe("<SessionManagerTab />", () => {
generateClientSecret: jest.fn(),
setDeviceDetails: jest.fn(),
getAccountData: jest.fn(),
deleteAccountData: jest.fn(),
doesServerSupportUnstableFeature: jest.fn().mockResolvedValue(true),
getPushers: jest.fn(),
setPusher: jest.fn(),
@ -182,6 +184,9 @@ describe("<SessionManagerTab />", () => {
],
});
// @ts-ignore mock
mockClient.store = { accountData: {} };
mockClient.getAccountData.mockReset().mockImplementation((eventType) => {
if (eventType.startsWith(LOCAL_NOTIFICATION_SETTINGS_PREFIX.name)) {
return new MatrixEvent({
@ -667,6 +672,47 @@ describe("<SessionManagerTab />", () => {
);
});
it("removes account data events for devices after sign out", async () => {
const mobileDeviceClientInfo = new MatrixEvent({
type: getClientInformationEventType(alicesMobileDevice.device_id),
content: {
name: "test",
},
});
// @ts-ignore setup mock
mockClient.store = {
// @ts-ignore setup mock
accountData: {
[mobileDeviceClientInfo.getType()]: mobileDeviceClientInfo,
},
};
mockClient.getDevices
.mockResolvedValueOnce({
devices: [alicesDevice, alicesMobileDevice, alicesOlderMobileDevice],
})
.mockResolvedValueOnce({
// refreshed devices after sign out
devices: [alicesDevice],
});
const { getByTestId, getByLabelText } = render(getComponent());
await act(async () => {
await flushPromises();
});
expect(mockClient.deleteAccountData).not.toHaveBeenCalled();
fireEvent.click(getByTestId("current-session-menu"));
fireEvent.click(getByLabelText("Sign out of all other sessions (2)"));
await confirmSignout(getByTestId);
// only called once for signed out device with account data event
expect(mockClient.deleteAccountData).toHaveBeenCalledTimes(1);
expect(mockClient.deleteAccountData).toHaveBeenCalledWith(mobileDeviceClientInfo.getType());
});
describe("other devices", () => {
const interactiveAuthError = { httpStatus: 401, data: { flows: [{ stages: ["m.login.password"] }] } };

View file

@ -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<void> => {
await actRTL(async () => {
await userEvent.click(renderResult.getByLabelText("Send a message…"));
await userEvent.keyboard(text);
});
};

42
test/test-utils/events.ts Normal file
View file

@ -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;
}

View file

@ -254,12 +254,12 @@ describe("VoiceBroadcastRecording", () => {
expect(voiceBroadcastRecording.getState()).toBe(VoiceBroadcastInfoState.Started);
});
describe("and calling stop()", () => {
describe("and calling stop", () => {
beforeEach(() => {
voiceBroadcastRecording.stop();
});
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Stopped, 1);
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Stopped, 0);
itShouldBeInState(VoiceBroadcastInfoState.Stopped);
it("should emit a stopped state changed event", () => {
@ -351,6 +351,7 @@ describe("VoiceBroadcastRecording", () => {
itShouldBeInState(VoiceBroadcastInfoState.Stopped);
itShouldSendAVoiceMessage([23, 24, 25], 3, getMaxBroadcastLength(), 2);
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Stopped, 2);
});
});
@ -364,6 +365,7 @@ describe("VoiceBroadcastRecording", () => {
});
itShouldSendAVoiceMessage([4, 5, 6], 3, 42, 1);
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Stopped, 1);
});
describe.each([
@ -375,7 +377,7 @@ describe("VoiceBroadcastRecording", () => {
});
itShouldBeInState(VoiceBroadcastInfoState.Paused);
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Paused, 1);
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Paused, 0);
it("should stop the recorder", () => {
expect(mocked(voiceBroadcastRecorder.stop)).toHaveBeenCalled();
@ -413,7 +415,7 @@ describe("VoiceBroadcastRecording", () => {
});
itShouldBeInState(VoiceBroadcastInfoState.Resumed);
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Resumed, 1);
itShouldSendAnInfoEvent(VoiceBroadcastInfoState.Resumed, 0);
it("should start the recorder", () => {
expect(mocked(voiceBroadcastRecorder.start)).toHaveBeenCalled();

View file

@ -0,0 +1,24 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`setUpVoiceBroadcastPreRecording when trying to start a broadcast if there is no connection should show an info dialog and not set up a pre-recording 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
Unfortunately we're unable to start a recording right now. Please try again later.
</p>,
"hasCloseButton": true,
"title": "Connection error",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;

View file

@ -91,3 +91,26 @@ exports[`startNewVoiceBroadcastRecording when the current user is not allowed to
],
}
`;
exports[`startNewVoiceBroadcastRecording when trying to start a broadcast if there is no connection should show an info dialog and not start a recording 1`] = `
[MockFunction] {
"calls": [
[
[Function],
{
"description": <p>
Unfortunately we're unable to start a recording right now. Please try again later.
</p>,
"hasCloseButton": true,
"title": "Connection error",
},
],
],
"results": [
{
"type": "return",
"value": undefined,
},
],
}
`;

View file

@ -16,9 +16,10 @@ limitations under the License.
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { SyncState } from "matrix-js-sdk/src/sync";
import Modal from "../../../src/Modal";
import {
checkVoiceBroadcastPreConditions,
VoiceBroadcastInfoState,
VoiceBroadcastPlayback,
VoiceBroadcastPlaybacksStore,
@ -30,7 +31,7 @@ import { setUpVoiceBroadcastPreRecording } from "../../../src/voice-broadcast/ut
import { mkRoomMemberJoinEvent, stubClient } from "../../test-utils";
import { mkVoiceBroadcastInfoStateEvent } from "./test-utils";
jest.mock("../../../src/voice-broadcast/utils/checkVoiceBroadcastPreConditions");
jest.mock("../../../src/Modal");
describe("setUpVoiceBroadcastPreRecording", () => {
const roomId = "!room:example.com";
@ -86,20 +87,19 @@ describe("setUpVoiceBroadcastPreRecording", () => {
playbacksStore = new VoiceBroadcastPlaybacksStore(recordingsStore);
});
describe("when the preconditions fail", () => {
describe("when trying to start a broadcast if there is no connection", () => {
beforeEach(async () => {
mocked(checkVoiceBroadcastPreConditions).mockResolvedValue(false);
mocked(client.getSyncState).mockReturnValue(SyncState.Error);
await setUpPreRecording();
});
itShouldNotCreateAPreRecording();
});
describe("when the preconditions pass", () => {
beforeEach(() => {
mocked(checkVoiceBroadcastPreConditions).mockResolvedValue(true);
it("should show an info dialog and not set up a pre-recording", () => {
expect(preRecordingStore.getCurrent()).toBeNull();
expect(Modal.createDialog).toMatchSnapshot();
});
});
describe("when setting up a pre-recording", () => {
describe("and there is no user id", () => {
beforeEach(async () => {
mocked(client.getUserId).mockReturnValue(null);
@ -120,17 +120,15 @@ describe("setUpVoiceBroadcastPreRecording", () => {
});
describe("and there is a room member and listening to another broadcast", () => {
beforeEach(() => {
beforeEach(async () => {
playbacksStore.setCurrent(playback);
room.currentState.setStateEvents([mkRoomMemberJoinEvent(userId, roomId)]);
setUpPreRecording();
await setUpPreRecording();
});
it("should pause the current playback and create a voice broadcast pre-recording", () => {
expect(playback.pause).toHaveBeenCalled();
expect(playbacksStore.getCurrent()).toBeNull();
expect(checkVoiceBroadcastPreConditions).toHaveBeenCalledWith(room, client, recordingsStore);
expect(preRecording).toBeInstanceOf(VoiceBroadcastPreRecording);
});
});

View file

@ -16,6 +16,7 @@ limitations under the License.
import { mocked } from "jest-mock";
import { EventType, ISendEventResponse, MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { SyncState } from "matrix-js-sdk/src/sync";
import Modal from "../../../src/Modal";
import {
@ -103,6 +104,18 @@ describe("startNewVoiceBroadcastRecording", () => {
jest.clearAllMocks();
});
describe("when trying to start a broadcast if there is no connection", () => {
beforeEach(async () => {
mocked(client.getSyncState).mockReturnValue(SyncState.Error);
result = await startNewVoiceBroadcastRecording(room, client, playbacksStore, recordingsStore);
});
it("should show an info dialog and not start a recording", () => {
expect(result).toBeNull();
expect(Modal.createDialog).toMatchSnapshot();
});
});
describe("when the current user is allowed to send voice broadcast info state events", () => {
beforeEach(() => {
mocked(room.currentState.maySendStateEvent).mockReturnValue(true);

View file

@ -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"