Merge remote-tracking branch 'origin/develop' into feat/add-formating-buttons-to-wysiwyg

This commit is contained in:
Florian Duros 2022-10-13 12:42:21 +02:00
commit f85f53248b
No known key found for this signature in database
GPG key ID: 9700AA5870258A0B
128 changed files with 4303 additions and 1024 deletions

View file

@ -1,3 +1,38 @@
Changes in [3.58.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.58.1) (2022-10-11)
=====================================================================================================
## 🐛 Bug Fixes
* Use correct default for notification silencing ([\#9388](https://github.com/matrix-org/matrix-react-sdk/pull/9388)). Fixes vector-im/element-web#23456.
Changes in [3.58.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.58.0) (2022-10-11)
===============================================================================================================
## Deprecations
* Legacy Piwik config.json option `piwik.policy_url` is deprecated in favour of `privacy_policy_url`. Support will be removed in the next release.
## ✨ Features
* Device manager - select all devices ([\#9330](https://github.com/matrix-org/matrix-react-sdk/pull/9330)).
* New group call experience: Call tiles ([\#9332](https://github.com/matrix-org/matrix-react-sdk/pull/9332)).
* Add Shift key to FormatQuote keyboard shortcut ([\#9298](https://github.com/matrix-org/matrix-react-sdk/pull/9298)). Contributed by @owi92.
* Device manager - sign out of multiple sessions ([\#9325](https://github.com/matrix-org/matrix-react-sdk/pull/9325)).
* Display push toggle for web sessions (MSC3890) ([\#9327](https://github.com/matrix-org/matrix-react-sdk/pull/9327)).
* Add device notifications enabled switch ([\#9324](https://github.com/matrix-org/matrix-react-sdk/pull/9324)).
* Implement push notification toggle in device detail ([\#9308](https://github.com/matrix-org/matrix-react-sdk/pull/9308)).
* New group call experience: Starting and ending calls ([\#9318](https://github.com/matrix-org/matrix-react-sdk/pull/9318)).
* New group call experience: Room header call buttons ([\#9311](https://github.com/matrix-org/matrix-react-sdk/pull/9311)).
* Make device ID copyable in device list ([\#9297](https://github.com/matrix-org/matrix-react-sdk/pull/9297)).
* Use display name instead of user ID when rendering power events ([\#9295](https://github.com/matrix-org/matrix-react-sdk/pull/9295)).
* Read receipts for threads ([\#9239](https://github.com/matrix-org/matrix-react-sdk/pull/9239)). Fixes vector-im/element-web#23191.
## 🐛 Bug Fixes
* Use the correct sender key when checking shared secret ([\#2730](https://github.com/matrix-org/matrix-js-sdk/pull/2730)). Fixes vector-im/element-web#23374.
* Fix device selection in pre-join screen for Element Call video rooms ([\#9321](https://github.com/matrix-org/matrix-react-sdk/pull/9321)). Fixes vector-im/element-web#23331.
* Don't render a 1px high room topic if the room topic is empty ([\#9317](https://github.com/matrix-org/matrix-react-sdk/pull/9317)). Contributed by @Arnei.
* Don't show feedback prompts when that UIFeature is disabled ([\#9305](https://github.com/matrix-org/matrix-react-sdk/pull/9305)). Fixes vector-im/element-web#23327.
* Fix soft crash around unknown room pills ([\#9301](https://github.com/matrix-org/matrix-react-sdk/pull/9301)). Fixes matrix-org/element-web-rageshakes#15465.
* Fix spaces feedback prompt wrongly showing when feedback is disabled ([\#9302](https://github.com/matrix-org/matrix-react-sdk/pull/9302)). Fixes vector-im/element-web#23314.
* Fix tile soft crash in ReplyInThreadButton ([\#9300](https://github.com/matrix-org/matrix-react-sdk/pull/9300)). Fixes matrix-org/element-web-rageshakes#15493.
Changes in [3.57.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.57.0) (2022-09-28) Changes in [3.57.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.57.0) (2022-09-28)
===================================================================================================== =====================================================================================================

View file

@ -1,81 +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.
*/
const en = require("../src/i18n/strings/en_EN");
const de = require("../src/i18n/strings/de_DE");
const lv = {
"Save": "Saglabāt",
"Uploading %(filename)s and %(count)s others|one": "Качване на %(filename)s и %(count)s друг",
};
function weblateToCounterpart(inTrs) {
const outTrs = {};
for (const key of Object.keys(inTrs)) {
const keyParts = key.split('|', 2);
if (keyParts.length === 2) {
let obj = outTrs[keyParts[0]];
if (obj === undefined) {
obj = outTrs[keyParts[0]] = {};
} else if (typeof obj === "string") {
// This is a transitional edge case if a string went from singular to pluralised and both still remain
// in the translation json file. Use the singular translation as `other` and merge pluralisation atop.
obj = outTrs[keyParts[0]] = {
"other": inTrs[key],
};
console.warn("Found entry in i18n file in both singular and pluralised form", keyParts[0]);
}
obj[keyParts[1]] = inTrs[key];
} else {
outTrs[key] = inTrs[key];
}
}
return outTrs;
}
// Mock the browser-request for the languageHandler tests to return
// Fake languages.json containing references to en_EN, de_DE and lv
// en_EN.json
// de_DE.json
// lv.json - mock version with few translations, used to test fallback translation
module.exports = jest.fn((opts, cb) => {
const url = opts.url || opts.uri;
if (url && url.endsWith("languages.json")) {
cb(undefined, { status: 200 }, JSON.stringify({
"en": {
"fileName": "en_EN.json",
"label": "English",
},
"de": {
"fileName": "de_DE.json",
"label": "German",
},
"lv": {
"fileName": "lv.json",
"label": "Latvian",
},
}));
} else if (url && url.endsWith("en_EN.json")) {
cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(en)));
} else if (url && url.endsWith("de_DE.json")) {
cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(de)));
} else if (url && url.endsWith("lv.json")) {
cb(undefined, { status: 200 }, JSON.stringify(weblateToCounterpart(lv)));
} else {
cb(true, { status: 404 }, "");
}
});

View file

@ -0,0 +1,117 @@
/*
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.
*/
/// <reference types="cypress" />
import { SynapseInstance } from "../../plugins/synapsedocker";
import type { UserCredentials } from "../../support/login";
describe("Device manager", () => {
let synapse: SynapseInstance | undefined;
let user: UserCredentials | undefined;
beforeEach(() => {
cy.enableLabsFeature("feature_new_device_manager");
cy.startSynapse("default").then(data => {
synapse = data;
cy.initTestUser(synapse, "Alice").then(credentials => {
user = credentials;
}).then(() => {
// create some extra sessions to manage
return cy.loginUser(synapse, user.username, user.password);
}).then(() => {
return cy.loginUser(synapse, user.username, user.password);
});
});
});
afterEach(() => {
cy.stopSynapse(synapse!);
});
it("should display sessions", () => {
cy.openUserSettings("Sessions");
cy.contains('Current session').should('exist');
cy.get('[data-testid="current-session-section"]').within(() => {
cy.contains('Unverified session').should('exist');
cy.get('.mx_DeviceSecurityCard_actions [role="button"]').should('exist');
});
// current session details opened
cy.get('[data-testid="current-session-toggle-details"]').click();
cy.contains('Session details').should('exist');
// close current session details
cy.get('[data-testid="current-session-toggle-details"]').click();
cy.contains('Session details').should('not.exist');
cy.get('[data-testid="security-recommendations-section"]').within(() => {
cy.contains('Security recommendations').should('exist');
cy.get('[data-testid="unverified-devices-cta"]').should('have.text', 'View all (3)').click();
});
/**
* Other sessions section
*/
cy.contains('Other sessions').should('exist');
// filter applied after clicking through from security recommendations
cy.get('[aria-label="Filter devices"]').should('have.text', 'Show: Unverified');
cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 3);
// select two sessions
cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem').first().click();
cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem').last().click();
// sign out from list selection action buttons
cy.get('[data-testid="sign-out-selection-cta"]').click();
// list updated after sign out
cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 1);
// security recommendation count updated
cy.get('[data-testid="unverified-devices-cta"]').should('have.text', 'View all (1)');
const sessionName = `Alice's device`;
// select the first session
cy.get('.mx_FilteredDeviceList_list .mx_FilteredDeviceList_listItem').first().within(() => {
cy.get('[aria-label="Toggle device details"]').click();
cy.contains('Session details').should('exist');
cy.get('[data-testid="device-heading-rename-cta"]').click();
cy.get('[data-testid="device-rename-input"]').type(sessionName);
cy.get('[data-testid="device-rename-submit-cta"]').click();
// there should be a spinner while device updates
cy.get(".mx_Spinner").should("exist");
// wait for spinner to complete
cy.get(".mx_Spinner").should("not.exist");
// session name updated in details
cy.get('.mx_DeviceDetailHeading h3').should('have.text', sessionName);
// and main list item
cy.get('.mx_DeviceTile h4').should('have.text', sessionName);
// sign out using the device details sign out
cy.get('[data-testid="device-detail-sign-out-cta"]').click();
});
// list updated after sign out
cy.get('.mx_FilteredDeviceList_list').find('.mx_FilteredDeviceList_listItem').should('have.length', 1);
// no other sessions or security recommendations sections when only one session
cy.contains('Other sessions').should('not.exist');
cy.get('[data-testid="security-recommendations-section"]').should('not.exist');
});
});

View file

@ -124,7 +124,8 @@ Cypress.Commands.add("startDM", (name: string) => {
cy.get(".mx_BasicMessageComposer_input") cy.get(".mx_BasicMessageComposer_input")
.should("have.focus") .should("have.focus")
.type("Hey!{enter}"); .type("Hey!{enter}");
cy.contains(".mx_EventTile_body", "Hey!"); // The DM room is created at this point, this can take a little bit of time
cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 });
cy.contains(".mx_RoomSublist[aria-label=People]", name); cy.contains(".mx_RoomSublist[aria-label=People]", name);
}); });
@ -217,7 +218,7 @@ describe("Spotlight", () => {
it("should find joined rooms", () => { it("should find joined rooms", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightSearch().clear().type(room1Name); cy.spotlightSearch().clear().type(room1Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).should("contain", room1Name);
cy.spotlightResults().eq(0).click(); cy.spotlightResults().eq(0).click();
@ -231,7 +232,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.PublicRooms); cy.spotlightFilter(Filter.PublicRooms);
cy.spotlightSearch().clear().type(room1Name); cy.spotlightSearch().clear().type(room1Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", room1Name); cy.spotlightResults().eq(0).should("contain", room1Name);
cy.spotlightResults().eq(0).should("contain", "View"); cy.spotlightResults().eq(0).should("contain", "View");
@ -246,7 +247,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.PublicRooms); cy.spotlightFilter(Filter.PublicRooms);
cy.spotlightSearch().clear().type(room2Name); cy.spotlightSearch().clear().type(room2Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", room2Name); cy.spotlightResults().eq(0).should("contain", room2Name);
cy.spotlightResults().eq(0).should("contain", "Join"); cy.spotlightResults().eq(0).should("contain", "Join");
@ -262,7 +263,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.PublicRooms); cy.spotlightFilter(Filter.PublicRooms);
cy.spotlightSearch().clear().type(room3Name); cy.spotlightSearch().clear().type(room3Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", room3Name); cy.spotlightResults().eq(0).should("contain", room3Name);
cy.spotlightResults().eq(0).should("contain", "View"); cy.spotlightResults().eq(0).should("contain", "View");
@ -301,7 +302,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.People); cy.spotlightFilter(Filter.People);
cy.spotlightSearch().clear().type(bot1Name); cy.spotlightSearch().clear().type(bot1Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", bot1Name); cy.spotlightResults().eq(0).should("contain", bot1Name);
cy.spotlightResults().eq(0).click(); cy.spotlightResults().eq(0).click();
@ -314,7 +315,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.People); cy.spotlightFilter(Filter.People);
cy.spotlightSearch().clear().type(bot2Name); cy.spotlightSearch().clear().type(bot2Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).should("contain", bot2Name);
cy.spotlightResults().eq(0).click(); cy.spotlightResults().eq(0).click();
@ -331,7 +332,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.People); cy.spotlightFilter(Filter.People);
cy.spotlightSearch().clear().type(bot2Name); cy.spotlightSearch().clear().type(bot2Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).should("contain", bot2Name);
cy.spotlightResults().eq(0).click(); cy.spotlightResults().eq(0).click();
@ -345,7 +346,7 @@ describe("Spotlight", () => {
.type("Hey!{enter}"); .type("Hey!{enter}");
// Assert DM exists by checking for the first message and the room being in the room list // Assert DM exists by checking for the first message and the room being in the room list
cy.contains(".mx_EventTile_body", "Hey!"); cy.contains(".mx_EventTile_body", "Hey!", { timeout: 30000 });
cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name); cy.get(".mx_RoomSublist[aria-label=People]").should("contain", bot2Name);
// Invite BotBob into existing DM with ByteBot // Invite BotBob into existing DM with ByteBot
@ -409,7 +410,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.People); cy.spotlightFilter(Filter.People);
cy.spotlightSearch().clear().type(bot2Name); cy.spotlightSearch().clear().type(bot2Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
cy.spotlightResults().eq(0).should("contain", bot2Name); cy.spotlightResults().eq(0).should("contain", bot2Name);
cy.get(".mx_SpotlightDialog_startGroupChat").should("contain", "Start a group chat"); cy.get(".mx_SpotlightDialog_startGroupChat").should("contain", "Start a group chat");
@ -431,7 +432,7 @@ describe("Spotlight", () => {
cy.openSpotlightDialog().within(() => { cy.openSpotlightDialog().within(() => {
cy.spotlightFilter(Filter.People); cy.spotlightFilter(Filter.People);
cy.spotlightSearch().clear().type(bot1Name); cy.spotlightSearch().clear().type(bot1Name);
cy.wait(1000); // wait for the dialog code to settle cy.wait(3000); // wait for the dialog code to settle
cy.get(".mx_Spinner").should("not.exist"); cy.get(".mx_Spinner").should("not.exist");
cy.spotlightResults().should("have.length", 1); cy.spotlightResults().should("have.length", 1);
}); });

View file

@ -91,11 +91,11 @@ describe("Timeline", () => {
describe("useOnlyCurrentProfiles", () => { describe("useOnlyCurrentProfiles", () => {
beforeEach(() => { beforeEach(() => {
cy.uploadContent(OLD_AVATAR).then((url) => { cy.uploadContent(OLD_AVATAR).then(({ content_uri: url }) => {
oldAvatarUrl = url; oldAvatarUrl = url;
cy.setAvatarUrl(url); cy.setAvatarUrl(url);
}); });
cy.uploadContent(NEW_AVATAR).then((url) => { cy.uploadContent(NEW_AVATAR).then(({ content_uri: url }) => {
newAvatarUrl = url; newAvatarUrl = url;
}); });
}); });
@ -271,7 +271,7 @@ describe("Timeline", () => {
cy.get(".mx_RoomHeader_searchButton").click(); cy.get(".mx_RoomHeader_searchButton").click();
cy.get(".mx_SearchBar_input input").type("Message{enter}"); cy.get(".mx_SearchBar_input input").type("Message{enter}");
cy.get(".mx_EventTile:not(.mx_EventTile_contextual)").find(".mx_EventTile_searchHighlight").should("exist"); cy.get(".mx_EventTile:not(.mx_EventTile_contextual) .mx_EventTile_searchHighlight").should("exist");
cy.get(".mx_RoomView_searchResultsPanel").percySnapshotElement("Highlighted search results"); cy.get(".mx_RoomView_searchResultsPanel").percySnapshotElement("Highlighted search results");
}); });

View file

@ -16,8 +16,6 @@ limitations under the License.
/// <reference types="cypress" /> /// <reference types="cypress" />
import request from "browser-request";
import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix"; import type { ISendEventResponse, MatrixClient, Room } from "matrix-js-sdk/src/matrix";
import { SynapseInstance } from "../plugins/synapsedocker"; import { SynapseInstance } from "../plugins/synapsedocker";
import Chainable = Cypress.Chainable; import Chainable = Cypress.Chainable;
@ -86,7 +84,6 @@ Cypress.Commands.add("getBot", (synapse: SynapseInstance, opts: CreateBotOpts):
userId: credentials.userId, userId: credentials.userId,
deviceId: credentials.deviceId, deviceId: credentials.deviceId,
accessToken: credentials.accessToken, accessToken: credentials.accessToken,
request,
store: new win.matrixcs.MemoryStore(), store: new win.matrixcs.MemoryStore(),
scheduler: new win.matrixcs.MatrixScheduler(), scheduler: new win.matrixcs.MatrixScheduler(),
cryptoStore: new win.matrixcs.MemoryCryptoStore(), cryptoStore: new win.matrixcs.MemoryCryptoStore(),

View file

@ -16,9 +16,8 @@ limitations under the License.
/// <reference types="cypress" /> /// <reference types="cypress" />
import type { FileType, UploadContentResponseType } from "matrix-js-sdk/src/http-api"; import type { FileType, Upload, UploadOpts } from "matrix-js-sdk/src/http-api";
import type { IAbortablePromise } from "matrix-js-sdk/src/@types/partials"; import type { ICreateRoomOpts, ISendEventResponse } from "matrix-js-sdk/src/@types/requests";
import type { ICreateRoomOpts, ISendEventResponse, IUploadOpts } from "matrix-js-sdk/src/@types/requests";
import type { MatrixClient } from "matrix-js-sdk/src/client"; import type { MatrixClient } from "matrix-js-sdk/src/client";
import type { Room } from "matrix-js-sdk/src/models/room"; import type { Room } from "matrix-js-sdk/src/models/room";
import type { IContent } from "matrix-js-sdk/src/models/event"; import type { IContent } from "matrix-js-sdk/src/models/event";
@ -90,10 +89,10 @@ declare global {
* can be sent to XMLHttpRequest.send (typically a File). Under node.js, * can be sent to XMLHttpRequest.send (typically a File). Under node.js,
* a a Buffer, String or ReadStream. * a a Buffer, String or ReadStream.
*/ */
uploadContent<O extends IUploadOpts>( uploadContent(
file: FileType, file: FileType,
opts?: O, opts?: UploadOpts,
): IAbortablePromise<UploadContentResponseType<O>>; ): Chainable<Awaited<Upload["promise"]>>;
/** /**
* Turn an MXC URL into an HTTP one. <strong>This method is experimental and * Turn an MXC URL into an HTTP one. <strong>This method is experimental and
* may change.</strong> * may change.</strong>
@ -203,9 +202,9 @@ Cypress.Commands.add("setDisplayName", (name: string): Chainable<{}> => {
}); });
}); });
Cypress.Commands.add("uploadContent", (file: FileType): Chainable<{}> => { Cypress.Commands.add("uploadContent", (file: FileType, opts?: UploadOpts): Chainable<Awaited<Upload["promise"]>> => {
return cy.getClient().then(async (cli: MatrixClient) => { return cy.getClient().then(async (cli: MatrixClient) => {
return cli.uploadContent(file); return cli.uploadContent(file, opts);
}); });
}); });

View file

@ -21,6 +21,7 @@ import { SynapseInstance } from "../plugins/synapsedocker";
export interface UserCredentials { export interface UserCredentials {
accessToken: string; accessToken: string;
username: string;
userId: string; userId: string;
deviceId: string; deviceId: string;
password: string; password: string;
@ -42,26 +43,25 @@ declare global {
displayName: string, displayName: string,
prelaunchFn?: () => void, prelaunchFn?: () => void,
): Chainable<UserCredentials>; ): Chainable<UserCredentials>;
/**
* Logs into synapse with the given username/password
* @param synapse the synapse returned by startSynapse
* @param username login username
* @param password login password
*/
loginUser(
synapse: SynapseInstance,
username: string,
password: string,
): Chainable<UserCredentials>;
} }
} }
} }
// eslint-disable-next-line max-len // eslint-disable-next-line max-len
Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string, prelaunchFn?: () => void): Chainable<UserCredentials> => { Cypress.Commands.add("loginUser", (synapse: SynapseInstance, username: string, password: string): Chainable<UserCredentials> => {
// XXX: work around Cypress not clearing IDB between tests const url = `${synapse.baseUrl}/_matrix/client/r0/login`;
cy.window({ log: false }).then(win => { return cy.request<{
win.indexedDB.databases().then(databases => {
databases.forEach(database => {
win.indexedDB.deleteDatabase(database.name);
});
});
});
const username = Cypress._.uniqueId("userId_");
const password = Cypress._.uniqueId("password_");
return cy.registerUser(synapse, username, password, displayName).then(() => {
const url = `${synapse.baseUrl}/_matrix/client/r0/login`;
return cy.request<{
access_token: string; access_token: string;
user_id: string; user_id: string;
device_id: string; device_id: string;
@ -77,14 +77,38 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str
}, },
"password": password, "password": password,
}, },
}).then(response => ({
password,
username,
accessToken: response.body.access_token,
userId: response.body.user_id,
deviceId: response.body.device_id,
homeServer: response.body.home_server,
}));
});
// eslint-disable-next-line max-len
Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: string, prelaunchFn?: () => void): Chainable<UserCredentials> => {
// XXX: work around Cypress not clearing IDB between tests
cy.window({ log: false }).then(win => {
win.indexedDB.databases().then(databases => {
databases.forEach(database => {
win.indexedDB.deleteDatabase(database.name);
});
}); });
});
const username = Cypress._.uniqueId("userId_");
const password = Cypress._.uniqueId("password_");
return cy.registerUser(synapse, username, password, displayName).then(() => {
return cy.loginUser(synapse, username, password);
}).then(response => { }).then(response => {
cy.window({ log: false }).then(win => { cy.window({ log: false }).then(win => {
// Seed the localStorage with the required credentials // Seed the localStorage with the required credentials
win.localStorage.setItem("mx_hs_url", synapse.baseUrl); win.localStorage.setItem("mx_hs_url", synapse.baseUrl);
win.localStorage.setItem("mx_user_id", response.body.user_id); win.localStorage.setItem("mx_user_id", response.userId);
win.localStorage.setItem("mx_access_token", response.body.access_token); win.localStorage.setItem("mx_access_token", response.accessToken);
win.localStorage.setItem("mx_device_id", response.body.device_id); win.localStorage.setItem("mx_device_id", response.deviceId);
win.localStorage.setItem("mx_is_guest", "false"); win.localStorage.setItem("mx_is_guest", "false");
win.localStorage.setItem("mx_has_pickle_key", "false"); win.localStorage.setItem("mx_has_pickle_key", "false");
win.localStorage.setItem("mx_has_access_token", "true"); win.localStorage.setItem("mx_has_access_token", "true");
@ -100,10 +124,11 @@ Cypress.Commands.add("initTestUser", (synapse: SynapseInstance, displayName: str
return cy.get(".mx_MatrixChat", { timeout: 30000 }); return cy.get(".mx_MatrixChat", { timeout: 30000 });
}).then(() => ({ }).then(() => ({
password, password,
accessToken: response.body.access_token, username,
userId: response.body.user_id, accessToken: response.accessToken,
deviceId: response.body.device_id, userId: response.userId,
homeServer: response.body.home_server, deviceId: response.deviceId,
homeServer: response.homeServer,
})); }));
}); });
}); });

View file

@ -110,7 +110,7 @@ Features can be controlled at the config level using the following structure:
``` ```
When `true`, the user will see the feature as enabled. Similarly, when `false` the user will see the feature as disabled. When `true`, the user will see the feature as enabled. Similarly, when `false` the user will see the feature as disabled.
The user will only be able to change/see these states if `showLabsSettings: true` is in the config. The user will only be able to change/see these states if `show_labs_settings: true` is in the config.
### Determining if a feature is enabled ### Determining if a feature is enabled

View file

@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.57.0", "version": "3.58.1",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@ -65,7 +65,6 @@
"@types/ua-parser-js": "^0.7.36", "@types/ua-parser-js": "^0.7.36",
"await-lock": "^2.1.0", "await-lock": "^2.1.0",
"blurhash": "^1.1.3", "blurhash": "^1.1.3",
"browser-request": "^0.3.3",
"cheerio": "^1.0.0-rc.9", "cheerio": "^1.0.0-rc.9",
"classnames": "^2.2.6", "classnames": "^2.2.6",
"commonmark": "^0.29.3", "commonmark": "^0.29.3",
@ -190,17 +189,16 @@
"eslint-plugin-matrix-org": "^0.6.1", "eslint-plugin-matrix-org": "^0.6.1",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.3.0",
"fetch-mock-jest": "^1.5.1",
"fs-extra": "^10.0.1", "fs-extra": "^10.0.1",
"glob": "^7.1.6", "glob": "^7.1.6",
"jest": "^27.4.0", "jest": "^27.4.0",
"jest-canvas-mock": "^2.3.0", "jest-canvas-mock": "^2.3.0",
"jest-environment-jsdom": "^27.0.6", "jest-environment-jsdom": "^27.0.6",
"jest-fetch-mock": "^3.0.3",
"jest-mock": "^27.5.1", "jest-mock": "^27.5.1",
"jest-raw-loader": "^1.0.1", "jest-raw-loader": "^1.0.1",
"jest-sonar-reporter": "^2.0.0", "jest-sonar-reporter": "^2.0.0",
"matrix-mock-request": "^2.0.0", "matrix-mock-request": "^2.5.0",
"matrix-react-test-utils": "^0.2.3",
"matrix-web-i18n": "^1.3.0", "matrix-web-i18n": "^1.3.0",
"postcss-scss": "^4.0.4", "postcss-scss": "^4.0.4",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",

View file

@ -17,6 +17,7 @@
@import "./components/views/beacon/_RoomLiveShareWarning.pcss"; @import "./components/views/beacon/_RoomLiveShareWarning.pcss";
@import "./components/views/beacon/_ShareLatestLocation.pcss"; @import "./components/views/beacon/_ShareLatestLocation.pcss";
@import "./components/views/beacon/_StyledLiveBeaconIcon.pcss"; @import "./components/views/beacon/_StyledLiveBeaconIcon.pcss";
@import "./components/views/context_menus/_KebabContextMenu.pcss";
@import "./components/views/elements/_FilterDropdown.pcss"; @import "./components/views/elements/_FilterDropdown.pcss";
@import "./components/views/location/_EnableLiveShare.pcss"; @import "./components/views/location/_EnableLiveShare.pcss";
@import "./components/views/location/_LiveDurationDropdown.pcss"; @import "./components/views/location/_LiveDurationDropdown.pcss";
@ -40,6 +41,7 @@
@import "./components/views/settings/devices/_SecurityRecommendations.pcss"; @import "./components/views/settings/devices/_SecurityRecommendations.pcss";
@import "./components/views/settings/devices/_SelectableDeviceTile.pcss"; @import "./components/views/settings/devices/_SelectableDeviceTile.pcss";
@import "./components/views/settings/shared/_SettingsSubsection.pcss"; @import "./components/views/settings/shared/_SettingsSubsection.pcss";
@import "./components/views/settings/shared/_SettingsSubsectionHeading.pcss";
@import "./components/views/spaces/_QuickThemeSwitcher.pcss"; @import "./components/views/spaces/_QuickThemeSwitcher.pcss";
@import "./components/views/typography/_Caption.pcss"; @import "./components/views/typography/_Caption.pcss";
@import "./structures/_AutoHideScrollbar.pcss"; @import "./structures/_AutoHideScrollbar.pcss";
@ -364,4 +366,5 @@
@import "./views/voip/_PiPContainer.pcss"; @import "./views/voip/_PiPContainer.pcss";
@import "./views/voip/_VideoFeed.pcss"; @import "./views/voip/_VideoFeed.pcss";
@import "./voice-broadcast/atoms/_LiveBadge.pcss"; @import "./voice-broadcast/atoms/_LiveBadge.pcss";
@import "./voice-broadcast/atoms/_VoiceBroadcastHeader.pcss";
@import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss"; @import "./voice-broadcast/molecules/_VoiceBroadcastRecordingBody.pcss";

View file

@ -15,9 +15,13 @@ limitations under the License.
*/ */
.mx_Icon { .mx_Icon {
box-sizing: border-box;
display: inline-block;
mask-origin: content-box;
mask-position: center; mask-position: center;
mask-repeat: no-repeat; mask-repeat: no-repeat;
mask-size: contain; mask-size: contain;
padding: 1px;
} }
.mx_Icon_16 { .mx_Icon_16 {
@ -32,3 +36,7 @@ limitations under the License.
.mx_Icon_live-badge { .mx_Icon_live-badge {
background-color: #fff; background-color: #fff;
} }
.mx_Icon_compound-secondary-content {
background-color: $secondary-content;
}

View file

@ -14,4 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export * from "./VoiceBroadcastRecordingsStore"; .mx_KebabContextMenu_icon {
width: 24px;
color: $secondary-content;
}

View file

@ -19,10 +19,6 @@ limitations under the License.
box-sizing: border-box; box-sizing: border-box;
} }
.mx_SettingsSubsection_heading {
padding-bottom: $spacing-8;
}
.mx_SettingsSubsection_description { .mx_SettingsSubsection_description {
width: 100%; width: 100%;
box-sizing: inherit; box-sizing: inherit;

View file

@ -14,13 +14,14 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
module.exports = { .mx_SettingsSubsectionHeading {
env: { display: flex;
mocha: true, flex-direction: row;
}, padding-bottom: $spacing-8;
// mocha defines a 'this' gap: $spacing-8;
rules: { }
"@babel/no-invalid-this": "off",
}, .mx_SettingsSubsectionHeading_heading {
}; flex: 1 1 100%;
}

View file

@ -82,7 +82,8 @@ limitations under the License.
display: flex; display: flex;
align-items: center; align-items: center;
&:hover { &:hover,
&:focus {
background-color: $menu-selected-color; background-color: $menu-selected-color;
} }
@ -187,3 +188,7 @@ limitations under the License.
color: $tertiary-content; color: $tertiary-content;
} }
} }
.mx_IconizedContextMenu_item.mx_IconizedContextMenu_itemDestructive {
color: $alert !important;
}

View file

@ -0,0 +1,44 @@
/*
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.
*/
.mx_VoiceBroadcastHeader {
align-items: flex-start;
display: flex;
gap: $spacing-8;
line-height: 20px;
margin-bottom: $spacing-8;
width: 266px;
}
.mx_VoiceBroadcastHeader_sender {
font-size: $font-12px;
font-weight: $font-semi-bold;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_VoiceBroadcastHeader_room {
font-size: $font-12px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.mx_VoiceBroadcastHeader_line {
align-items: center;
color: $secondary-content;
font-size: $font-12px;
display: flex;
gap: $spacing-4;
}

View file

@ -1,5 +1,7 @@
#!/bin/bash #!/bin/bash
set -ex
# Creates a layered environment with the full repo for the app and SDKs cloned # Creates a layered environment with the full repo for the app and SDKs cloned
# and linked. This gives an element-web dev environment ready to build with # and linked. This gives an element-web dev environment ready to build with
# the current react-sdk branch and any matching branches of react-sdk's dependencies # the current react-sdk branch and any matching branches of react-sdk's dependencies

View file

@ -85,7 +85,7 @@ export default class AddThreepid {
const identityAccessToken = await authClient.getAccessToken(); const identityAccessToken = await authClient.getAccessToken();
return MatrixClientPeg.get().requestEmailToken( return MatrixClientPeg.get().requestEmailToken(
emailAddress, this.clientSecret, 1, emailAddress, this.clientSecret, 1,
undefined, undefined, identityAccessToken, undefined, identityAccessToken,
).then((res) => { ).then((res) => {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;
@ -142,7 +142,7 @@ export default class AddThreepid {
const identityAccessToken = await authClient.getAccessToken(); const identityAccessToken = await authClient.getAccessToken();
return MatrixClientPeg.get().requestMsisdnToken( return MatrixClientPeg.get().requestMsisdnToken(
phoneCountry, phoneNumber, this.clientSecret, 1, phoneCountry, phoneNumber, this.clientSecret, 1,
undefined, undefined, identityAccessToken, undefined, identityAccessToken,
).then((res) => { ).then((res) => {
this.sessionId = res.sid; this.sessionId = res.sid;
return res; return res;

View file

@ -17,16 +17,16 @@ limitations under the License.
*/ */
import { MatrixClient } from "matrix-js-sdk/src/client"; import { MatrixClient } from "matrix-js-sdk/src/client";
import { IUploadOpts } from "matrix-js-sdk/src/@types/requests";
import { MsgType } from "matrix-js-sdk/src/@types/event"; import { MsgType } from "matrix-js-sdk/src/@types/event";
import encrypt from "matrix-encrypt-attachment"; import encrypt from "matrix-encrypt-attachment";
import extractPngChunks from "png-chunks-extract"; import extractPngChunks from "png-chunks-extract";
import { IAbortablePromise, IImageInfo } from "matrix-js-sdk/src/@types/partials"; import { IImageInfo } from "matrix-js-sdk/src/@types/partials";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { IEventRelation, ISendEventResponse, MatrixError, MatrixEvent } from "matrix-js-sdk/src/matrix"; import { IEventRelation, ISendEventResponse, MatrixEvent, UploadOpts, UploadProgress } from "matrix-js-sdk/src/matrix";
import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread"; import { THREAD_RELATION_TYPE } from "matrix-js-sdk/src/models/thread";
import { removeElement } from "matrix-js-sdk/src/utils";
import { IEncryptedFile, IMediaEventInfo } from "./customisations/models/IMediaEventContent"; import { IEncryptedFile, IMediaEventContent, IMediaEventInfo } from "./customisations/models/IMediaEventContent";
import dis from './dispatcher/dispatcher'; import dis from './dispatcher/dispatcher';
import { _t } from './languageHandler'; import { _t } from './languageHandler';
import Modal from './Modal'; import Modal from './Modal';
@ -39,7 +39,7 @@ import {
UploadProgressPayload, UploadProgressPayload,
UploadStartedPayload, UploadStartedPayload,
} from "./dispatcher/payloads/UploadPayload"; } from "./dispatcher/payloads/UploadPayload";
import { IUpload } from "./models/IUpload"; import { RoomUpload } from "./models/RoomUpload";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics"; import { decorateStartSendingTime, sendRoundTripMetric } from "./sendTimePerformanceMetrics";
import { TimelineRenderingType } from "./contexts/RoomContext"; import { TimelineRenderingType } from "./contexts/RoomContext";
@ -62,14 +62,6 @@ interface IMediaConfig {
"m.upload.size"?: number; "m.upload.size"?: number;
} }
interface IContent {
body: string;
msgtype: string;
info: IMediaEventInfo;
file?: string;
url?: string;
}
/** /**
* Load a file into a newly created image element. * Load a file into a newly created image element.
* *
@ -78,7 +70,7 @@ interface IContent {
*/ */
async function loadImageElement(imageFile: File) { async function loadImageElement(imageFile: File) {
// Load the file into an html element // Load the file into an html element
const img = document.createElement("img"); const img = new Image();
const objectUrl = URL.createObjectURL(imageFile); const objectUrl = URL.createObjectURL(imageFile);
const imgPromise = new Promise((resolve, reject) => { const imgPromise = new Promise((resolve, reject) => {
img.onload = function() { img.onload = function() {
@ -93,7 +85,7 @@ async function loadImageElement(imageFile: File) {
// check for hi-dpi PNGs and fudge display resolution as needed. // check for hi-dpi PNGs and fudge display resolution as needed.
// this is mainly needed for macOS screencaps // this is mainly needed for macOS screencaps
let parsePromise; let parsePromise: Promise<boolean>;
if (imageFile.type === "image/png") { if (imageFile.type === "image/png") {
// in practice macOS happens to order the chunks so they fall in // in practice macOS happens to order the chunks so they fall in
// the first 0x1000 bytes (thanks to a massive ICC header). // the first 0x1000 bytes (thanks to a massive ICC header).
@ -277,71 +269,58 @@ function readFileAsArrayBuffer(file: File | Blob): Promise<ArrayBuffer> {
* @param {File} file The file to upload. * @param {File} file The file to upload.
* @param {Function?} progressHandler optional callback to be called when a chunk of * @param {Function?} progressHandler optional callback to be called when a chunk of
* data is uploaded. * data is uploaded.
* @param {AbortController?} controller optional abortController to use for this upload.
* @return {Promise} A promise that resolves with an object. * @return {Promise} A promise that resolves with an object.
* If the file is unencrypted then the object will have a "url" key. * If the file is unencrypted then the object will have a "url" key.
* If the file is encrypted then the object will have a "file" key. * If the file is encrypted then the object will have a "file" key.
*/ */
export function uploadFile( export async function uploadFile(
matrixClient: MatrixClient, matrixClient: MatrixClient,
roomId: string, roomId: string,
file: File | Blob, file: File | Blob,
progressHandler?: IUploadOpts["progressHandler"], progressHandler?: UploadOpts["progressHandler"],
): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> { controller?: AbortController,
let canceled = false; ): Promise<{ url?: string, file?: IEncryptedFile }> {
const abortController = controller ?? new AbortController();
// If the room is encrypted then encrypt the file before uploading it.
if (matrixClient.isRoomEncrypted(roomId)) { if (matrixClient.isRoomEncrypted(roomId)) {
// If the room is encrypted then encrypt the file before uploading it.
// First read the file into memory. // First read the file into memory.
let uploadPromise: IAbortablePromise<string>; const data = await readFileAsArrayBuffer(file);
const prom = readFileAsArrayBuffer(file).then(function(data) { if (abortController.signal.aborted) throw new UploadCanceledError();
if (canceled) throw new UploadCanceledError();
// Then encrypt the file.
return encrypt.encryptAttachment(data);
}).then(function(encryptResult) {
if (canceled) throw new UploadCanceledError();
// Pass the encrypted data as a Blob to the uploader. // Then encrypt the file.
const blob = new Blob([encryptResult.data]); const encryptResult = await encrypt.encryptAttachment(data);
uploadPromise = matrixClient.uploadContent(blob, { if (abortController.signal.aborted) throw new UploadCanceledError();
progressHandler,
includeFilename: false,
});
return uploadPromise.then(url => { // Pass the encrypted data as a Blob to the uploader.
if (canceled) throw new UploadCanceledError(); const blob = new Blob([encryptResult.data]);
// If the attachment is encrypted then bundle the URL along const { content_uri: url } = await matrixClient.uploadContent(blob, {
// with the information needed to decrypt the attachment and progressHandler,
// add it under a file key. abortController,
return { includeFilename: false,
file: { });
...encryptResult.info, if (abortController.signal.aborted) throw new UploadCanceledError();
url,
}, // If the attachment is encrypted then bundle the URL along with the information
}; // needed to decrypt the attachment and add it under a file key.
}); return {
}) as IAbortablePromise<{ file: IEncryptedFile }>; file: {
prom.abort = () => { ...encryptResult.info,
canceled = true; url,
if (uploadPromise) matrixClient.cancelUpload(uploadPromise); } as IEncryptedFile,
}; };
return prom;
} else { } else {
const basePromise = matrixClient.uploadContent(file, { progressHandler }); const { content_uri: url } = await matrixClient.uploadContent(file, { progressHandler, abortController });
const promise1 = basePromise.then(function(url) { if (abortController.signal.aborted) throw new UploadCanceledError();
if (canceled) throw new UploadCanceledError(); // If the attachment isn't encrypted then include the URL directly.
// If the attachment isn't encrypted then include the URL directly. return { url };
return { url };
}) as IAbortablePromise<{ url: string }>;
promise1.abort = () => {
canceled = true;
matrixClient.cancelUpload(basePromise);
};
return promise1;
} }
} }
export default class ContentMessages { export default class ContentMessages {
private inprogress: IUpload[] = []; private inprogress: RoomUpload[] = [];
private mediaConfig: IMediaConfig = null; private mediaConfig: IMediaConfig = null;
public sendStickerContentToRoom( public sendStickerContentToRoom(
@ -460,36 +439,33 @@ export default class ContentMessages {
}); });
} }
public getCurrentUploads(relation?: IEventRelation): IUpload[] { public getCurrentUploads(relation?: IEventRelation): RoomUpload[] {
return this.inprogress.filter(upload => { return this.inprogress.filter(roomUpload => {
const noRelation = !relation && !upload.relation; const noRelation = !relation && !roomUpload.relation;
const matchingRelation = relation && upload.relation const matchingRelation = relation && roomUpload.relation
&& relation.rel_type === upload.relation.rel_type && relation.rel_type === roomUpload.relation.rel_type
&& relation.event_id === upload.relation.event_id; && relation.event_id === roomUpload.relation.event_id;
return (noRelation || matchingRelation) && !upload.canceled; return (noRelation || matchingRelation) && !roomUpload.cancelled;
}); });
} }
public cancelUpload(promise: IAbortablePromise<any>, matrixClient: MatrixClient): void { public cancelUpload(upload: RoomUpload): void {
const upload = this.inprogress.find(item => item.promise === promise); upload.abort();
if (upload) { dis.dispatch<UploadCanceledPayload>({ action: Action.UploadCanceled, upload });
upload.canceled = true;
matrixClient.cancelUpload(upload.promise);
dis.dispatch<UploadCanceledPayload>({ action: Action.UploadCanceled, upload });
}
} }
private sendContentToRoom( public async sendContentToRoom(
file: File, file: File,
roomId: string, roomId: string,
relation: IEventRelation | undefined, relation: IEventRelation | undefined,
matrixClient: MatrixClient, matrixClient: MatrixClient,
replyToEvent: MatrixEvent | undefined, replyToEvent: MatrixEvent | undefined,
promBefore: Promise<any>, promBefore?: Promise<any>,
) { ) {
const content: Omit<IContent, "info"> & { info: Partial<IMediaEventInfo> } = { const fileName = file.name || _t("Attachment");
body: file.name || 'Attachment', const content: Omit<IMediaEventContent, "info"> & { info: Partial<IMediaEventInfo> } = {
body: fileName,
info: { info: {
size: file.size, size: file.size,
}, },
@ -512,91 +488,72 @@ export default class ContentMessages {
content.info.mimetype = file.type; content.info.mimetype = file.type;
} }
const prom = new Promise<void>((resolve) => { const upload = new RoomUpload(roomId, fileName, relation, file.size);
if (file.type.indexOf('image/') === 0) {
content.msgtype = MsgType.Image;
infoForImageFile(matrixClient, roomId, file).then((imageInfo) => {
Object.assign(content.info, imageInfo);
resolve();
}, (e) => {
// Failed to thumbnail, fall back to uploading an m.file
logger.error(e);
content.msgtype = MsgType.File;
resolve();
});
} else if (file.type.indexOf('audio/') === 0) {
content.msgtype = MsgType.Audio;
resolve();
} else if (file.type.indexOf('video/') === 0) {
content.msgtype = MsgType.Video;
infoForVideoFile(matrixClient, roomId, file).then((videoInfo) => {
Object.assign(content.info, videoInfo);
resolve();
}, (e) => {
// Failed to thumbnail, fall back to uploading an m.file
logger.error(e);
content.msgtype = MsgType.File;
resolve();
});
} else {
content.msgtype = MsgType.File;
resolve();
}
}) as IAbortablePromise<void>;
// create temporary abort handler for before the actual upload gets passed off to js-sdk
prom.abort = () => {
upload.canceled = true;
};
const upload: IUpload = {
fileName: file.name || 'Attachment',
roomId,
relation,
total: file.size,
loaded: 0,
promise: prom,
};
this.inprogress.push(upload); this.inprogress.push(upload);
dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload }); dis.dispatch<UploadStartedPayload>({ action: Action.UploadStarted, upload });
function onProgress(ev) { function onProgress(progress: UploadProgress) {
upload.total = ev.total; upload.onProgress(progress);
upload.loaded = ev.loaded;
dis.dispatch<UploadProgressPayload>({ action: Action.UploadProgress, upload }); dis.dispatch<UploadProgressPayload>({ action: Action.UploadProgress, upload });
} }
let error: MatrixError; try {
return prom.then(() => { if (file.type.startsWith('image/')) {
if (upload.canceled) throw new UploadCanceledError(); content.msgtype = MsgType.Image;
// XXX: upload.promise must be the promise that try {
// is returned by uploadFile as it has an abort() const imageInfo = await infoForImageFile(matrixClient, roomId, file);
// method hacked onto it. Object.assign(content.info, imageInfo);
upload.promise = uploadFile(matrixClient, roomId, file, onProgress); } catch (e) {
return upload.promise.then(function(result) { // Failed to thumbnail, fall back to uploading an m.file
content.file = result.file; logger.error(e);
content.url = result.url; content.msgtype = MsgType.File;
}); }
}).then(() => { } else if (file.type.indexOf('audio/') === 0) {
// Await previous message being sent into the room content.msgtype = MsgType.Audio;
return promBefore; } else if (file.type.indexOf('video/') === 0) {
}).then(function() { content.msgtype = MsgType.Video;
if (upload.canceled) throw new UploadCanceledError(); try {
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name const videoInfo = await infoForVideoFile(matrixClient, roomId, file);
? relation.event_id Object.assign(content.info, videoInfo);
: null; } catch (e) {
const prom = matrixClient.sendMessage(roomId, threadId, content); // Failed to thumbnail, fall back to uploading an m.file
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { logger.error(e);
prom.then(resp => { content.msgtype = MsgType.File;
sendRoundTripMetric(matrixClient, roomId, resp.event_id); }
}); } else {
content.msgtype = MsgType.File;
} }
return prom;
}, function(err: MatrixError) { if (upload.cancelled) throw new UploadCanceledError();
error = err; const result = await uploadFile(matrixClient, roomId, file, onProgress, upload.abortController);
if (!upload.canceled) { content.file = result.file;
content.url = result.url;
if (upload.cancelled) throw new UploadCanceledError();
// Await previous message being sent into the room
if (promBefore) await promBefore;
if (upload.cancelled) throw new UploadCanceledError();
const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null;
const response = await matrixClient.sendMessage(roomId, threadId, content);
if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) {
sendRoundTripMetric(matrixClient, roomId, response.event_id);
}
dis.dispatch<UploadFinishedPayload>({ action: Action.UploadFinished, upload });
dis.dispatch({ action: 'message_sent' });
} catch (error) {
// 413: File was too big or upset the server in some way:
// clear the media size limit so we fetch it again next time we try to upload
if (error?.httpStatus === 413) {
this.mediaConfig = null;
}
if (!upload.cancelled) {
let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName }); let desc = _t("The file '%(fileName)s' failed to upload.", { fileName: upload.fileName });
if (err.httpStatus === 413) { if (error.httpStatus === 413) {
desc = _t( desc = _t(
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
{ fileName: upload.fileName }, { fileName: upload.fileName },
@ -606,27 +563,11 @@ export default class ContentMessages {
title: _t('Upload Failed'), title: _t('Upload Failed'),
description: desc, description: desc,
}); });
}
}).finally(() => {
for (let i = 0; i < this.inprogress.length; ++i) {
if (this.inprogress[i].promise === upload.promise) {
this.inprogress.splice(i, 1);
break;
}
}
if (error) {
// 413: File was too big or upset the server in some way:
// clear the media size limit so we fetch it again next time
// we try to upload
if (error?.httpStatus === 413) {
this.mediaConfig = null;
}
dis.dispatch<UploadErrorPayload>({ action: Action.UploadFailed, upload, error }); dis.dispatch<UploadErrorPayload>({ action: Action.UploadFailed, upload, error });
} else {
dis.dispatch<UploadFinishedPayload>({ action: Action.UploadFinished, upload });
dis.dispatch({ action: 'message_sent' });
} }
}); } finally {
removeElement(this.inprogress, e => e.promise === upload.promise);
}
} }
private isFileSizeAcceptable(file: File) { private isFileSizeAcceptable(file: File) {

View file

@ -141,9 +141,6 @@ export interface IConfigOptions {
servers: string[]; servers: string[];
}; };
piwik?: false | {
policy_url: string; // deprecated in favour of `privacy_policy_url` at root instead
};
posthog?: { posthog?: {
project_api_key: string; project_api_key: string;
api_host: string; // hostname api_host: string; // hostname
@ -181,6 +178,11 @@ export interface IConfigOptions {
sync_timeline_limit?: number; sync_timeline_limit?: number;
dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option dangerously_allow_unsafe_and_insecure_passwords?: boolean; // developer option
voice_broadcast?: {
// length per voice chunk in seconds
chunk_length?: number;
};
} }
export interface ISsoRedirectOptions { export interface ISsoRedirectOptions {

View file

@ -739,7 +739,7 @@ export function logout(): void {
_isLoggingOut = true; _isLoggingOut = true;
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId()); PlatformPeg.get().destroyPickleKey(client.getUserId(), client.getDeviceId());
client.logout(undefined, true).then(onLoggedOut, (err) => { client.logout(true).then(onLoggedOut, (err) => {
// Just throwing an error here is going to be very unhelpful // Just throwing an error here is going to be very unhelpful
// if you're trying to log out because your server's down and // if you're trying to log out because your server's down and
// you want to log into a different server, so just forget the // you want to log into a different server, so just forget the

View file

@ -169,7 +169,7 @@ export default class Login {
* @param {string} loginType the type of login to do * @param {string} loginType the type of login to do
* @param {ILoginParams} loginParams the parameters for the login * @param {ILoginParams} loginParams the parameters for the login
* *
* @returns {MatrixClientCreds} * @returns {IMatrixClientCreds}
*/ */
export async function sendLoginRequest( export async function sendLoginRequest(
hsUrl: string, hsUrl: string,

View file

@ -431,6 +431,7 @@ export const Notifier = {
const room = MatrixClientPeg.get().getRoom(roomId); const room = MatrixClientPeg.get().getRoom(roomId);
const actions = MatrixClientPeg.get().getPushActionsForEvent(ev); const actions = MatrixClientPeg.get().getPushActionsForEvent(ev);
if (actions?.notify) { if (actions?.notify) {
this._performCustomEventHandling(ev); this._performCustomEventHandling(ev);

View file

@ -15,10 +15,10 @@ limitations under the License.
*/ */
import url from 'url'; import url from 'url';
import request from "browser-request";
import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types"; import { SERVICE_TYPES } from "matrix-js-sdk/src/service-types";
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { IOpenIDToken } from 'matrix-js-sdk/src/matrix';
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms'; import { Service, startTermsFlow, TermsInteractionCallback, TermsNotSignedError } from './Terms';
@ -103,29 +103,29 @@ export default class ScalarAuthClient {
} }
} }
private getAccountName(token: string): Promise<string> { private async getAccountName(token: string): Promise<string> {
const url = this.apiUrl + "/account"; const url = new URL(this.apiUrl + "/account");
url.searchParams.set("scalar_token", token);
url.searchParams.set("v", imApiVersion);
return new Promise(function(resolve, reject) { const res = await fetch(url, {
request({ method: "GET",
method: "GET",
uri: url,
qs: { scalar_token: token, v: imApiVersion },
json: true,
}, (err, response, body) => {
if (err) {
reject(err);
} else if (body && body.errcode === 'M_TERMS_NOT_SIGNED') {
reject(new TermsNotSignedError());
} else if (response.statusCode / 100 !== 2) {
reject(body);
} else if (!body || !body.user_id) {
reject(new Error("Missing user_id in response"));
} else {
resolve(body.user_id);
}
});
}); });
const body = await res.json();
if (body?.errcode === "M_TERMS_NOT_SIGNED") {
throw new TermsNotSignedError();
}
if (!res.ok) {
throw body;
}
if (!body?.user_id) {
throw new Error("Missing user_id in response");
}
return body.user_id;
} }
private checkToken(token: string): Promise<string> { private checkToken(token: string): Promise<string> {
@ -183,56 +183,41 @@ export default class ScalarAuthClient {
}); });
} }
exchangeForScalarToken(openidTokenObject: any): Promise<string> { public async exchangeForScalarToken(openidTokenObject: IOpenIDToken): Promise<string> {
const scalarRestUrl = this.apiUrl; const scalarRestUrl = new URL(this.apiUrl + "/register");
scalarRestUrl.searchParams.set("v", imApiVersion);
return new Promise(function(resolve, reject) { const res = await fetch(scalarRestUrl, {
request({ method: "POST",
method: 'POST', body: JSON.stringify(openidTokenObject),
uri: scalarRestUrl + '/register',
qs: { v: imApiVersion },
body: openidTokenObject,
json: true,
}, (err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body || !body.scalar_token) {
reject(new Error("Missing scalar_token in response"));
} else {
resolve(body.scalar_token);
}
});
}); });
if (!res.ok) {
throw new Error(`Scalar request failed: ${res.status}`);
}
const body = await res.json();
if (!body?.scalar_token) {
throw new Error("Missing scalar_token in response");
}
return body.scalar_token;
} }
getScalarPageTitle(url: string): Promise<string> { public async getScalarPageTitle(url: string): Promise<string> {
let scalarPageLookupUrl = this.apiUrl + '/widgets/title_lookup'; const scalarPageLookupUrl = new URL(this.getStarterLink(this.apiUrl + '/widgets/title_lookup'));
scalarPageLookupUrl = this.getStarterLink(scalarPageLookupUrl); scalarPageLookupUrl.searchParams.set("curl", encodeURIComponent(url));
scalarPageLookupUrl += '&curl=' + encodeURIComponent(url);
return new Promise(function(resolve, reject) { const res = await fetch(scalarPageLookupUrl, {
request({ method: "GET",
method: 'GET',
uri: scalarPageLookupUrl,
json: true,
}, (err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) {
reject(new Error("Missing page title in response"));
} else {
let title = "";
if (body.page_title_cache_item && body.page_title_cache_item.cached_title) {
title = body.page_title_cache_item.cached_title;
}
resolve(title);
}
});
}); });
if (!res.ok) {
throw new Error(`Scalar request failed: ${res.status}`);
}
const body = await res.json();
return body?.page_title_cache_item?.cached_title;
} }
/** /**
@ -243,31 +228,24 @@ export default class ScalarAuthClient {
* @param {string} widgetId The widget ID to disable assets for * @param {string} widgetId The widget ID to disable assets for
* @return {Promise} Resolves on completion * @return {Promise} Resolves on completion
*/ */
disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise<void> { public async disableWidgetAssets(widgetType: WidgetType, widgetId: string): Promise<void> {
let url = this.apiUrl + '/widgets/set_assets_state'; const url = new URL(this.getStarterLink(this.apiUrl + "/widgets/set_assets_state"));
url = this.getStarterLink(url); url.searchParams.set("widget_type", widgetType.preferred);
return new Promise<void>((resolve, reject) => { url.searchParams.set("widget_id", widgetId);
request({ url.searchParams.set("state", "disable");
method: 'GET', // XXX: Actions shouldn't be GET requests
uri: url, const res = await fetch(url, {
json: true, method: "GET", // XXX: Actions shouldn't be GET requests
qs: {
'widget_type': widgetType.preferred,
'widget_id': widgetId,
'state': 'disable',
},
}, (err, response, body) => {
if (err) {
reject(err);
} else if (response.statusCode / 100 !== 2) {
reject(new Error(`Scalar request failed: ${response.statusCode}`));
} else if (!body) {
reject(new Error("Failed to set widget assets state"));
} else {
resolve();
}
});
}); });
if (!res.ok) {
throw new Error(`Scalar request failed: ${res.status}`);
}
const body = await res.text();
if (!body) {
throw new Error("Failed to set widget assets state");
}
} }
getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string { getScalarInterfaceUrlForRoom(room: Room, screen: string, id: string): string {

View file

@ -46,6 +46,9 @@ export const DEFAULTS: IConfigOptions = {
logo: require("../res/img/element-desktop-logo.svg").default, logo: require("../res/img/element-desktop-logo.svg").default,
url: "https://element.io/get-started", url: "https://element.io/get-started",
}, },
voice_broadcast: {
chunk_length: 60 * 1000, // one minute
},
}; };
export default class SdkConfig { export default class SdkConfig {

View file

@ -1104,12 +1104,13 @@ export const Commands = [
MatrixClientPeg.get().forceDiscardSession(roomId); MatrixClientPeg.get().forceDiscardSession(roomId);
// noinspection JSIgnoredPromiseFromCall return success(room.getEncryptionTargetMembers().then(members => {
MatrixClientPeg.get().crypto.ensureOlmSessionsForUsers(room.getMembers().map(m => m.userId), true); // noinspection JSIgnoredPromiseFromCall
MatrixClientPeg.get().crypto.ensureOlmSessionsForUsers(members.map(m => m.userId), true);
}));
} catch (e) { } catch (e) {
return reject(e.message); return reject(e.message);
} }
return success();
}, },
category: CommandCategories.advanced, category: CommandCategories.advanced,
renderingTypes: [TimelineRenderingType.Room], renderingTypes: [TimelineRenderingType.Room],

View file

@ -14,5 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export * from "./shouldDisplayAsVoiceBroadcastTile"; import { UserTab } from "../../components/views/dialogs/UserTab";
export * from "./startNewVoiceBroadcastRecording"; import { Action } from "../../dispatcher/actions";
import defaultDispatcher from "../../dispatcher/dispatcher";
/**
* Redirect to the correct device manager section
* Based on the labs setting
*/
export const viewUserDeviceSettings = (isNewDeviceManagerEnabled: boolean) => {
defaultDispatcher.dispatch({
action: Action.ViewUserSettings,
initialTabId: isNewDeviceManagerEnabled ? UserTab.SessionManager : UserTab.Security,
});
};

View file

@ -203,9 +203,7 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
// In testing, recorder time and worker time lag by about 400ms, which is roughly the // In testing, recorder time and worker time lag by about 400ms, which is roughly the
// time needed to encode a sample/frame. // time needed to encode a sample/frame.
// //
// Ref for recorderSeconds: https://github.com/chris-rudmin/opus-recorder#instance-fields const secondsLeft = TARGET_MAX_LENGTH - this.recorderSeconds;
const recorderSeconds = this.recorder.encodedSamplePosition / 48000;
const secondsLeft = TARGET_MAX_LENGTH - recorderSeconds;
if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame if (secondsLeft < 0) { // go over to make sure we definitely capture that last frame
// noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping // noinspection JSIgnoredPromiseFromCall - we aren't concerned with it overlapping
this.stop(); this.stop();
@ -217,6 +215,13 @@ export class VoiceRecording extends EventEmitter implements IDestroyable {
} }
}; };
/**
* {@link https://github.com/chris-rudmin/opus-recorder#instance-fields ref for recorderSeconds}
*/
public get recorderSeconds() {
return this.recorder.encodedSamplePosition / 48000;
}
public async start(): Promise<void> { public async start(): Promise<void> {
if (this.recording) { if (this.recording) {
throw new Error("Recording already in progress"); throw new Error("Recording already in progress");

View file

@ -29,6 +29,7 @@ const iconTypeMap = new Map([
export enum IconColour { export enum IconColour {
Accent = "accent", Accent = "accent",
LiveBadge = "live-badge", LiveBadge = "live-badge",
CompoundSecondaryContent = "compound-secondary-content",
} }
export enum IconSize { export enum IconSize {
@ -55,6 +56,7 @@ export const Icon: React.FC<IconProps> = ({
const styles: React.CSSProperties = { const styles: React.CSSProperties = {
maskImage: `url("${iconTypeMap.get(type)}")`, maskImage: `url("${iconTypeMap.get(type)}")`,
WebkitMaskImage: `url("${iconTypeMap.get(type)}")`,
}; };
return ( return (

View file

@ -92,6 +92,9 @@ export interface IProps extends IPosition {
// within an existing FocusLock e.g inside a modal. // within an existing FocusLock e.g inside a modal.
focusLock?: boolean; focusLock?: boolean;
// call onFinished on any interaction with the menu
closeOnInteraction?: boolean;
// Function to be called on menu close // Function to be called on menu close
onFinished(); onFinished();
// on resize callback // on resize callback
@ -186,6 +189,10 @@ export default class ContextMenu extends React.PureComponent<IProps, IState> {
private onClick = (ev: React.MouseEvent) => { private onClick = (ev: React.MouseEvent) => {
// Don't allow clicks to escape the context menu wrapper // Don't allow clicks to escape the context menu wrapper
ev.stopPropagation(); ev.stopPropagation();
if (this.props.closeOnInteraction) {
this.props.onFinished?.();
}
}; };
// We now only handle closing the ContextMenu in this keyDown handler. // We now only handle closing the ContextMenu in this keyDown handler.

View file

@ -17,7 +17,6 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import request from 'browser-request';
import sanitizeHtml from 'sanitize-html'; import sanitizeHtml from 'sanitize-html';
import classnames from 'classnames'; import classnames from 'classnames';
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@ -61,6 +60,37 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
return sanitizeHtml(_t(s)); return sanitizeHtml(_t(s));
} }
private async fetchEmbed() {
let res: Response;
try {
res = await fetch(this.props.url, { method: "GET" });
} catch (err) {
if (this.unmounted) return;
logger.warn(`Error loading page: ${err}`);
this.setState({ page: _t("Couldn't load page") });
return;
}
if (this.unmounted) return;
if (!res.ok) {
logger.warn(`Error loading page: ${res.status}`);
this.setState({ page: _t("Couldn't load page") });
return;
}
let body = (await res.text()).replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1) => this.translate(g1));
if (this.props.replaceMap) {
Object.keys(this.props.replaceMap).forEach(key => {
body = body.split(key).join(this.props.replaceMap[key]);
});
}
this.setState({ page: body });
}
public componentDidMount(): void { public componentDidMount(): void {
this.unmounted = false; this.unmounted = false;
@ -68,34 +98,10 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
return; return;
} }
// we use request() to inline the page into the react component // We use fetch to inline the page into the react component
// so that it can inherit CSS and theming easily rather than mess around // so that it can inherit CSS and theming easily rather than mess around
// with iframes and trying to synchronise document.stylesheets. // with iframes and trying to synchronise document.stylesheets.
this.fetchEmbed();
request(
{ method: "GET", url: this.props.url },
(err, response, body) => {
if (this.unmounted) {
return;
}
if (err || response.status < 200 || response.status >= 300) {
logger.warn(`Error loading page: ${err}`);
this.setState({ page: _t("Couldn't load page") });
return;
}
body = body.replace(/_t\(['"]([\s\S]*?)['"]\)/mg, (match, g1) => this.translate(g1));
if (this.props.replaceMap) {
Object.keys(this.props.replaceMap).forEach(key => {
body = body.split(key).join(this.props.replaceMap[key]);
});
}
this.setState({ page: body });
},
);
this.dispatcherRef = dis.register(this.onAction); this.dispatcherRef = dis.register(this.onAction);
} }

View file

@ -137,6 +137,7 @@ import { TimelineRenderingType } from "../../contexts/RoomContext";
import { UseCaseSelection } from '../views/elements/UseCaseSelection'; import { UseCaseSelection } from '../views/elements/UseCaseSelection';
import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig'; import { ValidatedServerConfig } from '../../utils/ValidatedServerConfig';
import { isLocalRoom } from '../../utils/localRoom/isLocalRoom'; import { isLocalRoom } from '../../utils/localRoom/isLocalRoom';
import { viewUserDeviceSettings } from '../../actions/handlers/viewUserDeviceSettings';
// legacy export // legacy export
export { default as Views } from "../../Views"; export { default as Views } from "../../Views";
@ -336,14 +337,19 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// the old creds, but rather go straight to the relevant page // the old creds, but rather go straight to the relevant page
const firstScreen = this.screenAfterLogin ? this.screenAfterLogin.screen : null; const firstScreen = this.screenAfterLogin ? this.screenAfterLogin.screen : null;
if (firstScreen === 'login' || const restoreSuccess = await this.loadSession();
firstScreen === 'register' || if (restoreSuccess) {
firstScreen === 'forgot_password') { return true;
this.showScreenAfterLogin();
return;
} }
return this.loadSession(); if (firstScreen === 'login' ||
firstScreen === 'register' ||
firstScreen === 'forgot_password'
) {
this.showScreenAfterLogin();
}
return false;
}); });
} }
@ -469,7 +475,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
return { serverConfig: props }; return { serverConfig: props };
} }
private loadSession() { private loadSession(): Promise<boolean> {
// the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as // the extra Promise.resolve() ensures that synchronous exceptions hit the same codepath as
// asynchronous ones. // asynchronous ones.
return Promise.resolve().then(() => { return Promise.resolve().then(() => {
@ -489,6 +495,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.dispatch({ action: "view_welcome_page" }); dis.dispatch({ action: "view_welcome_page" });
} }
} }
return loadedSession;
}); });
// Note we don't catch errors from this: we catch everything within // Note we don't catch errors from this: we catch everything within
// loadSession as there's logic there to ask the user if they want // loadSession as there's logic there to ask the user if they want
@ -677,6 +684,10 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
} }
break; break;
} }
case Action.ViewUserDeviceSettings: {
viewUserDeviceSettings(SettingsStore.getValue("feature_new_device_manager"));
break;
}
case Action.ViewUserSettings: { case Action.ViewUserSettings: {
const tabPayload = payload as OpenToTabPayload; const tabPayload = payload as OpenToTabPayload;
Modal.createDialog(UserSettingsDialog, Modal.createDialog(UserSettingsDialog,

View file

@ -1362,7 +1362,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
if (this.unmounted) return; if (this.unmounted) return;
this.setState({ timelineLoading: false }); this.setState({ timelineLoading: false });
logger.error(`Error loading timeline panel at ${this.props.timelineSet.room?.roomId}/${eventId}: ${error}`); logger.error(`Error loading timeline panel at ${this.props.timelineSet.room?.roomId}/${eventId}`, error);
let onFinished: () => void; let onFinished: () => void;

View file

@ -17,7 +17,7 @@ limitations under the License.
import React from 'react'; import React from 'react';
import { Room } from "matrix-js-sdk/src/models/room"; import { Room } from "matrix-js-sdk/src/models/room";
import filesize from "filesize"; import filesize from "filesize";
import { IAbortablePromise, IEventRelation } from 'matrix-js-sdk/src/matrix'; import { IEventRelation } from 'matrix-js-sdk/src/matrix';
import { Optional } from "matrix-events-sdk"; import { Optional } from "matrix-events-sdk";
import ContentMessages from '../../ContentMessages'; import ContentMessages from '../../ContentMessages';
@ -26,8 +26,7 @@ import { _t } from '../../languageHandler';
import { Action } from "../../dispatcher/actions"; import { Action } from "../../dispatcher/actions";
import ProgressBar from "../views/elements/ProgressBar"; import ProgressBar from "../views/elements/ProgressBar";
import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton"; import AccessibleButton, { ButtonEvent } from "../views/elements/AccessibleButton";
import { IUpload } from "../../models/IUpload"; import { RoomUpload } from "../../models/RoomUpload";
import MatrixClientContext from "../../contexts/MatrixClientContext";
import { ActionPayload } from '../../dispatcher/payloads'; import { ActionPayload } from '../../dispatcher/payloads';
import { UploadPayload } from "../../dispatcher/payloads/UploadPayload"; import { UploadPayload } from "../../dispatcher/payloads/UploadPayload";
@ -38,7 +37,7 @@ interface IProps {
interface IState { interface IState {
currentFile?: string; currentFile?: string;
currentPromise?: IAbortablePromise<any>; currentUpload?: RoomUpload;
currentLoaded?: number; currentLoaded?: number;
currentTotal?: number; currentTotal?: number;
countFiles: number; countFiles: number;
@ -55,8 +54,6 @@ function isUploadPayload(payload: ActionPayload): payload is UploadPayload {
} }
export default class UploadBar extends React.PureComponent<IProps, IState> { export default class UploadBar extends React.PureComponent<IProps, IState> {
static contextType = MatrixClientContext;
private dispatcherRef: Optional<string>; private dispatcherRef: Optional<string>;
private mounted = false; private mounted = false;
@ -78,7 +75,7 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
dis.unregister(this.dispatcherRef!); dis.unregister(this.dispatcherRef!);
} }
private getUploadsInRoom(): IUpload[] { private getUploadsInRoom(): RoomUpload[] {
const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation); const uploads = ContentMessages.sharedInstance().getCurrentUploads(this.props.relation);
return uploads.filter(u => u.roomId === this.props.room.roomId); return uploads.filter(u => u.roomId === this.props.room.roomId);
} }
@ -86,8 +83,8 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
private calculateState(): IState { private calculateState(): IState {
const [currentUpload, ...otherUploads] = this.getUploadsInRoom(); const [currentUpload, ...otherUploads] = this.getUploadsInRoom();
return { return {
currentUpload,
currentFile: currentUpload?.fileName, currentFile: currentUpload?.fileName,
currentPromise: currentUpload?.promise,
currentLoaded: currentUpload?.loaded, currentLoaded: currentUpload?.loaded,
currentTotal: currentUpload?.total, currentTotal: currentUpload?.total,
countFiles: otherUploads.length + 1, countFiles: otherUploads.length + 1,
@ -103,7 +100,7 @@ export default class UploadBar extends React.PureComponent<IProps, IState> {
private onCancelClick = (ev: ButtonEvent) => { private onCancelClick = (ev: ButtonEvent) => {
ev.preventDefault(); ev.preventDefault();
ContentMessages.sharedInstance().cancelUpload(this.state.currentPromise!, this.context); ContentMessages.sharedInstance().cancelUpload(this.state.currentUpload!);
}; };
render() { render() {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React, { ReactNode } from 'react'; import React, { ReactNode } from 'react';
import { MatrixError } from "matrix-js-sdk/src/http-api"; import { ConnectionError, MatrixError } from "matrix-js-sdk/src/http-api";
import classNames from "classnames"; import classNames from "classnames";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@ -453,7 +453,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " + let errorText: ReactNode = _t("There was a problem communicating with the homeserver, " +
"please try again later.") + (errCode ? " (" + errCode + ")" : ""); "please try again later.") + (errCode ? " (" + errCode + ")" : "");
if (err["cors"] === 'rejected') { // browser-request specific error field if (err instanceof ConnectionError) {
if (window.location.protocol === 'https:' && if (window.location.protocol === 'https:' &&
(this.props.serverConfig.hsUrl.startsWith("http:") || (this.props.serverConfig.hsUrl.startsWith("http:") ||
!this.props.serverConfig.hsUrl.startsWith("http")) !this.props.serverConfig.hsUrl.startsWith("http"))

View file

@ -39,6 +39,7 @@ interface IOptionListProps {
interface IOptionProps extends React.ComponentProps<typeof MenuItem> { interface IOptionProps extends React.ComponentProps<typeof MenuItem> {
iconClassName?: string; iconClassName?: string;
isDestructive?: boolean;
} }
interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> { interface ICheckboxProps extends React.ComponentProps<typeof MenuItemCheckbox> {
@ -112,12 +113,14 @@ export const IconizedContextMenuOption: React.FC<IOptionProps> = ({
className, className,
iconClassName, iconClassName,
children, children,
isDestructive,
...props ...props
}) => { }) => {
return <MenuItem return <MenuItem
{...props} {...props}
className={classNames(className, { className={classNames(className, {
mx_IconizedContextMenu_item: true, mx_IconizedContextMenu_item: true,
mx_IconizedContextMenu_itemDestructive: isDestructive,
})} })}
label={label} label={label}
> >

View file

@ -0,0 +1,66 @@
/*
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 { Icon as ContextMenuIcon } from '../../../../res/img/element-icons/context-menu.svg';
import { ChevronFace, ContextMenuButton, useContextMenu } from '../../structures/ContextMenu';
import AccessibleButton from '../elements/AccessibleButton';
import IconizedContextMenu, { IconizedContextMenuOptionList } from './IconizedContextMenu';
const contextMenuBelow = (elementRect: DOMRect) => {
// align the context menu's icons with the icon which opened the context menu
const left = elementRect.left + window.scrollX + elementRect.width;
const top = elementRect.bottom + window.scrollY;
const chevronFace = ChevronFace.None;
return { left, top, chevronFace };
};
interface KebabContextMenuProps extends Partial<React.ComponentProps<typeof AccessibleButton>> {
options: React.ReactNode[];
title: string;
}
export const KebabContextMenu: React.FC<KebabContextMenuProps> = ({
options,
title,
...props
}) => {
const [menuDisplayed, button, openMenu, closeMenu] = useContextMenu();
return <>
<ContextMenuButton
{...props}
onClick={openMenu}
title={title}
isExpanded={menuDisplayed}
inputRef={button}
>
<ContextMenuIcon className='mx_KebabContextMenu_icon' />
</ContextMenuButton>
{ menuDisplayed && (<IconizedContextMenu
onFinished={closeMenu}
compact
rightAligned
closeOnInteraction
{...contextMenuBelow(button.current.getBoundingClientRect())}
>
<IconizedContextMenuOptionList>
{ options }
</IconizedContextMenuOptionList>
</IconizedContextMenu>) }
</>;
};

View file

@ -16,7 +16,6 @@ Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
*/ */
import React from 'react'; import React from 'react';
import request from 'browser-request';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
import QuestionDialog from "./QuestionDialog"; import QuestionDialog from "./QuestionDialog";
@ -37,22 +36,33 @@ export default class ChangelogDialog extends React.Component<IProps> {
this.state = {}; this.state = {};
} }
private async fetchChanges(repo: string, oldVersion: string, newVersion: string): Promise<void> {
const url = `https://riot.im/github/repos/${repo}/compare/${oldVersion}...${newVersion}`;
try {
const res = await fetch(url);
if (!res.ok) {
this.setState({ [repo]: res.statusText });
return;
}
const body = await res.json();
this.setState({ [repo]: body.commits });
} catch (err) {
this.setState({ [repo]: err.message });
}
}
public componentDidMount() { public componentDidMount() {
const version = this.props.newVersion.split('-'); const version = this.props.newVersion.split('-');
const version2 = this.props.version.split('-'); const version2 = this.props.version.split('-');
if (version == null || version2 == null) return; if (version == null || version2 == null) return;
// parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version] // parse versions of form: [vectorversion]-react-[react-sdk-version]-js-[js-sdk-version]
for (let i=0; i<REPOS.length; i++) { for (let i = 0; i < REPOS.length; i++) {
const oldVersion = version2[2*i]; const oldVersion = version2[2*i];
const newVersion = version[2*i]; const newVersion = version[2*i];
const url = `https://riot.im/github/repos/${REPOS[i]}/compare/${oldVersion}...${newVersion}`; this.fetchChanges(REPOS[i], oldVersion, newVersion);
request(url, (err, response, body) => {
if (response.statusCode < 200 || response.statusCode >= 300) {
this.setState({ [REPOS[i]]: response.statusText });
return;
}
this.setState({ [REPOS[i]]: JSON.parse(body).commits });
});
} }
} }

View file

@ -654,12 +654,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
const token = await authClient.getAccessToken(); const token = await authClient.getAccessToken();
if (term !== this.state.filterText) return; // abandon hope if (term !== this.state.filterText) return; // abandon hope
const lookup = await MatrixClientPeg.get().lookupThreePid( const lookup = await MatrixClientPeg.get().lookupThreePid('email', term, token);
'email',
term,
undefined, // callback
token,
);
if (term !== this.state.filterText) return; // abandon hope if (term !== this.state.filterText) return; // abandon hope
if (!lookup || !lookup.mxid) { if (!lookup || !lookup.mxid) {

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import React from 'react'; import React from 'react';
import { MatrixClient } from 'matrix-js-sdk/src/matrix'; import { MatrixClient, Method } from 'matrix-js-sdk/src/matrix';
import { logger } from 'matrix-js-sdk/src/logger'; import { logger } from 'matrix-js-sdk/src/logger';
import { _t } from '../../../languageHandler'; import { _t } from '../../../languageHandler';
@ -33,17 +33,10 @@ import { SettingLevel } from "../../../settings/SettingLevel";
* @throws if the proxy server is unreachable or not configured to the given homeserver * @throws if the proxy server is unreachable or not configured to the given homeserver
*/ */
async function syncHealthCheck(cli: MatrixClient): Promise<void> { async function syncHealthCheck(cli: MatrixClient): Promise<void> {
const controller = new AbortController(); await cli.http.authedRequest(Method.Post, "/sync", undefined, undefined, {
const id = setTimeout(() => controller.abort(), 10 * 1000); // 10s localTimeoutMs: 10 * 1000, // 10s
const url = cli.http.getUrl("/sync", {}, "/_matrix/client/unstable/org.matrix.msc3575"); prefix: "/_matrix/client/unstable/org.matrix.msc3575",
const res = await fetch(url, {
signal: controller.signal,
method: "POST",
}); });
clearTimeout(id);
if (res.status != 200) {
throw new Error(`syncHealthCheck: server returned HTTP ${res.status}`);
}
logger.info("server natively support sliding sync OK"); logger.info("server natively support sliding sync OK");
} }

View file

@ -38,7 +38,7 @@ interface IProps {
interface IState { interface IState {
searchQuery: string; searchQuery: string;
langs: string[]; langs: Awaited<ReturnType<typeof languageHandler.getAllLanguagesFromJson>>;
} }
export default class LanguageDropdown extends React.Component<IProps, IState> { export default class LanguageDropdown extends React.Component<IProps, IState> {
@ -60,7 +60,7 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
}); });
this.setState({ langs }); this.setState({ langs });
}).catch(() => { }).catch(() => {
this.setState({ langs: ['en'] }); this.setState({ langs: [{ value: 'en', label: "English" }] });
}); });
if (!this.props.value) { if (!this.props.value) {
@ -83,7 +83,7 @@ export default class LanguageDropdown extends React.Component<IProps, IState> {
return <Spinner />; return <Spinner />;
} }
let displayedLanguages; let displayedLanguages: Awaited<ReturnType<typeof languageHandler.getAllLanguagesFromJson>>;
if (this.state.searchQuery) { if (this.state.searchQuery) {
displayedLanguages = this.state.langs.filter((lang) => { displayedLanguages = this.state.langs.filter((lang) => {
return languageMatchesSearchQuery(this.state.searchQuery, lang); return languageMatchesSearchQuery(this.state.searchQuery, lang);

View file

@ -74,7 +74,7 @@ const MiniAvatarUploader: React.FC<IProps> = ({
if (!ev.target.files?.length) return; if (!ev.target.files?.length) return;
setBusy(true); setBusy(true);
const file = ev.target.files[0]; const file = ev.target.files[0];
const uri = await cli.uploadContent(file); const { content_uri: uri } = await cli.uploadContent(file);
await setAvatarUrl(uri); await setAvatarUrl(uri);
setBusy(false); setBusy(false);
}} }}

View file

@ -43,6 +43,8 @@ import MjolnirBody from "./MjolnirBody";
import MBeaconBody from "./MBeaconBody"; import MBeaconBody from "./MBeaconBody";
import { IEventTileOps } from "../rooms/EventTile"; import { IEventTileOps } from "../rooms/EventTile";
import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from '../../../voice-broadcast'; import { VoiceBroadcastBody, VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from '../../../voice-broadcast';
import { Features } from '../../../settings/Settings';
import { SettingLevel } from '../../../settings/SettingLevel';
// onMessageAllowed is handled internally // onMessageAllowed is handled internally
interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> { interface IProps extends Omit<IBodyProps, "onMessageAllowed" | "mediaEventHelper"> {
@ -60,6 +62,10 @@ export interface IOperableEventTile {
getEventTileOps(): IEventTileOps; getEventTileOps(): IEventTileOps;
} }
interface State {
voiceBroadcastEnabled: boolean;
}
const baseBodyTypes = new Map<string, typeof React.Component>([ const baseBodyTypes = new Map<string, typeof React.Component>([
[MsgType.Text, TextualBody], [MsgType.Text, TextualBody],
[MsgType.Notice, TextualBody], [MsgType.Notice, TextualBody],
@ -78,7 +84,7 @@ const baseEvTypes = new Map<string, React.ComponentType<Partial<IBodyProps>>>([
[VoiceBroadcastInfoEventType, VoiceBroadcastBody], [VoiceBroadcastInfoEventType, VoiceBroadcastBody],
]); ]);
export default class MessageEvent extends React.Component<IProps> implements IMediaBody, IOperableEventTile { export default class MessageEvent extends React.Component<IProps, State> implements IMediaBody, IOperableEventTile {
private body: React.RefObject<React.Component | IOperableEventTile> = createRef(); private body: React.RefObject<React.Component | IOperableEventTile> = createRef();
private mediaHelper: MediaEventHelper; private mediaHelper: MediaEventHelper;
private bodyTypes = new Map<string, typeof React.Component>(baseBodyTypes.entries()); private bodyTypes = new Map<string, typeof React.Component>(baseBodyTypes.entries());
@ -86,6 +92,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
public static contextType = MatrixClientContext; public static contextType = MatrixClientContext;
public context!: React.ContextType<typeof MatrixClientContext>; public context!: React.ContextType<typeof MatrixClientContext>;
private voiceBroadcastSettingWatcherRef: string;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) { public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context); super(props, context);
@ -95,15 +102,29 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
} }
this.updateComponentMaps(); this.updateComponentMaps();
this.state = {
// only check voice broadcast settings for a voice broadcast event
voiceBroadcastEnabled: this.props.mxEvent.getType() === VoiceBroadcastInfoEventType
&& SettingsStore.getValue(Features.VoiceBroadcast),
};
} }
public componentDidMount(): void { public componentDidMount(): void {
this.props.mxEvent.addListener(MatrixEventEvent.Decrypted, this.onDecrypted); this.props.mxEvent.addListener(MatrixEventEvent.Decrypted, this.onDecrypted);
if (this.props.mxEvent.getType() === VoiceBroadcastInfoEventType) {
this.watchVoiceBroadcastFeatureSetting();
}
} }
public componentWillUnmount() { public componentWillUnmount() {
this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted); this.props.mxEvent.removeListener(MatrixEventEvent.Decrypted, this.onDecrypted);
this.mediaHelper?.destroy(); this.mediaHelper?.destroy();
if (this.voiceBroadcastSettingWatcherRef) {
SettingsStore.unwatchSetting(this.voiceBroadcastSettingWatcherRef);
}
} }
public componentDidUpdate(prevProps: Readonly<IProps>) { public componentDidUpdate(prevProps: Readonly<IProps>) {
@ -147,6 +168,16 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
this.forceUpdate(); this.forceUpdate();
}; };
private watchVoiceBroadcastFeatureSetting(): void {
this.voiceBroadcastSettingWatcherRef = SettingsStore.watchSetting(
Features.VoiceBroadcast,
null,
(settingName: string, roomId: string, atLevel: SettingLevel, newValAtLevel, newValue: boolean) => {
this.setState({ voiceBroadcastEnabled: newValue });
},
);
}
public render() { public render() {
const content = this.props.mxEvent.getContent(); const content = this.props.mxEvent.getContent();
const type = this.props.mxEvent.getType(); const type = this.props.mxEvent.getType();
@ -174,7 +205,11 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
BodyType = MLocationBody; BodyType = MLocationBody;
} }
if (type === VoiceBroadcastInfoEventType && content?.state === VoiceBroadcastInfoState.Started) { if (
this.state.voiceBroadcastEnabled
&& type === VoiceBroadcastInfoEventType
&& content?.state === VoiceBroadcastInfoState.Started
) {
BodyType = VoiceBroadcastBody; BodyType = VoiceBroadcastBody;
} }
} }

View file

@ -48,7 +48,6 @@ import EncryptionPanel from "./EncryptionPanel";
import { useAsyncMemo } from '../../../hooks/useAsyncMemo'; import { useAsyncMemo } from '../../../hooks/useAsyncMemo';
import { legacyVerifyUser, verifyDevice, verifyUser } from '../../../verification'; import { legacyVerifyUser, verifyDevice, verifyUser } from '../../../verification';
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { UserTab } from "../dialogs/UserTab";
import { useIsEncrypted } from "../../../hooks/useIsEncrypted"; import { useIsEncrypted } from "../../../hooks/useIsEncrypted";
import BaseCard from "./BaseCard"; import BaseCard from "./BaseCard";
import { E2EStatus } from "../../../utils/ShieldUtils"; import { E2EStatus } from "../../../utils/ShieldUtils";
@ -1331,8 +1330,7 @@ const BasicUserInfo: React.FC<{
className="mx_UserInfo_field" className="mx_UserInfo_field"
onClick={() => { onClick={() => {
dis.dispatch({ dis.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserDeviceSettings,
initialTabId: UserTab.Security,
}); });
}} }}
> >

View file

@ -134,7 +134,7 @@ export default class RoomProfileSettings extends React.Component<IProps, IState>
} }
if (this.state.avatarFile) { if (this.state.avatarFile) {
const uri = await client.uploadContent(this.state.avatarFile); const { content_uri: uri } = await client.uploadContent(this.state.avatarFile);
await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, ''); await client.sendStateEvent(this.props.roomId, 'm.room.avatar', { url: uri }, '');
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl; newState.originalAvatarUrl = newState.avatarUrl;

View file

@ -148,7 +148,6 @@ export default class RoomPreviewBar extends React.Component<IProps, IState> {
const result = await MatrixClientPeg.get().lookupThreePid( const result = await MatrixClientPeg.get().lookupThreePid(
'email', 'email',
this.props.invitedEmail, this.props.invitedEmail,
undefined /* callback */,
identityAccessToken, identityAccessToken,
); );
this.setState({ invitedEmailMxid: result.mxid }); this.setState({ invitedEmailMxid: result.mxid });

View file

@ -115,13 +115,13 @@ export default class ChangeAvatar extends React.Component<IProps, IState> {
this.setState({ this.setState({
phase: Phases.Uploading, phase: Phases.Uploading,
}); });
const httpPromise = MatrixClientPeg.get().uploadContent(file).then((url) => { const httpPromise = MatrixClientPeg.get().uploadContent(file).then(({ content_uri: url }) => {
newUrl = url; newUrl = url;
if (this.props.room) { if (this.props.room) {
return MatrixClientPeg.get().sendStateEvent( return MatrixClientPeg.get().sendStateEvent(
this.props.room.roomId, this.props.room.roomId,
'm.room.avatar', 'm.room.avatar',
{ url: url }, { url },
'', '',
); );
} else { } else {

View file

@ -111,7 +111,7 @@ export default class ProfileSettings extends React.Component<{}, IState> {
logger.log( logger.log(
`Uploading new avatar, ${this.state.avatarFile.name} of type ${this.state.avatarFile.type},` + `Uploading new avatar, ${this.state.avatarFile.name} of type ${this.state.avatarFile.type},` +
` (${this.state.avatarFile.size}) bytes`); ` (${this.state.avatarFile.size}) bytes`);
const uri = await client.uploadContent(this.state.avatarFile); const { content_uri: uri } = await client.uploadContent(this.state.avatarFile);
await client.setAvatarUrl(uri); await client.setAvatarUrl(uri);
newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96); newState.avatarUrl = mediaFromMxc(uri).getSquareThumbnailHttp(96);
newState.originalAvatarUrl = newState.avatarUrl; newState.originalAvatarUrl = newState.avatarUrl;

View file

@ -14,17 +14,20 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import React, { useState } from 'react'; import React, { useState } from 'react';
import { LocalNotificationSettings } from 'matrix-js-sdk/src/@types/local_notifications';
import { _t } from '../../../../languageHandler'; import { _t } from '../../../../languageHandler';
import Spinner from '../../elements/Spinner'; import Spinner from '../../elements/Spinner';
import SettingsSubsection from '../shared/SettingsSubsection'; import SettingsSubsection from '../shared/SettingsSubsection';
import { SettingsSubsectionHeading } from '../shared/SettingsSubsectionHeading';
import DeviceDetails from './DeviceDetails'; import DeviceDetails from './DeviceDetails';
import DeviceExpandDetailsButton from './DeviceExpandDetailsButton'; import DeviceExpandDetailsButton from './DeviceExpandDetailsButton';
import DeviceTile from './DeviceTile'; import DeviceTile from './DeviceTile';
import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard'; import { DeviceVerificationStatusCard } from './DeviceVerificationStatusCard';
import { ExtendedDevice } from './types'; import { ExtendedDevice } from './types';
import { KebabContextMenu } from '../../context_menus/KebabContextMenu';
import { IconizedContextMenuOption } from '../../context_menus/IconizedContextMenu';
interface Props { interface Props {
device?: ExtendedDevice; device?: ExtendedDevice;
@ -34,9 +37,48 @@ interface Props {
setPushNotifications?: (deviceId: string, enabled: boolean) => Promise<void> | undefined; setPushNotifications?: (deviceId: string, enabled: boolean) => Promise<void> | undefined;
onVerifyCurrentDevice: () => void; onVerifyCurrentDevice: () => void;
onSignOutCurrentDevice: () => void; onSignOutCurrentDevice: () => void;
signOutAllOtherSessions?: () => void;
saveDeviceName: (deviceName: string) => Promise<void>; saveDeviceName: (deviceName: string) => Promise<void>;
} }
type CurrentDeviceSectionHeadingProps =
Pick<Props, 'onSignOutCurrentDevice' | 'signOutAllOtherSessions'>
& { disabled?: boolean };
const CurrentDeviceSectionHeading: React.FC<CurrentDeviceSectionHeadingProps> = ({
onSignOutCurrentDevice,
signOutAllOtherSessions,
disabled,
}) => {
const menuOptions = [
<IconizedContextMenuOption
key="sign-out"
label={_t('Sign out')}
onClick={onSignOutCurrentDevice}
isDestructive
/>,
...(signOutAllOtherSessions
? [
<IconizedContextMenuOption
key="sign-out-all-others"
label={_t('Sign out all other sessions')}
onClick={signOutAllOtherSessions}
isDestructive
/>,
]
: []
),
];
return <SettingsSubsectionHeading heading={_t('Current session')}>
<KebabContextMenu
disabled={disabled}
title={_t('Options')}
options={menuOptions}
data-testid='current-session-menu'
/>
</SettingsSubsectionHeading>;
};
const CurrentDeviceSection: React.FC<Props> = ({ const CurrentDeviceSection: React.FC<Props> = ({
device, device,
isLoading, isLoading,
@ -45,13 +87,18 @@ const CurrentDeviceSection: React.FC<Props> = ({
setPushNotifications, setPushNotifications,
onVerifyCurrentDevice, onVerifyCurrentDevice,
onSignOutCurrentDevice, onSignOutCurrentDevice,
signOutAllOtherSessions,
saveDeviceName, saveDeviceName,
}) => { }) => {
const [isExpanded, setIsExpanded] = useState(false); const [isExpanded, setIsExpanded] = useState(false);
return <SettingsSubsection return <SettingsSubsection
heading={_t('Current session')}
data-testid='current-session-section' data-testid='current-session-section'
heading={<CurrentDeviceSectionHeading
onSignOutCurrentDevice={onSignOutCurrentDevice}
signOutAllOtherSessions={signOutAllOtherSessions}
disabled={isLoading || !device || isSigningOut}
/>}
> >
{ /* only show big spinner on first load */ } { /* only show big spinner on first load */ }
{ isLoading && !device && <Spinner /> } { isLoading && !device && <Spinner /> }

View file

@ -16,17 +16,22 @@ limitations under the License.
import React, { HTMLAttributes } from "react"; import React, { HTMLAttributes } from "react";
import Heading from "../../typography/Heading"; import { SettingsSubsectionHeading } from "./SettingsSubsectionHeading";
export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement> { export interface SettingsSubsectionProps extends HTMLAttributes<HTMLDivElement> {
heading: string; heading: string | React.ReactNode;
description?: string | React.ReactNode; description?: string | React.ReactNode;
children?: React.ReactNode; children?: React.ReactNode;
} }
const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({ heading, description, children, ...rest }) => ( const SettingsSubsection: React.FC<SettingsSubsectionProps> = ({ heading, description, children, ...rest }) => (
<div {...rest} className="mx_SettingsSubsection"> <div {...rest} className="mx_SettingsSubsection">
<Heading className="mx_SettingsSubsection_heading" size='h3'>{ heading }</Heading> { typeof heading === 'string'
? <SettingsSubsectionHeading heading={heading} />
: <>
{ heading }
</>
}
{ !!description && <div className="mx_SettingsSubsection_description">{ description }</div> } { !!description && <div className="mx_SettingsSubsection_description">{ description }</div> }
<div className="mx_SettingsSubsection_content"> <div className="mx_SettingsSubsection_content">
{ children } { children }

View file

@ -14,6 +14,18 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
export * from "./atoms/LiveBadge"; import React, { HTMLAttributes } from "react";
export * from "./molecules/VoiceBroadcastRecordingBody";
export * from "./VoiceBroadcastBody"; import Heading from "../../typography/Heading";
export interface SettingsSubsectionHeadingProps extends HTMLAttributes<HTMLDivElement> {
heading: string;
children?: React.ReactNode;
}
export const SettingsSubsectionHeading: React.FC<SettingsSubsectionHeadingProps> = ({ heading, children, ...rest }) => (
<div {...rest} className="mx_SettingsSubsectionHeading">
<Heading className="mx_SettingsSubsectionHeading_heading" size='h3'>{ heading }</Heading>
{ children }
</div>
);

View file

@ -80,7 +80,10 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> {
let betaSection; let betaSection;
if (betas.length) { if (betas.length) {
betaSection = <div className="mx_SettingsTab_section"> betaSection = <div
data-testid="labs-beta-section"
className="mx_SettingsTab_section"
>
{ betas.map(f => <BetaCard key={f} featureId={f} />) } { betas.map(f => <BetaCard key={f} featureId={f} />) }
</div>; </div>;
} }
@ -137,7 +140,11 @@ export default class LabsUserSettingsTab extends React.Component<{}, IState> {
labsSections = <> labsSections = <>
{ sortBy(Array.from(groups.entries()), "0").map(([group, flags]) => ( { sortBy(Array.from(groups.entries()), "0").map(([group, flags]) => (
<div className="mx_SettingsTab_section" key={group}> <div
className="mx_SettingsTab_section"
key={group}
data-testid={`labs-group-${group}`}
>
<span className="mx_SettingsTab_subheading">{ _t(labGroupNames[group]) }</span> <span className="mx_SettingsTab_subheading">{ _t(labGroupNames[group]) }</span>
{ flags } { flags }
</div> </div>

View file

@ -346,19 +346,29 @@ export default class SecurityUserSettingsTab extends React.Component<IProps, ISt
} }
} }
return ( const useNewSessionManager = SettingsStore.getValue("feature_new_device_manager");
<div className="mx_SettingsTab mx_SecurityUserSettingsTab"> const devicesSection = useNewSessionManager
{ warning } ? null
: <>
<div className="mx_SettingsTab_heading">{ _t("Where you're signed in") }</div> <div className="mx_SettingsTab_heading">{ _t("Where you're signed in") }</div>
<div className="mx_SettingsTab_section"> <div
className="mx_SettingsTab_section"
data-testid="devices-section"
>
<span className="mx_SettingsTab_subsectionText"> <span className="mx_SettingsTab_subsectionText">
{ _t( { _t(
"Manage your signed-in devices below. " + "Manage your signed-in devices below. " +
"A device's name is visible to people you communicate with.", "A device's name is visible to people you communicate with.",
) } ) }
</span> </span>
<DevicesPanel /> <DevicesPanel />
</div> </div>
</>;
return (
<div className="mx_SettingsTab mx_SecurityUserSettingsTab">
{ warning }
{ devicesSection }
<div className="mx_SettingsTab_heading">{ _t("Encryption") }</div> <div className="mx_SettingsTab_heading">{ _t("Encryption") }</div>
<div className="mx_SettingsTab_section"> <div className="mx_SettingsTab_section">
{ secureBackup } { secureBackup }

View file

@ -171,6 +171,10 @@ const SessionManagerTab: React.FC = () => {
setSelectedDeviceIds([]); setSelectedDeviceIds([]);
}, [filter, setSelectedDeviceIds]); }, [filter, setSelectedDeviceIds]);
const signOutAllOtherSessions = shouldShowOtherSessions ? () => {
onSignOutOtherDevices(Object.keys(otherDevices));
}: undefined;
return <SettingsTab heading={_t('Sessions')}> return <SettingsTab heading={_t('Sessions')}>
<SecurityRecommendations <SecurityRecommendations
devices={devices} devices={devices}
@ -186,6 +190,7 @@ const SessionManagerTab: React.FC = () => {
saveDeviceName={(deviceName) => saveDeviceName(currentDeviceId, deviceName)} saveDeviceName={(deviceName) => saveDeviceName(currentDeviceId, deviceName)}
onVerifyCurrentDevice={onVerifyCurrentDevice} onVerifyCurrentDevice={onVerifyCurrentDevice}
onSignOutCurrentDevice={onSignOutCurrentDevice} onSignOutCurrentDevice={onSignOutCurrentDevice}
signOutAllOtherSessions={signOutAllOtherSessions}
/> />
{ {
shouldShowOtherSessions && shouldShowOtherSessions &&

View file

@ -154,9 +154,10 @@ export default class PipView extends React.Component<IProps, IState> {
public componentWillUnmount() { public componentWillUnmount() {
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls); LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallChangeRoom, this.updateCalls);
LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls); LegacyCallHandler.instance.removeListener(LegacyCallHandlerEvent.CallState, this.updateCalls);
MatrixClientPeg.get().removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold); const cli = MatrixClientPeg.get();
cli?.removeListener(CallEvent.RemoteHoldUnhold, this.onCallRemoteHold);
RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate); RoomViewStore.instance.removeListener(UPDATE_EVENT, this.onRoomViewStoreUpdate);
const room = MatrixClientPeg.get().getRoom(this.state.viewedRoomId); const room = cli?.getRoom(this.state.viewedRoomId);
if (room) { if (room) {
WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls); WidgetLayoutStore.instance.off(WidgetLayoutStore.emissionForRoom(room), this.updateCalls);
} }

View file

@ -246,7 +246,7 @@ export default async function createRoom(opts: IOpts): Promise<string | null> {
if (opts.avatar) { if (opts.avatar) {
let url = opts.avatar; let url = opts.avatar;
if (opts.avatar instanceof File) { if (opts.avatar instanceof File) {
url = await client.uploadContent(opts.avatar); ({ content_uri: url } = await client.uploadContent(opts.avatar));
} }
createOpts.initial_state.push({ createOpts.initial_state.push({

View file

@ -151,7 +151,7 @@ export class Media {
* @param {MatrixClient} client? Optional client to use. * @param {MatrixClient} client? Optional client to use.
* @returns {Media} The media object. * @returns {Media} The media object.
*/ */
export function mediaFromContent(content: IMediaEventContent, client?: MatrixClient): Media { export function mediaFromContent(content: Partial<IMediaEventContent>, client?: MatrixClient): Media {
return new Media(prepEventContentAsMedia(content), client); return new Media(prepEventContentAsMedia(content), client);
} }

View file

@ -46,6 +46,7 @@ export interface IMediaEventInfo {
} }
export interface IMediaEventContent { export interface IMediaEventContent {
msgtype: string;
body?: string; body?: string;
filename?: string; // `m.file` optional field filename?: string; // `m.file` optional field
url?: string; // required on unencrypted media url?: string; // required on unencrypted media
@ -69,7 +70,7 @@ export interface IMediaObject {
* @returns {IPreparedMedia} A prepared media object. * @returns {IPreparedMedia} A prepared media object.
* @throws Throws if the given content cannot be packaged into a prepared media object. * @throws Throws if the given content cannot be packaged into a prepared media object.
*/ */
export function prepEventContentAsMedia(content: IMediaEventContent): IPreparedMedia { export function prepEventContentAsMedia(content: Partial<IMediaEventContent>): IPreparedMedia {
let thumbnail: IMediaObject = null; let thumbnail: IMediaObject = null;
if (content?.info?.thumbnail_url) { if (content?.info?.thumbnail_url) {
thumbnail = { thumbnail = {

View file

@ -40,6 +40,11 @@ export enum Action {
*/ */
ViewUserSettings = "view_user_settings", ViewUserSettings = "view_user_settings",
/**
* Open the user device settings. No additional payload information required.
*/
ViewUserDeviceSettings = "view_user_device_settings",
/** /**
* Opens the room directory. No additional payload information required. * Opens the room directory. No additional payload information required.
*/ */

View file

@ -16,13 +16,13 @@ limitations under the License.
import { ActionPayload } from "../payloads"; import { ActionPayload } from "../payloads";
import { Action } from "../actions"; import { Action } from "../actions";
import { IUpload } from "../../models/IUpload"; import { RoomUpload } from "../../models/RoomUpload";
export interface UploadPayload extends ActionPayload { export interface UploadPayload extends ActionPayload {
/** /**
* The upload with fields representing the new upload state. * The upload with fields representing the new upload state.
*/ */
upload: IUpload; upload: RoomUpload;
} }
export interface UploadStartedPayload extends UploadPayload { export interface UploadStartedPayload extends UploadPayload {

View file

@ -46,6 +46,7 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline"; import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile"; import { shouldDisplayAsVoiceBroadcastTile } from "../voice-broadcast/utils/shouldDisplayAsVoiceBroadcastTile";
import { ElementCall } from "../models/Call"; import { ElementCall } from "../models/Call";
import { VoiceBroadcastChunkEventType } from "../voice-broadcast";
// Subset of EventTile's IProps plus some mixins // Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps { export interface EventTileTypeProps {
@ -252,6 +253,11 @@ export function pickFactory(
return noEventFactoryFactory(); return noEventFactoryFactory();
} }
if (mxEvent.getContent()[VoiceBroadcastChunkEventType]) {
// hide voice broadcast chunks
return noEventFactoryFactory();
}
return EVENT_TILE_TYPES.get(evType) ?? noEventFactoryFactory(); return EVENT_TILE_TYPES.get(evType) ?? noEventFactoryFactory();
} }

View file

@ -16,6 +16,7 @@
"Error": "Error", "Error": "Error",
"Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.", "Unable to load! Check your network connectivity and try again.": "Unable to load! Check your network connectivity and try again.",
"Dismiss": "Dismiss", "Dismiss": "Dismiss",
"Attachment": "Attachment",
"The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.", "The file '%(fileName)s' failed to upload.": "The file '%(fileName)s' failed to upload.",
"The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads", "The file '%(fileName)s' exceeds this homeserver's size limit for uploads": "The file '%(fileName)s' exceeds this homeserver's size limit for uploads",
"Upload Failed": "Upload Failed", "Upload Failed": "Upload Failed",
@ -637,6 +638,7 @@
"See <b>%(msgtype)s</b> messages posted to this room": "See <b>%(msgtype)s</b> messages posted to this room", "See <b>%(msgtype)s</b> messages posted to this room": "See <b>%(msgtype)s</b> messages posted to this room",
"See <b>%(msgtype)s</b> messages posted to your active room": "See <b>%(msgtype)s</b> messages posted to your active room", "See <b>%(msgtype)s</b> messages posted to your active room": "See <b>%(msgtype)s</b> messages posted to your active room",
"Live": "Live", "Live": "Live",
"Voice broadcast": "Voice broadcast",
"Cannot reach homeserver": "Cannot reach homeserver", "Cannot reach homeserver": "Cannot reach homeserver",
"Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin", "Ensure you have a stable internet connection, or get in touch with the server admin": "Ensure you have a stable internet connection, or get in touch with the server admin",
"Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured", "Your %(brand)s is misconfigured": "Your %(brand)s is misconfigured",
@ -653,7 +655,6 @@
"This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.", "This homeserver has exceeded one of its resource limits.": "This homeserver has exceeded one of its resource limits.",
"Please <a>contact your service administrator</a> to continue using the service.": "Please <a>contact your service administrator</a> to continue using the service.", "Please <a>contact your service administrator</a> to continue using the service.": "Please <a>contact your service administrator</a> to continue using the service.",
"Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...", "Unable to connect to Homeserver. Retrying...": "Unable to connect to Homeserver. Retrying...",
"Attachment": "Attachment",
"%(items)s and %(count)s others|other": "%(items)s and %(count)s others", "%(items)s and %(count)s others|other": "%(items)s and %(count)s others",
"%(items)s and %(count)s others|one": "%(items)s and one other", "%(items)s and %(count)s others|one": "%(items)s and one other",
"%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s", "%(items)s and %(lastItem)s": "%(items)s and %(lastItem)s",
@ -922,7 +923,10 @@
"Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)", "Live Location Sharing (temporary implementation: locations persist in room history)": "Live Location Sharing (temporary implementation: locations persist in room history)",
"Favourite Messages (under active development)": "Favourite Messages (under active development)", "Favourite Messages (under active development)": "Favourite Messages (under active development)",
"Voice broadcast (under active development)": "Voice broadcast (under active development)", "Voice broadcast (under active development)": "Voice broadcast (under active development)",
"Use new session manager (under active development)": "Use new session manager (under active development)", "Use new session manager": "Use new session manager",
"New session manager": "New session manager",
"Have greater visibility and control over all your sessions.": "Have greater visibility and control over all your sessions.",
"Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.": "Our new sessions manager provides better visibility of all your sessions, and greater control over them including the ability to remotely toggle push notifications.",
"Font size": "Font size", "Font size": "Font size",
"Use custom size": "Use custom size", "Use custom size": "Use custom size",
"Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing", "Enable Emoji suggestions while typing": "Enable Emoji suggestions while typing",
@ -1714,6 +1718,8 @@
"Please enter verification code sent via text.": "Please enter verification code sent via text.", "Please enter verification code sent via text.": "Please enter verification code sent via text.",
"Verification code": "Verification code", "Verification code": "Verification code",
"Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.", "Discovery options will appear once you have added a phone number above.": "Discovery options will appear once you have added a phone number above.",
"Sign out": "Sign out",
"Sign out all other sessions": "Sign out all other sessions",
"Current session": "Current session", "Current session": "Current session",
"Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.", "Confirm logging out these devices by using Single Sign On to prove your identity.|other": "Confirm logging out these devices by using Single Sign On to prove your identity.",
"Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.", "Confirm logging out these devices by using Single Sign On to prove your identity.|one": "Confirm logging out this device by using Single Sign On to prove your identity.",
@ -1770,7 +1776,6 @@
"Not ready for secure messaging": "Not ready for secure messaging", "Not ready for secure messaging": "Not ready for secure messaging",
"Inactive": "Inactive", "Inactive": "Inactive",
"Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer", "Inactive for %(inactiveAgeDays)s days or longer": "Inactive for %(inactiveAgeDays)s days or longer",
"Sign out": "Sign out",
"Filter devices": "Filter devices", "Filter devices": "Filter devices",
"Show": "Show", "Show": "Show",
"%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected", "%(selectedDeviceCount)s sessions selected": "%(selectedDeviceCount)s sessions selected",
@ -1847,7 +1852,6 @@
"Emoji": "Emoji", "Emoji": "Emoji",
"Hide stickers": "Hide stickers", "Hide stickers": "Hide stickers",
"Sticker": "Sticker", "Sticker": "Sticker",
"Voice broadcast": "Voice broadcast",
"Voice Message": "Voice Message", "Voice Message": "Voice Message",
"You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.", "You do not have permission to start polls in this room.": "You do not have permission to start polls in this room.",
"Poll": "Poll", "Poll": "Poll",

View file

@ -17,7 +17,6 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import request from 'browser-request';
import counterpart from 'counterpart'; import counterpart from 'counterpart';
import React from 'react'; import React from 'react';
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@ -386,6 +385,13 @@ export function setMissingEntryGenerator(f: (value: string) => void) {
counterpart.setMissingEntryGenerator(f); counterpart.setMissingEntryGenerator(f);
} }
type Languages = {
[lang: string]: {
fileName: string;
label: string;
};
};
export function setLanguage(preferredLangs: string | string[]) { export function setLanguage(preferredLangs: string | string[]) {
if (!Array.isArray(preferredLangs)) { if (!Array.isArray(preferredLangs)) {
preferredLangs = [preferredLangs]; preferredLangs = [preferredLangs];
@ -396,8 +402,8 @@ export function setLanguage(preferredLangs: string | string[]) {
plaf.setLanguage(preferredLangs); plaf.setLanguage(preferredLangs);
} }
let langToUse; let langToUse: string;
let availLangs; let availLangs: Languages;
return getLangsJson().then((result) => { return getLangsJson().then((result) => {
availLangs = result; availLangs = result;
@ -434,9 +440,14 @@ export function setLanguage(preferredLangs: string | string[]) {
}); });
} }
export function getAllLanguagesFromJson() { type Language = {
value: string;
label: string;
};
export function getAllLanguagesFromJson(): Promise<Language[]> {
return getLangsJson().then((langsObject) => { return getLangsJson().then((langsObject) => {
const langs = []; const langs: Language[] = [];
for (const langKey in langsObject) { for (const langKey in langsObject) {
if (langsObject.hasOwnProperty(langKey)) { if (langsObject.hasOwnProperty(langKey)) {
langs.push({ langs.push({
@ -532,29 +543,21 @@ export function pickBestLanguage(langs: string[]): string {
return langs[0]; return langs[0];
} }
function getLangsJson(): Promise<object> { async function getLangsJson(): Promise<Languages> {
return new Promise((resolve, reject) => { let url: string;
let url; if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through
if (typeof(webpackLangJsonUrl) === 'string') { // in Jest this 'url' isn't a URL, so just fall through url = webpackLangJsonUrl;
url = webpackLangJsonUrl; } else {
} else { url = i18nFolder + 'languages.json';
url = i18nFolder + 'languages.json'; }
}
request( const res = await fetch(url, { method: "GET" });
{ method: "GET", url },
(err, response, body) => { if (!res.ok) {
if (err) { throw new Error(`Failed to load ${url}, got ${res.status}`);
reject(err); }
return;
} return res.json();
if (response.status < 200 || response.status >= 300) {
reject(new Error(`Failed to load ${url}, got ${response.status}`));
return;
}
resolve(JSON.parse(body));
},
);
});
} }
interface ICounterpartTranslation { interface ICounterpartTranslation {
@ -571,23 +574,14 @@ async function getLanguageRetry(langPath: string, num = 3): Promise<ICounterpart
}); });
} }
function getLanguage(langPath: string): Promise<ICounterpartTranslation> { async function getLanguage(langPath: string): Promise<ICounterpartTranslation> {
return new Promise((resolve, reject) => { const res = await fetch(langPath, { method: "GET" });
request(
{ method: "GET", url: langPath }, if (!res.ok) {
(err, response, body) => { throw new Error(`Failed to load ${langPath}, got ${res.status}`);
if (err) { }
reject(err);
return; return res.json();
}
if (response.status < 200 || response.status >= 300) {
reject(new Error(`Failed to load ${langPath}, got ${response.status}`));
return;
}
resolve(JSON.parse(body));
},
);
});
} }
export interface ICustomTranslations { export interface ICustomTranslations {

View file

@ -1,28 +0,0 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEventRelation } from "matrix-js-sdk/src/matrix";
import { IAbortablePromise } from "matrix-js-sdk/src/@types/partials";
export interface IUpload {
fileName: string;
roomId: string;
relation?: IEventRelation;
total: number;
loaded: number;
promise: IAbortablePromise<any>;
canceled?: boolean;
}

53
src/models/RoomUpload.ts Normal file
View file

@ -0,0 +1,53 @@
/*
Copyright 2021 The Matrix.org Foundation C.I.C.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { IEventRelation, UploadProgress } from "matrix-js-sdk/src/matrix";
import { IEncryptedFile } from "../customisations/models/IMediaEventContent";
export class RoomUpload {
public readonly abortController = new AbortController();
public promise: Promise<{ url?: string, file?: IEncryptedFile }>;
private uploaded = 0;
constructor(
public readonly roomId: string,
public readonly fileName: string,
public readonly relation?: IEventRelation,
public fileSize = 0,
) {}
public onProgress(progress: UploadProgress) {
this.uploaded = progress.loaded;
this.fileSize = progress.total;
}
public abort(): void {
this.abortController.abort();
}
public get cancelled(): boolean {
return this.abortController.signal.aborted;
}
public get total(): number {
return this.fileSize;
}
public get loaded(): number {
return this.uploaded;
}
}

View file

@ -475,8 +475,24 @@ export const SETTINGS: {[setting: string]: ISetting} = {
isFeature: true, isFeature: true,
labsGroup: LabGroup.Experimental, labsGroup: LabGroup.Experimental,
supportedLevels: LEVELS_FEATURE, supportedLevels: LEVELS_FEATURE,
displayName: _td("Use new session manager (under active development)"), displayName: _td("Use new session manager"),
default: false, default: false,
betaInfo: {
title: _td('New session manager'),
caption: () => <>
<p>
{ _td('Have greater visibility and control over all your sessions.') }
</p>
<p>
{ _td(
'Our new sessions manager provides better visibility of all your sessions, '
+ 'and greater control over them including the ability to remotely toggle push notifications.',
)
}
</p>
</>,
},
}, },
"baseFontSize": { "baseFontSize": {
displayName: _td("Font size"), displayName: _td("Font size"),

View file

@ -90,7 +90,7 @@ export class CallStore extends AsyncStoreWithClient<{}> {
} }
this.callListeners.clear(); this.callListeners.clear();
this.calls.clear(); this.calls.clear();
this.activeCalls = new Set(); this._activeCalls.clear();
this.matrixClient.off(ClientEvent.Room, this.onRoom); this.matrixClient.off(ClientEvent.Room, this.onRoom);
this.matrixClient.off(RoomStateEvent.Events, this.onRoomState); this.matrixClient.off(RoomStateEvent.Events, this.onRoomState);

View file

@ -449,7 +449,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
from, from,
to, to,
limit, limit,
direction: dir, dir,
}); });
return { return {

View file

@ -28,8 +28,6 @@ import {
showDialog as showAnalyticsLearnMoreDialog, showDialog as showAnalyticsLearnMoreDialog,
} from "../components/views/dialogs/AnalyticsLearnMoreDialog"; } from "../components/views/dialogs/AnalyticsLearnMoreDialog";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import { SnakedObject } from "../utils/SnakedObject";
import { IConfigOptions } from "../IConfigOptions";
import SettingsStore from "../settings/SettingsStore"; import SettingsStore from "../settings/SettingsStore";
const onAccept = () => { const onAccept = () => {
@ -78,16 +76,7 @@ const onLearnMorePreviouslyOptedIn = () => {
const TOAST_KEY = "analytics"; const TOAST_KEY = "analytics";
export function getPolicyUrl(): Optional<string> { export function getPolicyUrl(): Optional<string> {
const policyUrl = SdkConfig.get("privacy_policy_url"); return SdkConfig.get("privacy_policy_url");
if (policyUrl) return policyUrl;
// Try get from legacy config location
const piwikConfig = SdkConfig.get("piwik");
let piwik: Optional<SnakedObject<Extract<IConfigOptions["piwik"], object>>>;
if (typeof piwikConfig === 'object') {
piwik = new SnakedObject(piwikConfig);
}
return piwik?.get("policy_url");
} }
export const showToast = (): void => { export const showToast = (): void => {

View file

@ -20,7 +20,6 @@ import DeviceListener from '../DeviceListener';
import GenericToast from "../components/views/toasts/GenericToast"; import GenericToast from "../components/views/toasts/GenericToast";
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import { UserTab } from "../components/views/dialogs/UserTab";
const TOAST_KEY = "reviewsessions"; const TOAST_KEY = "reviewsessions";
@ -29,8 +28,7 @@ export const showToast = (deviceIds: Set<string>) => {
DeviceListener.sharedInstance().dismissUnverifiedSessions(deviceIds); DeviceListener.sharedInstance().dismissUnverifiedSessions(deviceIds);
dis.dispatch({ dis.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserDeviceSettings,
initialTabId: UserTab.Security,
}); });
}; };

View file

@ -21,7 +21,6 @@ import DeviceListener from '../DeviceListener';
import ToastStore from "../stores/ToastStore"; import ToastStore from "../stores/ToastStore";
import GenericToast from "../components/views/toasts/GenericToast"; import GenericToast from "../components/views/toasts/GenericToast";
import { Action } from "../dispatcher/actions"; import { Action } from "../dispatcher/actions";
import { UserTab } from "../components/views/dialogs/UserTab";
function toastKey(deviceId: string) { function toastKey(deviceId: string) {
return "unverified_session_" + deviceId; return "unverified_session_" + deviceId;
@ -33,8 +32,7 @@ export const showToast = async (deviceId: string) => {
const onAccept = () => { const onAccept = () => {
DeviceListener.sharedInstance().dismissUnverifiedSessions([deviceId]); DeviceListener.sharedInstance().dismissUnverifiedSessions([deviceId]);
dis.dispatch({ dis.dispatch({
action: Action.ViewUserSettings, action: Action.ViewUserDeviceSettings,
initialTabId: UserTab.Security,
}); });
}; };

View file

@ -184,7 +184,7 @@ export default class MultiInviter {
} }
} }
return this.matrixClient.invite(roomId, addr, undefined, this.reason); return this.matrixClient.invite(roomId, addr, this.reason);
} else { } else {
throw new Error('Unsupported address'); throw new Error('Unsupported address');
} }

View file

@ -51,5 +51,5 @@ export async function createLocalNotificationSettingsIfNeeded(cli: MatrixClient)
export function localNotificationsAreSilenced(cli: MatrixClient): boolean { export function localNotificationsAreSilenced(cli: MatrixClient): boolean {
const eventType = getLocalNotificationAccountDataEventType(cli.deviceId); const eventType = getLocalNotificationAccountDataEventType(cli.deviceId);
const event = cli.getAccountData(eventType); const event = cli.getAccountData(eventType);
return event?.getContent<LocalNotificationSettings>()?.is_silenced ?? true; return event?.getContent<LocalNotificationSettings>()?.is_silenced ?? false;
} }

View file

@ -0,0 +1,141 @@
/*
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 { Optional } from "matrix-events-sdk";
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { VoiceRecording } from "../../audio/VoiceRecording";
import SdkConfig, { DEFAULTS } from "../../SdkConfig";
import { concat } from "../../utils/arrays";
import { IDestroyable } from "../../utils/IDestroyable";
export enum VoiceBroadcastRecorderEvent {
ChunkRecorded = "chunk_recorded",
}
interface EventMap {
[VoiceBroadcastRecorderEvent.ChunkRecorded]: (chunk: ChunkRecordedPayload) => void;
}
export interface ChunkRecordedPayload {
buffer: Uint8Array;
length: number;
}
/**
* This class provides the function to seamlessly record fixed length chunks.
* Subscribe with on(VoiceBroadcastRecordingEvents.ChunkRecorded, (payload: ChunkRecordedPayload) => {})
* to retrieve chunks while recording.
*/
export class VoiceBroadcastRecorder
extends TypedEventEmitter<VoiceBroadcastRecorderEvent, EventMap>
implements IDestroyable {
private headers = new Uint8Array(0);
private chunkBuffer = new Uint8Array(0);
private previousChunkEndTimePosition = 0;
private pagesFromRecorderCount = 0;
public constructor(
private voiceRecording: VoiceRecording,
public readonly targetChunkLength: number,
) {
super();
this.voiceRecording.onDataAvailable = this.onDataAvailable;
}
public async start(): Promise<void> {
return this.voiceRecording.start();
}
/**
* Stops the recording and returns the remaining chunk (if any).
*/
public async stop(): Promise<Optional<ChunkRecordedPayload>> {
await this.voiceRecording.stop();
return this.extractChunk();
}
public get contentType(): string {
return this.voiceRecording.contentType;
}
private get chunkLength(): number {
return this.voiceRecording.recorderSeconds - this.previousChunkEndTimePosition;
}
private onDataAvailable = (data: ArrayBuffer): void => {
const dataArray = new Uint8Array(data);
this.pagesFromRecorderCount++;
if (this.pagesFromRecorderCount <= 2) {
// first two pages contain the headers
this.headers = concat(this.headers, dataArray);
return;
}
this.handleData(dataArray);
};
private handleData(data: Uint8Array): void {
this.chunkBuffer = concat(this.chunkBuffer, data);
this.emitChunkIfTargetLengthReached();
}
private emitChunkIfTargetLengthReached(): void {
if (this.chunkLength >= this.targetChunkLength) {
this.emitAndResetChunk();
}
}
/**
* Extracts the current chunk and resets the buffer.
*/
private extractChunk(): Optional<ChunkRecordedPayload> {
if (this.chunkBuffer.length === 0) {
return null;
}
const currentRecorderTime = this.voiceRecording.recorderSeconds;
const payload: ChunkRecordedPayload = {
buffer: concat(this.headers, this.chunkBuffer),
length: this.chunkLength,
};
this.chunkBuffer = new Uint8Array(0);
this.previousChunkEndTimePosition = currentRecorderTime;
return payload;
}
private emitAndResetChunk(): void {
if (this.chunkBuffer.length === 0) {
return;
}
this.emit(
VoiceBroadcastRecorderEvent.ChunkRecorded,
this.extractChunk(),
);
}
public destroy(): void {
this.removeAllListeners();
this.voiceRecording.destroy();
}
}
export const createVoiceBroadcastRecorder = (): VoiceBroadcastRecorder => {
const targetChunkLength = SdkConfig.get("voice_broadcast")?.chunk_length || DEFAULTS.voice_broadcast!.chunk_length;
return new VoiceBroadcastRecorder(new VoiceRecording(), targetChunkLength);
};

View file

@ -31,7 +31,7 @@ export const VoiceBroadcastBody: React.FC<IBodyProps> = ({ mxEvent }) => {
const client = MatrixClientPeg.get(); const client = MatrixClientPeg.get();
const room = client.getRoom(mxEvent.getRoomId()); const room = client.getRoom(mxEvent.getRoomId());
const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client); const recording = VoiceBroadcastRecordingsStore.instance().getByInfoEvent(mxEvent, client);
const [recordingState, setRecordingState] = useState(recording.state); const [recordingState, setRecordingState] = useState(recording.getState());
useTypedEventEmitter( useTypedEventEmitter(
recording, recording,
@ -46,13 +46,10 @@ export const VoiceBroadcastBody: React.FC<IBodyProps> = ({ mxEvent }) => {
recording.stop(); recording.stop();
}; };
const senderId = mxEvent.getSender();
const sender = mxEvent.sender;
return <VoiceBroadcastRecordingBody return <VoiceBroadcastRecordingBody
onClick={stopVoiceBroadcast} onClick={stopVoiceBroadcast}
live={recordingState === VoiceBroadcastInfoState.Started} live={recordingState === VoiceBroadcastInfoState.Started}
member={sender} sender={mxEvent.sender}
userId={senderId} roomName={room.name}
title={`${sender?.name ?? senderId}${room.name}`}
/>; />;
}; };

View file

@ -0,0 +1,55 @@
/*
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 { RoomMember } from "matrix-js-sdk/src/matrix";
import MemberAvatar from "../../../components/views/avatars/MemberAvatar";
import { LiveBadge } from "../..";
import { Icon, IconColour, IconType } from "../../../components/atoms/Icon";
import { _t } from "../../../languageHandler";
interface VoiceBroadcastHeaderProps {
live: boolean;
sender: RoomMember;
roomName: string;
showBroadcast?: boolean;
}
export const VoiceBroadcastHeader: React.FC<VoiceBroadcastHeaderProps> = ({
live,
sender,
roomName,
showBroadcast = false,
}) => {
const broadcast = showBroadcast
? <div className="mx_VoiceBroadcastHeader_line">
<Icon type={IconType.Live} colour={IconColour.CompoundSecondaryContent} />
{ _t("Voice broadcast") }
</div>
: null;
const liveBadge = live ? <LiveBadge /> : null;
return <div className="mx_VoiceBroadcastHeader">
<MemberAvatar member={sender} fallbackUserId={sender.userId} />
<div className="mx_VoiceBroadcastHeader_content">
<div className="mx_VoiceBroadcastHeader_sender">
{ sender.name }
</div>
<div className="mx_VoiceBroadcastHeader_room">
{ roomName }
</div>
{ broadcast }
</div>
{ liveBadge }
</div>;
};

View file

@ -17,40 +17,31 @@ limitations under the License.
import React, { MouseEventHandler } from "react"; import React, { MouseEventHandler } from "react";
import { RoomMember } from "matrix-js-sdk/src/matrix"; import { RoomMember } from "matrix-js-sdk/src/matrix";
import { LiveBadge } from "../.."; import { VoiceBroadcastHeader } from "../..";
import MemberAvatar from "../../../components/views/avatars/MemberAvatar";
interface VoiceBroadcastRecordingBodyProps { interface VoiceBroadcastRecordingBodyProps {
live: boolean; live: boolean;
member: RoomMember;
onClick: MouseEventHandler<HTMLDivElement>; onClick: MouseEventHandler<HTMLDivElement>;
title: string; roomName: string;
userId: string; sender: RoomMember;
} }
export const VoiceBroadcastRecordingBody: React.FC<VoiceBroadcastRecordingBodyProps> = ({ export const VoiceBroadcastRecordingBody: React.FC<VoiceBroadcastRecordingBodyProps> = ({
live, live,
member,
onClick, onClick,
title, roomName,
userId, sender,
}) => { }) => {
const liveBadge = live
? <LiveBadge />
: null;
return ( return (
<div <div
className="mx_VoiceBroadcastRecordingBody" className="mx_VoiceBroadcastRecordingBody"
onClick={onClick} onClick={onClick}
> >
<MemberAvatar member={member} fallbackUserId={userId} /> <VoiceBroadcastHeader
<div className="mx_VoiceBroadcastRecordingBody_content"> live={live}
<div className="mx_VoiceBroadcastRecordingBody_title"> sender={sender}
{ title } roomName={roomName}
</div> />
</div>
{ liveBadge }
</div> </div>
); );
}; };

View file

@ -21,12 +21,18 @@ limitations under the License.
import { RelationType } from "matrix-js-sdk/src/matrix"; import { RelationType } from "matrix-js-sdk/src/matrix";
export * from "./components"; export * from "./audio/VoiceBroadcastRecorder";
export * from "./models"; export * from "./components/VoiceBroadcastBody";
export * from "./utils"; export * from "./components/atoms/LiveBadge";
export * from "./stores"; export * from "./components/atoms/VoiceBroadcastHeader";
export * from "./components/molecules/VoiceBroadcastRecordingBody";
export * from "./models/VoiceBroadcastRecording";
export * from "./stores/VoiceBroadcastRecordingsStore";
export * from "./utils/shouldDisplayAsVoiceBroadcastTile";
export * from "./utils/startNewVoiceBroadcastRecording";
export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info"; export const VoiceBroadcastInfoEventType = "io.element.voice_broadcast_info";
export const VoiceBroadcastChunkEventType = "io.element.voice_broadcast_chunk";
export enum VoiceBroadcastInfoState { export enum VoiceBroadcastInfoState {
Started = "started", Started = "started",
@ -36,6 +42,7 @@ export enum VoiceBroadcastInfoState {
} }
export interface VoiceBroadcastInfoEventContent { export interface VoiceBroadcastInfoEventContent {
device_id: string;
state: VoiceBroadcastInfoState; state: VoiceBroadcastInfoState;
chunk_length?: number; chunk_length?: number;
["m.relates_to"]?: { ["m.relates_to"]?: {

View file

@ -14,10 +14,22 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix"; import { MatrixClient, MatrixEvent, RelationType } from "matrix-js-sdk/src/matrix";
import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter"; import { TypedEventEmitter } from "matrix-js-sdk/src/models/typed-event-emitter";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from ".."; import {
ChunkRecordedPayload,
createVoiceBroadcastRecorder,
VoiceBroadcastInfoEventType,
VoiceBroadcastInfoState,
VoiceBroadcastRecorder,
VoiceBroadcastRecorderEvent,
} from "..";
import { uploadFile } from "../../ContentMessages";
import { IEncryptedFile } from "../../customisations/models/IMediaEventContent";
import { createVoiceMessageContent } from "../../utils/createVoiceMessageContent";
import { IDestroyable } from "../../utils/IDestroyable";
export enum VoiceBroadcastRecordingEvent { export enum VoiceBroadcastRecordingEvent {
StateChanged = "liveness_changed", StateChanged = "liveness_changed",
@ -27,8 +39,12 @@ interface EventMap {
[VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void; [VoiceBroadcastRecordingEvent.StateChanged]: (state: VoiceBroadcastInfoState) => void;
} }
export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap> { export class VoiceBroadcastRecording
private _state: VoiceBroadcastInfoState; extends TypedEventEmitter<VoiceBroadcastRecordingEvent, EventMap>
implements IDestroyable {
private state: VoiceBroadcastInfoState;
private recorder: VoiceBroadcastRecorder;
private sequence = 1;
public constructor( public constructor(
public readonly infoEvent: MatrixEvent, public readonly infoEvent: MatrixEvent,
@ -43,25 +59,94 @@ export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRec
VoiceBroadcastInfoEventType, VoiceBroadcastInfoEventType,
); );
const relatedEvents = relations?.getRelations(); const relatedEvents = relations?.getRelations();
this._state = !relatedEvents?.find((event: MatrixEvent) => { this.state = !relatedEvents?.find((event: MatrixEvent) => {
return event.getContent()?.state === VoiceBroadcastInfoState.Stopped; return event.getContent()?.state === VoiceBroadcastInfoState.Stopped;
}) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped; }) ? VoiceBroadcastInfoState.Started : VoiceBroadcastInfoState.Stopped;
// TODO Michael W: add listening for updates // TODO Michael W: add listening for updates
} }
public async start(): Promise<void> {
return this.getRecorder().start();
}
public async stop(): Promise<void> {
this.setState(VoiceBroadcastInfoState.Stopped);
await this.stopRecorder();
await this.sendStoppedStateEvent();
}
public getState(): VoiceBroadcastInfoState {
return this.state;
}
private getRecorder(): VoiceBroadcastRecorder {
if (!this.recorder) {
this.recorder = createVoiceBroadcastRecorder();
this.recorder.on(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded);
}
return this.recorder;
}
public destroy(): void {
if (this.recorder) {
this.recorder.off(VoiceBroadcastRecorderEvent.ChunkRecorded, this.onChunkRecorded);
this.recorder.stop();
}
this.removeAllListeners();
}
private setState(state: VoiceBroadcastInfoState): void { private setState(state: VoiceBroadcastInfoState): void {
this._state = state; this.state = state;
this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state); this.emit(VoiceBroadcastRecordingEvent.StateChanged, this.state);
} }
public async stop() { private onChunkRecorded = async (chunk: ChunkRecordedPayload): Promise<void> => {
this.setState(VoiceBroadcastInfoState.Stopped); const { url, file } = await this.uploadFile(chunk);
// TODO Michael W: add error handling await this.sendVoiceMessage(chunk, url, file);
};
private uploadFile(chunk: ChunkRecordedPayload): ReturnType<typeof uploadFile> {
return uploadFile(
this.client,
this.infoEvent.getRoomId(),
new Blob(
[chunk.buffer],
{
type: this.getRecorder().contentType,
},
),
);
}
private async sendVoiceMessage(chunk: ChunkRecordedPayload, url: string, file: IEncryptedFile): Promise<void> {
const content = createVoiceMessageContent(
url,
this.getRecorder().contentType,
Math.round(chunk.length * 1000),
chunk.buffer.length,
file,
);
content["m.relates_to"] = {
rel_type: RelationType.Reference,
event_id: this.infoEvent.getId(),
};
content["io.element.voice_broadcast_chunk"] = {
sequence: this.sequence++,
};
await this.client.sendMessage(this.infoEvent.getRoomId(), content);
}
private async sendStoppedStateEvent(): Promise<void> {
// TODO Michael W: add error handling for state event
await this.client.sendStateEvent( await this.client.sendStateEvent(
this.infoEvent.getRoomId(), this.infoEvent.getRoomId(),
VoiceBroadcastInfoEventType, VoiceBroadcastInfoEventType,
{ {
device_id: this.client.getDeviceId(),
state: VoiceBroadcastInfoState.Stopped, state: VoiceBroadcastInfoState.Stopped,
["m.relates_to"]: { ["m.relates_to"]: {
rel_type: RelationType.Reference, rel_type: RelationType.Reference,
@ -72,7 +157,18 @@ export class VoiceBroadcastRecording extends TypedEventEmitter<VoiceBroadcastRec
); );
} }
public get state(): VoiceBroadcastInfoState { private async stopRecorder(): Promise<void> {
return this._state; if (!this.recorder) {
return;
}
try {
const lastChunk = await this.recorder.stop();
if (lastChunk) {
await this.onChunkRecorded(lastChunk);
}
} catch (err) {
logger.warn("error stopping voice broadcast recorder", err);
}
} }
} }

View file

@ -1,17 +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.
*/
export * from "./VoiceBroadcastRecording";

View file

@ -31,7 +31,7 @@ interface EventMap {
* This store provides access to the current and specific Voice Broadcast recordings. * This store provides access to the current and specific Voice Broadcast recordings.
*/ */
export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadcastRecordingsStoreEvent, EventMap> { export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadcastRecordingsStoreEvent, EventMap> {
private _current: VoiceBroadcastRecording | null; private current: VoiceBroadcastRecording | null;
private recordings = new Map<string, VoiceBroadcastRecording>(); private recordings = new Map<string, VoiceBroadcastRecording>();
public constructor() { public constructor() {
@ -39,15 +39,15 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
} }
public setCurrent(current: VoiceBroadcastRecording): void { public setCurrent(current: VoiceBroadcastRecording): void {
if (this._current === current) return; if (this.current === current) return;
this._current = current; this.current = current;
this.recordings.set(current.infoEvent.getId(), current); this.recordings.set(current.infoEvent.getId(), current);
this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current); this.emit(VoiceBroadcastRecordingsStoreEvent.CurrentChanged, current);
} }
public get current(): VoiceBroadcastRecording { public getCurrent(): VoiceBroadcastRecording {
return this._current; return this.current;
} }
public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording { public getByInfoEvent(infoEvent: MatrixEvent, client: MatrixClient): VoiceBroadcastRecording {
@ -60,12 +60,12 @@ export class VoiceBroadcastRecordingsStore extends TypedEventEmitter<VoiceBroadc
return this.recordings.get(infoEventId); return this.recordings.get(infoEventId);
} }
public static readonly _instance = new VoiceBroadcastRecordingsStore(); private static readonly cachedInstance = new VoiceBroadcastRecordingsStore();
/** /**
* TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged * TODO Michael W: replace when https://github.com/matrix-org/matrix-react-sdk/pull/9293 has been merged
*/ */
public static instance() { public static instance(): VoiceBroadcastRecordingsStore {
return VoiceBroadcastRecordingsStore._instance; return VoiceBroadcastRecordingsStore.cachedInstance;
} }
} }

View file

@ -53,6 +53,7 @@ export const startNewVoiceBroadcastRecording = async (
client, client,
); );
recordingsStore.setCurrent(recording); recordingsStore.setCurrent(recording);
recording.start();
resolve(recording); resolve(recording);
} }
}; };
@ -64,6 +65,7 @@ export const startNewVoiceBroadcastRecording = async (
roomId, roomId,
VoiceBroadcastInfoEventType, VoiceBroadcastInfoEventType,
{ {
device_id: client.getDeviceId(),
state: VoiceBroadcastInfoState.Started, state: VoiceBroadcastInfoState.Started,
chunk_length: 300, chunk_length: 300,
} as VoiceBroadcastInfoEventContent, } as VoiceBroadcastInfoEventContent,

View file

@ -15,15 +15,31 @@ limitations under the License.
*/ */
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { IImageInfo, ISendEventResponse, MatrixClient } from "matrix-js-sdk/src/matrix"; import { IImageInfo, ISendEventResponse, MatrixClient, RelationType, UploadResponse } from "matrix-js-sdk/src/matrix";
import { defer } from "matrix-js-sdk/src/utils";
import encrypt, { IEncryptedFile } from "matrix-encrypt-attachment";
import ContentMessages from "../src/ContentMessages"; import ContentMessages, { UploadCanceledError, uploadFile } from "../src/ContentMessages";
import { doMaybeLocalRoomAction } from "../src/utils/local-room"; import { doMaybeLocalRoomAction } from "../src/utils/local-room";
import { createTestClient } from "./test-utils";
import { BlurhashEncoder } from "../src/BlurhashEncoder";
jest.mock("matrix-encrypt-attachment", () => ({ encryptAttachment: jest.fn().mockResolvedValue({}) }));
jest.mock("../src/BlurhashEncoder", () => ({
BlurhashEncoder: {
instance: {
getBlurhash: jest.fn(),
},
},
}));
jest.mock("../src/utils/local-room", () => ({ jest.mock("../src/utils/local-room", () => ({
doMaybeLocalRoomAction: jest.fn(), doMaybeLocalRoomAction: jest.fn(),
})); }));
const createElement = document.createElement.bind(document);
describe("ContentMessages", () => { describe("ContentMessages", () => {
const stickerUrl = "https://example.com/sticker"; const stickerUrl = "https://example.com/sticker";
const roomId = "!room:example.com"; const roomId = "!room:example.com";
@ -36,6 +52,9 @@ describe("ContentMessages", () => {
beforeEach(() => { beforeEach(() => {
client = { client = {
sendStickerMessage: jest.fn(), sendStickerMessage: jest.fn(),
sendMessage: jest.fn(),
isRoomEncrypted: jest.fn().mockReturnValue(false),
uploadContent: jest.fn().mockResolvedValue({ content_uri: "mxc://server/file" }),
} as unknown as MatrixClient; } as unknown as MatrixClient;
contentMessages = new ContentMessages(); contentMessages = new ContentMessages();
prom = Promise.resolve(null); prom = Promise.resolve(null);
@ -65,4 +84,226 @@ describe("ContentMessages", () => {
expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text); expect(client.sendStickerMessage).toHaveBeenCalledWith(roomId, null, stickerUrl, imageInfo, text);
}); });
}); });
describe("sendContentToRoom", () => {
const roomId = "!roomId:server";
beforeEach(() => {
Object.defineProperty(global.Image.prototype, 'src', {
// Define the property setter
set(src) {
setTimeout(() => this.onload());
},
});
Object.defineProperty(global.Image.prototype, 'height', {
get() { return 600; },
});
Object.defineProperty(global.Image.prototype, 'width', {
get() { return 800; },
});
mocked(doMaybeLocalRoomAction).mockImplementation((
roomId: string,
fn: (actualRoomId: string) => Promise<ISendEventResponse>,
) => fn(roomId));
mocked(BlurhashEncoder.instance.getBlurhash).mockResolvedValue(undefined);
});
it("should use m.image for image files", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/jpeg" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.image",
}));
});
it("should fall back to m.file for invalid image files", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "image/png" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
}));
});
it("should use m.video for video files", async () => {
jest.spyOn(document, "createElement").mockImplementation(tagName => {
const element = createElement(tagName);
if (tagName === "video") {
element.load = jest.fn();
element.play = () => element.onloadeddata(new Event("loadeddata"));
element.pause = jest.fn();
Object.defineProperty(element, 'videoHeight', {
get() { return 600; },
});
Object.defineProperty(element, 'videoWidth', {
get() { return 800; },
});
}
return element;
});
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "video/mp4" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.video",
}));
});
it("should use m.audio for audio files", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "fileName", { type: "audio/mp3" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.audio",
}));
});
it("should default to name 'Attachment' if file doesn't have a name", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "", { type: "text/plain" });
await contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
expect(client.sendMessage).toHaveBeenCalledWith(roomId, null, expect.objectContaining({
url: "mxc://server/file",
msgtype: "m.file",
body: "Attachment",
}));
});
it("should keep RoomUpload's total and loaded values up to date", async () => {
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const file = new File([], "", { type: "text/plain" });
const prom = contentMessages.sendContentToRoom(file, roomId, undefined, client, undefined);
const [upload] = contentMessages.getCurrentUploads();
expect(upload.loaded).toBe(0);
expect(upload.total).toBe(file.size);
const { progressHandler } = mocked(client.uploadContent).mock.calls[0][1];
progressHandler({ loaded: 123, total: 1234 });
expect(upload.loaded).toBe(123);
expect(upload.total).toBe(1234);
await prom;
});
});
describe("getCurrentUploads", () => {
const file1 = new File([], "file1");
const file2 = new File([], "file2");
const roomId = "!roomId:server";
beforeEach(() => {
mocked(doMaybeLocalRoomAction).mockImplementation((
roomId: string,
fn: (actualRoomId: string) => Promise<ISendEventResponse>,
) => fn(roomId));
});
it("should return only uploads for the given relation", async () => {
const relation = {
rel_type: RelationType.Thread,
event_id: "!threadId:server",
};
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
const uploads = contentMessages.getCurrentUploads(relation);
expect(uploads).toHaveLength(1);
expect(uploads[0].relation).toEqual(relation);
expect(uploads[0].fileName).toEqual("file1");
await Promise.all([p1, p2]);
});
it("should return only uploads for no relation when not passed one", async () => {
const relation = {
rel_type: RelationType.Thread,
event_id: "!threadId:server",
};
const p1 = contentMessages.sendContentToRoom(file1, roomId, relation, client, undefined);
const p2 = contentMessages.sendContentToRoom(file2, roomId, undefined, client, undefined);
const uploads = contentMessages.getCurrentUploads();
expect(uploads).toHaveLength(1);
expect(uploads[0].relation).toEqual(undefined);
expect(uploads[0].fileName).toEqual("file2");
await Promise.all([p1, p2]);
});
});
describe("cancelUpload", () => {
it("should cancel in-flight upload", async () => {
const deferred = defer<UploadResponse>();
mocked(client.uploadContent).mockReturnValue(deferred.promise);
const file1 = new File([], "file1");
const prom = contentMessages.sendContentToRoom(file1, roomId, undefined, client, undefined);
const { abortController } = mocked(client.uploadContent).mock.calls[0][1];
expect(abortController.signal.aborted).toBeFalsy();
const [upload] = contentMessages.getCurrentUploads();
contentMessages.cancelUpload(upload);
expect(abortController.signal.aborted).toBeTruthy();
deferred.resolve({} as UploadResponse);
await prom;
});
});
});
describe("uploadFile", () => {
beforeEach(() => {
jest.clearAllMocks();
});
const client = createTestClient();
it("should not encrypt the file if the room isn't encrypted", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
const progressHandler = jest.fn();
const file = new Blob([]);
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
expect(res.url).toBe("mxc://server/file");
expect(res.file).toBeFalsy();
expect(encrypt.encryptAttachment).not.toHaveBeenCalled();
expect(client.uploadContent).toHaveBeenCalledWith(file, expect.objectContaining({ progressHandler }));
});
it("should encrypt the file if the room is encrypted", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(true);
mocked(client.uploadContent).mockResolvedValue({ content_uri: "mxc://server/file" });
mocked(encrypt.encryptAttachment).mockResolvedValue({
data: new ArrayBuffer(123),
info: {} as IEncryptedFile,
});
const progressHandler = jest.fn();
const file = new Blob(["123"]);
const res = await uploadFile(client, "!roomId:server", file, progressHandler);
expect(res.url).toBeFalsy();
expect(res.file).toEqual(expect.objectContaining({
url: "mxc://server/file",
}));
expect(encrypt.encryptAttachment).toHaveBeenCalled();
expect(client.uploadContent).toHaveBeenCalledWith(expect.any(Blob), expect.objectContaining({
progressHandler,
includeFilename: false,
}));
expect(mocked(client.uploadContent).mock.calls[0][0]).not.toBe(file);
});
it("should throw UploadCanceledError upon aborting the upload", async () => {
mocked(client.isRoomEncrypted).mockReturnValue(false);
const deferred = defer<UploadResponse>();
mocked(client.uploadContent).mockReturnValue(deferred.promise);
const file = new Blob([]);
const prom = uploadFile(client, "!roomId:server", file);
mocked(client.uploadContent).mock.calls[0][1].abortController.abort();
deferred.resolve({ content_uri: "mxc://foo/bar" });
await expect(prom).rejects.toThrowError(UploadCanceledError);
});
}); });

View file

@ -29,7 +29,7 @@ import {
createLocalNotificationSettingsIfNeeded, createLocalNotificationSettingsIfNeeded,
getLocalNotificationAccountDataEventType, getLocalNotificationAccountDataEventType,
} from "../src/utils/notifications"; } from "../src/utils/notifications";
import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockPlatformPeg } from "./test-utils"; import { getMockClientWithEventEmitter, mkEvent, mkRoom, mockClientMethodsUser, mockPlatformPeg } from "./test-utils";
import { IncomingCallToast } from "../src/toasts/IncomingCallToast"; import { IncomingCallToast } from "../src/toasts/IncomingCallToast";
jest.mock("../src/utils/notifications", () => ({ jest.mock("../src/utils/notifications", () => ({
@ -54,22 +54,28 @@ describe("Notifier", () => {
let accountDataEventKey: string; let accountDataEventKey: string;
let accountDataStore = {}; let accountDataStore = {};
const userId = "@bob:example.org";
beforeEach(() => { beforeEach(() => {
accountDataStore = {}; accountDataStore = {};
mockClient = getMockClientWithEventEmitter({ mockClient = getMockClientWithEventEmitter({
getUserId: jest.fn().mockReturnValue("@bob:example.org"), ...mockClientMethodsUser(userId),
isGuest: jest.fn().mockReturnValue(false), isGuest: jest.fn().mockReturnValue(false),
getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]), getAccountData: jest.fn().mockImplementation(eventType => accountDataStore[eventType]),
setAccountData: jest.fn().mockImplementation((eventType, content) => { setAccountData: jest.fn().mockImplementation((eventType, content) => {
accountDataStore[eventType] = new MatrixEvent({ accountDataStore[eventType] = content ? new MatrixEvent({
type: eventType, type: eventType,
content, content,
}); }) : undefined;
}), }),
decryptEventIfNeeded: jest.fn(), decryptEventIfNeeded: jest.fn(),
getRoom: jest.fn(), getRoom: jest.fn(),
getPushActionsForEvent: jest.fn(), getPushActionsForEvent: jest.fn(),
}); });
mockClient.pushRules = {
global: undefined,
};
accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId); accountDataEventKey = getLocalNotificationAccountDataEventType(mockClient.deviceId);
testRoom = mkRoom(mockClient, roomId); testRoom = mkRoom(mockClient, roomId);
@ -78,6 +84,7 @@ describe("Notifier", () => {
supportsNotifications: jest.fn().mockReturnValue(true), supportsNotifications: jest.fn().mockReturnValue(true),
maySendNotifications: jest.fn().mockReturnValue(true), maySendNotifications: jest.fn().mockReturnValue(true),
displayNotification: jest.fn(), displayNotification: jest.fn(),
loudNotification: jest.fn(),
}); });
Notifier.isBodyEnabled = jest.fn().mockReturnValue(true); Notifier.isBodyEnabled = jest.fn().mockReturnValue(true);
@ -85,12 +92,128 @@ describe("Notifier", () => {
mockClient.getRoom.mockReturnValue(testRoom); mockClient.getRoom.mockReturnValue(testRoom);
}); });
describe('triggering notification from events', () => {
let hasStartedNotiferBefore = false;
const event = new MatrixEvent({
sender: '@alice:server.org',
type: 'm.room.message',
room_id: '!room:server.org',
content: {
body: 'hey',
},
});
beforeEach(() => {
// notifier defines some listener functions in start
// and references them in stop
// so blows up if stopped before it was started
if (hasStartedNotiferBefore) {
Notifier.stop();
}
Notifier.start();
hasStartedNotiferBefore = true;
mockClient.getRoom.mockReturnValue(testRoom);
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: true,
},
});
const enabledSettings = [
'notificationsEnabled',
'audioNotificationsEnabled',
];
// enable notifications by default
jest.spyOn(SettingsStore, "getValue").mockImplementation(
settingName => enabledSettings.includes(settingName),
);
});
afterAll(() => {
Notifier.stop();
});
it('does not create notifications before syncing has started', () => {
mockClient!.emit(ClientEvent.Event, event);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it('does not create notifications for own event', () => {
const ownEvent = new MatrixEvent({ sender: userId });
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing);
mockClient!.emit(ClientEvent.Event, ownEvent);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it('does not create notifications when event does not have notify push action', () => {
mockClient.getPushActionsForEvent.mockReturnValue({
notify: false,
tweaks: {
sound: true,
},
});
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing);
mockClient!.emit(ClientEvent.Event, event);
expect(MockPlatform.displayNotification).not.toHaveBeenCalled();
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
it('creates desktop notification when enabled', () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing);
mockClient!.emit(ClientEvent.Event, event);
expect(MockPlatform.displayNotification).toHaveBeenCalledWith(
testRoom.name,
'hey',
null,
testRoom,
event,
);
});
it('creates a loud notification when enabled', () => {
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing);
mockClient!.emit(ClientEvent.Event, event);
expect(MockPlatform.loudNotification).toHaveBeenCalledWith(
event, testRoom,
);
});
it('does not create loud notification when event does not have sound tweak in push actions', () => {
mockClient.getPushActionsForEvent.mockReturnValue({
notify: true,
tweaks: {
sound: false,
},
});
mockClient!.emit(ClientEvent.Sync, SyncState.Syncing);
mockClient!.emit(ClientEvent.Event, event);
// desktop notification created
expect(MockPlatform.displayNotification).toHaveBeenCalled();
// without noisy
expect(MockPlatform.loudNotification).not.toHaveBeenCalled();
});
});
describe("_displayPopupNotification", () => { describe("_displayPopupNotification", () => {
it.each([ it.each([
{ silenced: true, count: 0 }, { event: { is_silenced: true }, count: 0 },
{ silenced: false, count: 1 }, { event: { is_silenced: false }, count: 1 },
])("does not dispatch when notifications are silenced", ({ silenced, count }) => { { event: undefined, count: 1 },
mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced }); ])("does not dispatch when notifications are silenced", ({ event, count }) => {
mockClient.setAccountData(accountDataEventKey, event);
Notifier._displayPopupNotification(testEvent, testRoom); Notifier._displayPopupNotification(testEvent, testRoom);
expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count); expect(MockPlatform.displayNotification).toHaveBeenCalledTimes(count);
}); });
@ -98,14 +221,15 @@ describe("Notifier", () => {
describe("_playAudioNotification", () => { describe("_playAudioNotification", () => {
it.each([ it.each([
{ silenced: true, count: 0 }, { event: { is_silenced: true }, count: 0 },
{ silenced: false, count: 1 }, { event: { is_silenced: false }, count: 1 },
])("does not dispatch when notifications are silenced", ({ silenced, count }) => { { event: undefined, count: 1 },
])("does not dispatch when notifications are silenced", ({ event, count }) => {
// It's not ideal to only look at whether this function has been called // It's not ideal to only look at whether this function has been called
// but avoids starting to look into DOM stuff // but avoids starting to look into DOM stuff
Notifier.getSoundForRoom = jest.fn(); Notifier.getSoundForRoom = jest.fn();
mockClient.setAccountData(accountDataEventKey, { is_silenced: silenced }); mockClient.setAccountData(accountDataEventKey, event);
Notifier._playAudioNotification(testEvent, testRoom); Notifier._playAudioNotification(testEvent, testRoom);
expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count); expect(Notifier.getSoundForRoom).toHaveBeenCalledTimes(count);
}); });

View file

@ -14,47 +14,199 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import { mocked } from "jest-mock";
import fetchMock from "fetch-mock-jest";
import ScalarAuthClient from '../src/ScalarAuthClient'; import ScalarAuthClient from '../src/ScalarAuthClient';
import { MatrixClientPeg } from '../src/MatrixClientPeg';
import { stubClient } from './test-utils'; import { stubClient } from './test-utils';
import SdkConfig from "../src/SdkConfig";
import { WidgetType } from "../src/widgets/WidgetType";
describe('ScalarAuthClient', function() { describe('ScalarAuthClient', function() {
const apiUrl = 'test.com/api'; const apiUrl = 'https://test.com/api';
const uiUrl = 'test.com/app'; const uiUrl = 'https:/test.com/app';
const tokenObject = {
access_token: "token",
token_type: "Bearer",
matrix_server_name: "localhost",
expires_in: 999,
};
let client;
beforeEach(function() { beforeEach(function() {
window.localStorage.getItem = jest.fn((arg) => { jest.clearAllMocks();
if (arg === "mx_scalar_token") return "brokentoken"; client = stubClient();
});
stubClient();
}); });
it('should request a new token if the old one fails', async function() { it('should request a new token if the old one fails', async function() {
const sac = new ScalarAuthClient(apiUrl, uiUrl); const sac = new ScalarAuthClient(apiUrl + 0, uiUrl);
// @ts-ignore unhappy with Promise calls fetchMock.get("https://test.com/api0/account?scalar_token=brokentoken&v=1.1", {
jest.spyOn(sac, 'getAccountName').mockImplementation((arg: string) => { body: { message: "Invalid token" },
switch (arg) {
case "brokentoken":
return Promise.reject({
message: "Invalid token",
});
case "wokentoken":
default:
return Promise.resolve(MatrixClientPeg.get().getUserId());
}
}); });
MatrixClientPeg.get().getOpenIdToken = jest.fn().mockResolvedValue('this is your openid token'); fetchMock.get("https://test.com/api0/account?scalar_token=wokentoken&v=1.1", {
body: { user_id: client.getUserId() },
});
client.getOpenIdToken = jest.fn().mockResolvedValue(tokenObject);
sac.exchangeForScalarToken = jest.fn((arg) => { sac.exchangeForScalarToken = jest.fn((arg) => {
if (arg === "this is your openid token") return Promise.resolve("wokentoken"); if (arg === tokenObject) return Promise.resolve("wokentoken");
}); });
await sac.connect(); await sac.connect();
expect(sac.exchangeForScalarToken).toBeCalledWith('this is your openid token'); expect(sac.exchangeForScalarToken).toBeCalledWith(tokenObject);
expect(sac.hasCredentials).toBeTruthy(); expect(sac.hasCredentials).toBeTruthy();
// @ts-ignore private property // @ts-ignore private property
expect(sac.scalarToken).toEqual('wokentoken'); expect(sac.scalarToken).toEqual('wokentoken');
}); });
describe("exchangeForScalarToken", () => {
it("should return `scalar_token` from API /register", async () => {
const sac = new ScalarAuthClient(apiUrl + 1, uiUrl);
fetchMock.postOnce("https://test.com/api1/register?v=1.1", {
body: { scalar_token: "stoken" },
});
await expect(sac.exchangeForScalarToken(tokenObject)).resolves.toBe("stoken");
});
it("should throw upon non-20x code", async () => {
const sac = new ScalarAuthClient(apiUrl + 2, uiUrl);
fetchMock.postOnce("https://test.com/api2/register?v=1.1", {
status: 500,
});
await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Scalar request failed: 500");
});
it("should throw if scalar_token is missing in response", async () => {
const sac = new ScalarAuthClient(apiUrl + 3, uiUrl);
fetchMock.postOnce("https://test.com/api3/register?v=1.1", {
body: {},
});
await expect(sac.exchangeForScalarToken(tokenObject)).rejects.toThrow("Missing scalar_token in response");
});
});
describe("registerForToken", () => {
it("should call `termsInteractionCallback` upon M_TERMS_NOT_SIGNED error", async () => {
const sac = new ScalarAuthClient(apiUrl + 4, uiUrl);
const termsInteractionCallback = jest.fn();
sac.setTermsInteractionCallback(termsInteractionCallback);
fetchMock.get("https://test.com/api4/account?scalar_token=testtoken1&v=1.1", {
body: { errcode: "M_TERMS_NOT_SIGNED" },
});
sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken1"));
mocked(client.getTerms).mockResolvedValue({ policies: [] });
await expect(sac.registerForToken()).resolves.toBe("testtoken1");
});
it("should throw upon non-20x code", async () => {
const sac = new ScalarAuthClient(apiUrl + 5, uiUrl);
fetchMock.get("https://test.com/api5/account?scalar_token=testtoken2&v=1.1", {
body: { errcode: "SERVER_IS_SAD" },
status: 500,
});
sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken2"));
await expect(sac.registerForToken()).rejects.toBeTruthy();
});
it("should throw if user_id is missing from response", async () => {
const sac = new ScalarAuthClient(apiUrl + 6, uiUrl);
fetchMock.get("https://test.com/api6/account?scalar_token=testtoken3&v=1.1", {
body: {},
});
sac.exchangeForScalarToken = jest.fn(() => Promise.resolve("testtoken3"));
await expect(sac.registerForToken()).rejects.toThrow("Missing user_id in response");
});
});
describe("getScalarPageTitle", () => {
let sac: ScalarAuthClient;
beforeEach(async () => {
SdkConfig.put({
integrations_rest_url: apiUrl + 7,
integrations_ui_url: uiUrl,
});
window.localStorage.setItem("mx_scalar_token_at_https://test.com/api7", "wokentoken1");
fetchMock.get("https://test.com/api7/account?scalar_token=wokentoken1&v=1.1", {
body: { user_id: client.getUserId() },
});
sac = new ScalarAuthClient(apiUrl + 7, uiUrl);
await sac.connect();
});
it("should return `cached_title` from API /widgets/title_lookup", async () => {
const url = "google.com";
fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, {
body: {
page_title_cache_item: {
cached_title: "Google",
},
},
});
await expect(sac.getScalarPageTitle(url)).resolves.toBe("Google");
});
it("should throw upon non-20x code", async () => {
const url = "yahoo.com";
fetchMock.get("https://test.com/api7/widgets/title_lookup?scalar_token=wokentoken1&curl=" + url, {
status: 500,
});
await expect(sac.getScalarPageTitle(url)).rejects.toThrow("Scalar request failed: 500");
});
});
describe("disableWidgetAssets", () => {
let sac: ScalarAuthClient;
beforeEach(async () => {
SdkConfig.put({
integrations_rest_url: apiUrl + 8,
integrations_ui_url: uiUrl,
});
window.localStorage.setItem("mx_scalar_token_at_https://test.com/api8", "wokentoken1");
fetchMock.get("https://test.com/api8/account?scalar_token=wokentoken1&v=1.1", {
body: { user_id: client.getUserId() },
});
sac = new ScalarAuthClient(apiUrl + 8, uiUrl);
await sac.connect();
});
it("should send state=disable to API /widgets/set_assets_state", async () => {
fetchMock.get("https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" +
"&widget_type=m.custom&widget_id=id1&state=disable", {
body: "OK",
});
await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id1")).resolves.toBeUndefined();
});
it("should throw upon non-20x code", async () => {
fetchMock.get("https://test.com/api8/widgets/set_assets_state?scalar_token=wokentoken1" +
"&widget_type=m.custom&widget_id=id2&state=disable", {
status: 500,
});
await expect(sac.disableWidgetAssets(WidgetType.CUSTOM, "id2"))
.rejects.toThrow("Scalar request failed: 500");
});
});
}); });

41
test/SdkConfig-test.ts Normal file
View file

@ -0,0 +1,41 @@
/*
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 SdkConfig, { DEFAULTS } from "../src/SdkConfig";
describe("SdkConfig", () => {
describe("with default values", () => {
it("should return the default config", () => {
expect(SdkConfig.get()).toEqual(DEFAULTS);
});
});
describe("with custom values", () => {
beforeEach(() => {
SdkConfig.put({
voice_broadcast: {
chunk_length: 1337,
},
});
});
it("should return the custom config", () => {
const customConfig = JSON.parse(JSON.stringify(DEFAULTS));
customConfig.voice_broadcast.chunk_length = 1337;
expect(SdkConfig.get()).toEqual(customConfig);
});
});
});

View file

@ -0,0 +1,48 @@
/*
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 { viewUserDeviceSettings } from "../../../src/actions/handlers/viewUserDeviceSettings";
import { UserTab } from "../../../src/components/views/dialogs/UserTab";
import { Action } from "../../../src/dispatcher/actions";
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
describe('viewUserDeviceSettings()', () => {
const dispatchSpy = jest.spyOn(defaultDispatcher, 'dispatch');
beforeEach(() => {
dispatchSpy.mockClear();
});
it('dispatches action to view new session manager when enabled', () => {
const isNewDeviceManagerEnabled = true;
viewUserDeviceSettings(isNewDeviceManagerEnabled);
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.SessionManager,
});
});
it('dispatches action to view old session manager when disabled', () => {
const isNewDeviceManagerEnabled = false;
viewUserDeviceSettings(isNewDeviceManagerEnabled);
expect(dispatchSpy).toHaveBeenCalledWith({
action: Action.ViewUserSettings,
initialTabId: UserTab.Security,
});
});
});

View file

@ -15,7 +15,7 @@ limitations under the License.
*/ */
import { mocked } from "jest-mock"; import { mocked } from "jest-mock";
import { IAbortablePromise, IEncryptedFile, IUploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix"; import { IEncryptedFile, UploadOpts, MatrixClient } from "matrix-js-sdk/src/matrix";
import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording"; import { createVoiceMessageRecording, VoiceMessageRecording } from "../../src/audio/VoiceMessageRecording";
import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording"; import { RecordingState, VoiceRecording } from "../../src/audio/VoiceRecording";
@ -161,8 +161,8 @@ describe("VoiceMessageRecording", () => {
matrixClient: MatrixClient, matrixClient: MatrixClient,
roomId: string, roomId: string,
file: File | Blob, file: File | Blob,
_progressHandler?: IUploadOpts["progressHandler"], _progressHandler?: UploadOpts["progressHandler"],
): IAbortablePromise<{ url?: string, file?: IEncryptedFile }> => { ): Promise<{ url?: string, file?: IEncryptedFile }> => {
uploadFileClient = matrixClient; uploadFileClient = matrixClient;
uploadFileRoomId = roomId; uploadFileRoomId = roomId;
uploadBlob = file; uploadBlob = file;

View file

@ -0,0 +1,58 @@
/*
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 fetchMock from "fetch-mock-jest";
import { render, screen } from "@testing-library/react";
import { mocked } from "jest-mock";
import { _t } from "../../../../src/languageHandler";
import EmbeddedPage from "../../../../src/components/structures/EmbeddedPage";
jest.mock("../../../../src/languageHandler", () => ({
_t: jest.fn(),
}));
describe("<EmbeddedPage />", () => {
it("should translate _t strings", async () => {
mocked(_t).mockReturnValue("Przeglądaj pokoje");
fetchMock.get("https://home.page", {
body: '<h1>_t("Explore rooms")</h1>',
});
const { asFragment } = render(<EmbeddedPage url="https://home.page" />);
await screen.findByText("Przeglądaj pokoje");
expect(_t).toHaveBeenCalledWith("Explore rooms");
expect(asFragment()).toMatchSnapshot();
});
it("should show error if unable to load", async () => {
mocked(_t).mockReturnValue("Couldn't load page");
fetchMock.get("https://other.page", {
status: 404,
});
const { asFragment } = render(<EmbeddedPage url="https://other.page" />);
await screen.findByText("Couldn't load page");
expect(_t).toHaveBeenCalledWith("Couldn't load page");
expect(asFragment()).toMatchSnapshot();
});
it("should render nothing if no url given", () => {
const { asFragment } = render(<EmbeddedPage />);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -0,0 +1,43 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<EmbeddedPage /> should render nothing if no url given 1`] = `
<DocumentFragment>
<div
class="undefined undefined_guest"
>
<div
class="undefined_body"
/>
</div>
</DocumentFragment>
`;
exports[`<EmbeddedPage /> should show error if unable to load 1`] = `
<DocumentFragment>
<div
class="undefined undefined_guest"
>
<div
class="undefined_body"
>
Couldn't load page
</div>
</div>
</DocumentFragment>
`;
exports[`<EmbeddedPage /> should translate _t strings 1`] = `
<DocumentFragment>
<div
class="undefined undefined_guest"
>
<div
class="undefined_body"
>
<h1>
Przeglądaj pokoje
</h1>
</div>
</div>
</DocumentFragment>
`;

View file

@ -0,0 +1,104 @@
/*
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 fetchMock from "fetch-mock-jest";
import { render, screen, waitForElementToBeRemoved } from "@testing-library/react";
import ChangelogDialog from "../../../../src/components/views/dialogs/ChangelogDialog";
describe("<ChangelogDialog />", () => {
it("should fetch github proxy url for each repo with old and new version strings", async () => {
const webUrl = "https://riot.im/github/repos/vector-im/element-web/compare/oldsha1...newsha1";
fetchMock.get(webUrl, {
url: "https://api.github.com/repos/vector-im/element-web/compare/master...develop",
html_url: "https://github.com/vector-im/element-web/compare/master...develop",
permalink_url: "https://github.com/vector-im/element-web/compare/vector-im:72ca95e...vector-im:8891698",
diff_url: "https://github.com/vector-im/element-web/compare/master...develop.diff",
patch_url: "https://github.com/vector-im/element-web/compare/master...develop.patch",
base_commit: {},
merge_base_commit: {},
status: "ahead",
ahead_by: 24,
behind_by: 0,
total_commits: 24,
commits: [{
sha: "commit-sha",
html_url: "https://api.github.com/repos/vector-im/element-web/commit/commit-sha",
commit: { message: "This is the first commit message" },
}],
files: [],
});
const reactUrl = "https://riot.im/github/repos/matrix-org/matrix-react-sdk/compare/oldsha2...newsha2";
fetchMock.get(reactUrl, {
url: "https://api.github.com/repos/matrix-org/matrix-react-sdk/compare/master...develop",
html_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop",
permalink_url: "https://github.com/matrix-org/matrix-react-sdk/compare/matrix-org:cdb00...matrix-org:4a926",
diff_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop.diff",
patch_url: "https://github.com/matrix-org/matrix-react-sdk/compare/master...develop.patch",
base_commit: {},
merge_base_commit: {},
status: "ahead",
ahead_by: 83,
behind_by: 0,
total_commits: 83,
commits: [{
sha: "commit-sha0",
html_url: "https://api.github.com/repos/matrix-org/matrix-react-sdk/commit/commit-sha",
commit: { message: "This is a commit message" },
}],
files: [],
});
const jsUrl = "https://riot.im/github/repos/matrix-org/matrix-js-sdk/compare/oldsha3...newsha3";
fetchMock.get(jsUrl, {
url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/compare/master...develop",
html_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop",
permalink_url: "https://github.com/matrix-org/matrix-js-sdk/compare/matrix-org:6166a8f...matrix-org:fec350",
diff_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop.diff",
patch_url: "https://github.com/matrix-org/matrix-js-sdk/compare/master...develop.patch",
base_commit: {},
merge_base_commit: {},
status: "ahead",
ahead_by: 48,
behind_by: 0,
total_commits: 48,
commits: [{
sha: "commit-sha1",
html_url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha1",
commit: { message: "This is a commit message" },
}, {
sha: "commit-sha2",
html_url: "https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha2",
commit: { message: "This is another commit message" },
}],
files: [],
});
const newVersion = "newsha1-react-newsha2-js-newsha3";
const oldVersion = "oldsha1-react-oldsha2-js-oldsha3";
const { asFragment } = render((
<ChangelogDialog newVersion={newVersion} version={oldVersion} onFinished={jest.fn()} />
));
// Wait for spinners to go away
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
expect(fetchMock).toHaveFetched(webUrl);
expect(fetchMock).toHaveFetched(reactUrl);
expect(fetchMock).toHaveFetched(jsUrl);
expect(asFragment()).toMatchSnapshot();
});
});

View file

@ -26,6 +26,11 @@ import SdkConfig from "../../../../src/SdkConfig";
import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig"; import { ValidatedServerConfig } from "../../../../src/utils/ValidatedServerConfig";
import { IConfigOptions } from "../../../../src/IConfigOptions"; import { IConfigOptions } from "../../../../src/IConfigOptions";
const mockGetAccessToken = jest.fn().mockResolvedValue("getAccessToken");
jest.mock("../../../../src/IdentityAuthClient", () => jest.fn().mockImplementation(() => ({
getAccessToken: mockGetAccessToken,
})));
describe("InviteDialog", () => { describe("InviteDialog", () => {
const roomId = "!111111111111111111:example.org"; const roomId = "!111111111111111111:example.org";
const aliceId = "@alice:example.org"; const aliceId = "@alice:example.org";
@ -42,6 +47,14 @@ describe("InviteDialog", () => {
getProfileInfo: jest.fn().mockRejectedValue({ errcode: "" }), getProfileInfo: jest.fn().mockRejectedValue({ errcode: "" }),
getIdentityServerUrl: jest.fn(), getIdentityServerUrl: jest.fn(),
searchUserDirectory: jest.fn().mockResolvedValue({}), searchUserDirectory: jest.fn().mockResolvedValue({}),
lookupThreePid: jest.fn(),
registerWithIdentityServer: jest.fn().mockResolvedValue({
access_token: "access_token",
token: "token",
}),
getOpenIdToken: jest.fn().mockResolvedValue({}),
getIdentityAccount: jest.fn().mockResolvedValue({}),
getTerms: jest.fn().mockResolvedValue({ policies: [] }),
}); });
beforeEach(() => { beforeEach(() => {
@ -85,7 +98,7 @@ describe("InviteDialog", () => {
expect(screen.queryByText("Invite to Room")).toBeTruthy(); expect(screen.queryByText("Invite to Room")).toBeTruthy();
}); });
it("should suggest valid MXIDs even if unknown", () => { it("should suggest valid MXIDs even if unknown", async () => {
render(( render((
<InviteDialog <InviteDialog
kind={KIND_INVITE} kind={KIND_INVITE}
@ -95,7 +108,7 @@ describe("InviteDialog", () => {
/> />
)); ));
expect(screen.queryByText("@localpart:server.tld")).toBeFalsy(); await screen.findAllByText("@localpart:server.tld"); // Using findAllByText as the MXID is used for name too
}); });
it("should not suggest invalid MXIDs", () => { it("should not suggest invalid MXIDs", () => {
@ -110,4 +123,48 @@ describe("InviteDialog", () => {
expect(screen.queryByText("@localpart:server:tld")).toBeFalsy(); expect(screen.queryByText("@localpart:server:tld")).toBeFalsy();
}); });
it("should lookup inputs which look like email addresses", async () => {
mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server");
mockClient.lookupThreePid.mockResolvedValue({
address: "foobar@email.com",
medium: "email",
mxid: "@foobar:server",
});
mockClient.getProfileInfo.mockResolvedValue({
displayname: "Mr. Foo",
avatar_url: "mxc://foo/bar",
});
render((
<InviteDialog
kind={KIND_INVITE}
roomId={roomId}
onFinished={jest.fn()}
initialText="foobar@email.com"
/>
));
await screen.findByText("Mr. Foo");
await screen.findByText("@foobar:server");
expect(mockClient.lookupThreePid).toHaveBeenCalledWith("email", "foobar@email.com", expect.anything());
expect(mockClient.getProfileInfo).toHaveBeenCalledWith("@foobar:server");
});
it("should suggest e-mail even if lookup fails", async () => {
mockClient.getIdentityServerUrl.mockReturnValue("https://identity-server");
mockClient.lookupThreePid.mockResolvedValue({});
render((
<InviteDialog
kind={KIND_INVITE}
roomId={roomId}
onFinished={jest.fn()}
initialText="foobar@email.com"
/>
));
await screen.findByText("foobar@email.com");
await screen.findByText("Invite by email");
});
}); });

View file

@ -0,0 +1,135 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`<ChangelogDialog /> should fetch github proxy url for each repo with old and new version strings 1`] = `
<DocumentFragment>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
<div
aria-describedby="mx_Dialog_content"
aria-labelledby="mx_BaseDialog_title"
class="mx_QuestionDialog mx_Dialog_fixedWidth"
data-focus-lock-disabled="false"
role="dialog"
>
<div
class="mx_Dialog_header mx_Dialog_headerWithCancel"
>
<h2
class="mx_Heading_h2 mx_Dialog_title"
id="mx_BaseDialog_title"
>
Changelog
</h2>
<div
aria-label="Close dialog"
class="mx_AccessibleButton mx_Dialog_cancelButton"
role="button"
tabindex="0"
/>
</div>
<div
class="mx_Dialog_content"
id="mx_Dialog_content"
>
<div
class="mx_ChangelogDialog_content"
>
<div>
<h2>
vector-im/element-web
</h2>
<ul>
<li
class="mx_ChangelogDialog_li"
>
<a
href="https://api.github.com/repos/vector-im/element-web/commit/commit-sha"
rel="noreferrer noopener"
target="_blank"
>
This is the first commit message
</a>
</li>
</ul>
</div>
<div>
<h2>
matrix-org/matrix-react-sdk
</h2>
<ul>
<li
class="mx_ChangelogDialog_li"
>
<a
href="https://api.github.com/repos/matrix-org/matrix-react-sdk/commit/commit-sha"
rel="noreferrer noopener"
target="_blank"
>
This is a commit message
</a>
</li>
</ul>
</div>
<div>
<h2>
matrix-org/matrix-js-sdk
</h2>
<ul>
<li
class="mx_ChangelogDialog_li"
>
<a
href="https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha1"
rel="noreferrer noopener"
target="_blank"
>
This is a commit message
</a>
</li>
<li
class="mx_ChangelogDialog_li"
>
<a
href="https://api.github.com/repos/matrix-org/matrix-js-sdk/commit/commit-sha2"
rel="noreferrer noopener"
target="_blank"
>
This is another commit message
</a>
</li>
</ul>
</div>
</div>
</div>
<div
class="mx_Dialog_buttons"
>
<span
class="mx_Dialog_buttons_row"
>
<button
data-test-id="dialog-cancel-button"
type="button"
>
Cancel
</button>
<button
class="mx_Dialog_primary"
data-test-id="dialog-primary-button"
type="button"
>
Update
</button>
</span>
</div>
</div>
<div
data-focus-guard="true"
style="width: 1px; height: 0px; padding: 0px; overflow: hidden; position: fixed; top: 1px; left: 1px;"
tabindex="0"
/>
</DocumentFragment>
`;

View file

@ -0,0 +1,133 @@
/*
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 { render, RenderResult } from "@testing-library/react";
import { mocked } from "jest-mock";
import { MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { Features } from "../../../../src/settings/Settings";
import SettingsStore, { CallbackFn } from "../../../../src/settings/SettingsStore";
import { VoiceBroadcastInfoEventType, VoiceBroadcastInfoState } from "../../../../src/voice-broadcast";
import { mkEvent, mkRoom, stubClient } from "../../../test-utils";
import MessageEvent from "../../../../src/components/views/messages/MessageEvent";
import { RoomPermalinkCreator } from "../../../../src/utils/permalinks/Permalinks";
jest.mock("../../../../src/components/views/messages/UnknownBody", () => ({
__esModule: true,
default: () => (<div data-testid="unknown-body" />),
}));
jest.mock("../../../../src/voice-broadcast/components/VoiceBroadcastBody", () => ({
VoiceBroadcastBody: () => (<div data-testid="voice-broadcast-body" />),
}));
describe("MessageEvent", () => {
let room: Room;
let client: MatrixClient;
let event: MatrixEvent;
const renderMessageEvent = (): RenderResult => {
return render(<MessageEvent
mxEvent={event}
onHeightChanged={jest.fn()}
permalinkCreator={new RoomPermalinkCreator(room)}
/>);
};
beforeEach(() => {
client = stubClient();
room = mkRoom(client, "!room:example.com");
jest.spyOn(SettingsStore, "getValue");
jest.spyOn(SettingsStore, "watchSetting");
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
});
describe("when a voice broadcast start event occurs", () => {
const voiceBroadcastSettingWatcherRef = "vb ref";
let onVoiceBroadcastSettingChanged: CallbackFn;
beforeEach(() => {
event = mkEvent({
event: true,
type: VoiceBroadcastInfoEventType,
user: client.getUserId(),
room: room.roomId,
content: {
state: VoiceBroadcastInfoState.Started,
},
});
mocked(SettingsStore.watchSetting).mockImplementation(
(settingName: string, roomId: string | null, callbackFn: CallbackFn) => {
if (settingName === Features.VoiceBroadcast) {
onVoiceBroadcastSettingChanged = callbackFn;
return voiceBroadcastSettingWatcherRef;
}
},
);
});
describe("and the voice broadcast feature is enabled", () => {
let result: RenderResult;
beforeEach(() => {
mocked(SettingsStore.getValue).mockImplementation((settingName: string) => {
return settingName === Features.VoiceBroadcast;
});
result = renderMessageEvent();
});
it("should render a VoiceBroadcast component", () => {
result.getByTestId("voice-broadcast-body");
});
describe("and switching the voice broadcast feature off", () => {
beforeEach(() => {
onVoiceBroadcastSettingChanged(Features.VoiceBroadcast, null, null, null, false);
});
it("should render an UnknownBody component", () => {
const result = renderMessageEvent();
result.getByTestId("unknown-body");
});
});
describe("and unmounted", () => {
beforeEach(() => {
result.unmount();
});
it("should unregister the settings watcher", () => {
expect(SettingsStore.unwatchSetting).toHaveBeenCalled();
});
});
});
describe("and the voice broadcast feature is disabled", () => {
beforeEach(() => {
mocked(SettingsStore.getValue).mockImplementation((settingName: string) => {
return false;
});
});
it("should render an UnknownBody component", () => {
const result = renderMessageEvent();
result.getByTestId("unknown-body");
});
});
});
});

View file

@ -364,7 +364,7 @@ describe('<RoomPreviewBar />', () => {
expect(getMessage(component)).toMatchSnapshot(); expect(getMessage(component)).toMatchSnapshot();
expect(MatrixClientPeg.get().lookupThreePid).toHaveBeenCalledWith( expect(MatrixClientPeg.get().lookupThreePid).toHaveBeenCalledWith(
'email', invitedEmail, undefined, 'mock-token', 'email', invitedEmail, 'mock-token',
); );
await testJoinButton({ inviterName, invitedEmail })(); await testJoinButton({ inviterName, invitedEmail })();
}); });

View file

@ -120,11 +120,29 @@ exports[`<CurrentDeviceSection /> handles when device is falsy 1`] = `
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
data-testid="current-session-section" data-testid="current-session-section"
> >
<h3 <div
class="mx_Heading_h3 mx_SettingsSubsection_heading" class="mx_SettingsSubsectionHeading"
> >
Current session <h3
</h3> class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Current session
</h3>
<div
aria-disabled="true"
aria-expanded="false"
aria-haspopup="true"
class="mx_AccessibleButton mx_AccessibleButton_disabled"
data-testid="current-session-menu"
disabled=""
role="button"
tabindex="0"
>
<div
class="mx_KebabContextMenu_icon"
/>
</div>
</div>
<div <div
class="mx_SettingsSubsection_content" class="mx_SettingsSubsection_content"
/> />
@ -138,11 +156,27 @@ exports[`<CurrentDeviceSection /> renders device and correct security card when
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
data-testid="current-session-section" data-testid="current-session-section"
> >
<h3 <div
class="mx_Heading_h3 mx_SettingsSubsection_heading" class="mx_SettingsSubsectionHeading"
> >
Current session <h3
</h3> class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Current session
</h3>
<div
aria-expanded="false"
aria-haspopup="true"
class="mx_AccessibleButton"
data-testid="current-session-menu"
role="button"
tabindex="0"
>
<div
class="mx_KebabContextMenu_icon"
/>
</div>
</div>
<div <div
class="mx_SettingsSubsection_content" class="mx_SettingsSubsection_content"
> >
@ -258,11 +292,27 @@ exports[`<CurrentDeviceSection /> renders device and correct security card when
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
data-testid="current-session-section" data-testid="current-session-section"
> >
<h3 <div
class="mx_Heading_h3 mx_SettingsSubsection_heading" class="mx_SettingsSubsectionHeading"
> >
Current session <h3
</h3> class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Current session
</h3>
<div
aria-expanded="false"
aria-haspopup="true"
class="mx_AccessibleButton"
data-testid="current-session-menu"
role="button"
tabindex="0"
>
<div
class="mx_KebabContextMenu_icon"
/>
</div>
</div>
<div <div
class="mx_SettingsSubsection_content" class="mx_SettingsSubsection_content"
> >

View file

@ -6,11 +6,15 @@ exports[`<SecurityRecommendations /> renders both cards when user has both unver
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
data-testid="security-recommendations-section" data-testid="security-recommendations-section"
> >
<h3 <div
class="mx_Heading_h3 mx_SettingsSubsection_heading" class="mx_SettingsSubsectionHeading"
> >
Security recommendations <h3
</h3> class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Security recommendations
</h3>
</div>
<div <div
class="mx_SettingsSubsection_description" class="mx_SettingsSubsection_description"
> >
@ -109,11 +113,15 @@ exports[`<SecurityRecommendations /> renders inactive devices section when user
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
data-testid="security-recommendations-section" data-testid="security-recommendations-section"
> >
<h3 <div
class="mx_Heading_h3 mx_SettingsSubsection_heading" class="mx_SettingsSubsectionHeading"
> >
Security recommendations <h3
</h3> class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Security recommendations
</h3>
</div>
<div <div
class="mx_SettingsSubsection_description" class="mx_SettingsSubsection_description"
> >
@ -212,11 +220,15 @@ exports[`<SecurityRecommendations /> renders unverified devices section when use
class="mx_SettingsSubsection" class="mx_SettingsSubsection"
data-testid="security-recommendations-section" data-testid="security-recommendations-section"
> >
<h3 <div
class="mx_Heading_h3 mx_SettingsSubsection_heading" class="mx_SettingsSubsectionHeading"
> >
Security recommendations <h3
</h3> class="mx_Heading_h3 mx_SettingsSubsectionHeading_heading"
>
Security recommendations
</h3>
</div>
<div <div
class="mx_SettingsSubsection_description" class="mx_SettingsSubsection_description"
> >

View file

@ -27,6 +27,17 @@ describe('<SettingsSubsection />', () => {
const getComponent = (props = {}): React.ReactElement => const getComponent = (props = {}): React.ReactElement =>
(<SettingsSubsection {...defaultProps} {...props} />); (<SettingsSubsection {...defaultProps} {...props} />);
it('renders with plain text heading', () => {
const { container } = render(getComponent());
expect(container).toMatchSnapshot();
});
it('renders with react element heading', () => {
const heading = <h3>This is the heading</h3>;
const { container } = render(getComponent({ heading }));
expect(container).toMatchSnapshot();
});
it('renders without description', () => { it('renders without description', () => {
const { container } = render(getComponent()); const { container } = render(getComponent());
expect(container).toMatchSnapshot(); expect(container).toMatchSnapshot();

View file

@ -0,0 +1,41 @@
/*
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 { render } from '@testing-library/react';
import React from 'react';
import {
SettingsSubsectionHeading,
} from '../../../../../src/components/views/settings/shared/SettingsSubsectionHeading';
describe('<SettingsSubsectionHeading />', () => {
const defaultProps = {
heading: 'test',
};
const getComponent = (props = {}) =>
render(<SettingsSubsectionHeading {...defaultProps} {...props} />);
it('renders without children', () => {
const { container } = getComponent();
expect({ container }).toMatchSnapshot();
});
it('renders with children', () => {
const children = <a href='/#'>test</a>;
const { container } = getComponent({ children });
expect({ container }).toMatchSnapshot();
});
});

Some files were not shown because too many files have changed in this diff Show more