Merge remote-tracking branch 'origin/develop' into feat/add-formating-buttons-to-wysiwyg
This commit is contained in:
commit
f85f53248b
128 changed files with 4303 additions and 1024 deletions
35
CHANGELOG.md
35
CHANGELOG.md
|
@ -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)
|
||||||
=====================================================================================================
|
=====================================================================================================
|
||||||
|
|
||||||
|
|
|
@ -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 }, "");
|
|
||||||
}
|
|
||||||
});
|
|
117
cypress/e2e/settings/device-management.spec.ts
Normal file
117
cypress/e2e/settings/device-management.spec.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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(),
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -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,
|
||||||
}));
|
}));
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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";
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
|
|
@ -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%;
|
||||||
|
}
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
44
res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss
Normal file
44
res/css/voice-broadcast/atoms/_VoiceBroadcastHeader.pcss
Normal 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;
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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],
|
||||||
|
|
|
@ -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,
|
||||||
|
});
|
||||||
|
};
|
|
@ -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");
|
||||||
|
|
|
@ -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 (
|
||||||
|
|
|
@ -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.
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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;
|
||||||
|
|
||||||
|
|
|
@ -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() {
|
||||||
|
|
|
@ -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"))
|
||||||
|
|
|
@ -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}
|
||||||
>
|
>
|
||||||
|
|
66
src/components/views/context_menus/KebabContextMenu.tsx
Normal file
66
src/components/views/context_menus/KebabContextMenu.tsx
Normal 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>) }
|
||||||
|
</>;
|
||||||
|
};
|
|
@ -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 });
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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) {
|
||||||
|
|
|
@ -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");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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);
|
||||||
}}
|
}}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 });
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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 /> }
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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>
|
||||||
|
);
|
|
@ -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>
|
||||||
|
|
|
@ -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 }
|
||||||
|
|
|
@ -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 &&
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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({
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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 = {
|
||||||
|
|
|
@ -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.
|
||||||
*/
|
*/
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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",
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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
53
src/models/RoomUpload.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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"),
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -449,7 +449,7 @@ export class StopGapWidgetDriver extends WidgetDriver {
|
||||||
from,
|
from,
|
||||||
to,
|
to,
|
||||||
limit,
|
limit,
|
||||||
direction: dir,
|
dir,
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
@ -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 => {
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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,
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -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');
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
141
src/voice-broadcast/audio/VoiceBroadcastRecorder.ts
Normal file
141
src/voice-broadcast/audio/VoiceBroadcastRecorder.ts
Normal 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);
|
||||||
|
};
|
|
@ -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}`}
|
|
||||||
/>;
|
/>;
|
||||||
};
|
};
|
||||||
|
|
|
@ -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>;
|
||||||
|
};
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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"]?: {
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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";
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
|
|
|
@ -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
41
test/SdkConfig-test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
48
test/actions/handlers/viewUserDeviceSettings-test.ts
Normal file
48
test/actions/handlers/viewUserDeviceSettings-test.ts
Normal 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,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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;
|
||||||
|
|
58
test/components/views/context_menus/EmbeddedPage-test.tsx
Normal file
58
test/components/views/context_menus/EmbeddedPage-test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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>
|
||||||
|
`;
|
104
test/components/views/dialogs/ChangelogDialog-test.tsx
Normal file
104
test/components/views/dialogs/ChangelogDialog-test.tsx
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
|
@ -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");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -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>
|
||||||
|
`;
|
133
test/components/views/messages/MessageEvent-test.tsx
Normal file
133
test/components/views/messages/MessageEvent-test.tsx
Normal 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");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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 })();
|
||||||
});
|
});
|
||||||
|
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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"
|
||||||
>
|
>
|
||||||
|
|
|
@ -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();
|
||||||
|
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue