1
0
mirror of https://github.com/matrix-org/matrix-react-sdk.git synced 2025-09-03 07:02:05 +03:00

Merge branch 'develop' into florianduros/fix/spotlight-click

This commit is contained in:
Florian Duros
2024-08-26 11:08:58 +02:00
committed by GitHub
128 changed files with 5064 additions and 3371 deletions

View File

@@ -1,3 +1,37 @@
Changes in [3.107.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.107.0) (2024-08-20)
=======================================================================================================
* No changes
Changes in [3.106.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.106.0) (2024-08-13)
=======================================================================================================
## ✨ Features
* Invite dialog: display MXID on its own line ([#11756](https://github.com/matrix-org/matrix-react-sdk/pull/11756)). Contributed by @AndrewFerr.
* Align RoomSummaryCard styles with Figma ([#12793](https://github.com/matrix-org/matrix-react-sdk/pull/12793)). Contributed by @t3chguy.
* Extract Extensions into their own right panel tab ([#12844](https://github.com/matrix-org/matrix-react-sdk/pull/12844)). Contributed by @t3chguy.
* Remove topic from new room header and expand right panel topic ([#12842](https://github.com/matrix-org/matrix-react-sdk/pull/12842)). Contributed by @t3chguy.
* Rework how the onboarding notifications task works ([#12839](https://github.com/matrix-org/matrix-react-sdk/pull/12839)). Contributed by @t3chguy.
* Update toast styles to match Figma ([#12833](https://github.com/matrix-org/matrix-react-sdk/pull/12833)). Contributed by @t3chguy.
* Warn users on unsupported browsers before they lack features ([#12830](https://github.com/matrix-org/matrix-react-sdk/pull/12830)). Contributed by @t3chguy.
* Add sign out button to settings profile section ([#12666](https://github.com/matrix-org/matrix-react-sdk/pull/12666)). Contributed by @dbkr.
* Remove MatrixRTC realted import ES lint exceptions using a index.ts for matrixrtc ([#12780](https://github.com/matrix-org/matrix-react-sdk/pull/12780)). Contributed by @toger5.
* Fix unwanted ringing of other devices even though the user is already connected to the call. ([#12742](https://github.com/matrix-org/matrix-react-sdk/pull/12742)). Contributed by @toger5.
* Acknowledge `DeviceMute` widget actions ([#12790](https://github.com/matrix-org/matrix-react-sdk/pull/12790)). Contributed by @toger5.
## 🐛 Bug Fixes
* Fix formatting of rich text emotes ([#12862](https://github.com/matrix-org/matrix-react-sdk/pull/12862)). Contributed by @dbkr.
* Fixed custom emotes background color #27745 ([#12798](https://github.com/matrix-org/matrix-react-sdk/pull/12798)). Contributed by @asimdelvi.
* Ignore permalink\_prefix when serializing pills ([#11726](https://github.com/matrix-org/matrix-react-sdk/pull/11726)). Contributed by @herkulessi.
* Deflake the chat export test ([#12854](https://github.com/matrix-org/matrix-react-sdk/pull/12854)). Contributed by @dbkr.
* Fix alignment of RTL messages ([#12837](https://github.com/matrix-org/matrix-react-sdk/pull/12837)). Contributed by @dbkr.
* Handle media download errors better ([#12848](https://github.com/matrix-org/matrix-react-sdk/pull/12848)). Contributed by @t3chguy.
* Make micIcon display on primary ([#11908](https://github.com/matrix-org/matrix-react-sdk/pull/11908)). Contributed by @kdanielm.
* Fix compound typography font component issues ([#12826](https://github.com/matrix-org/matrix-react-sdk/pull/12826)). Contributed by @t3chguy.
* Allow Chrome page translator to translate messages in rooms ([#11113](https://github.com/matrix-org/matrix-react-sdk/pull/11113)). Contributed by @lukaszpolowczyk.
Changes in [3.105.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.105.1) (2024-08-06)
=======================================================================================================
Fixes for CVE-2024-42347 / GHSA-f83w-wqhc-cfp4

View File

@@ -1,6 +1,6 @@
{
"name": "matrix-react-sdk",
"version": "3.105.1",
"version": "3.107.0",
"description": "SDK for matrix.org using React",
"author": "matrix.org",
"repository": {
@@ -26,10 +26,8 @@
"engines": {
"node": ">=20.0.0"
},
"main": "./src/index.ts",
"matrix_src_main": "./src/index.ts",
"matrix_lib_main": "./lib/index.ts",
"matrix_lib_typings": "./lib/index.d.ts",
"main": "./lib/index.ts",
"typings": "./lib/index.d.ts",
"matrix_i18n_extra_translation_funcs": [
"UserFriendlyError"
],
@@ -82,7 +80,7 @@
"@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0",
"@testing-library/react-hooks": "^8.0.1",
"@vector-im/compound-design-tokens": "^1.6.1",
"@vector-im/compound-design-tokens": "^1.8.0",
"@vector-im/compound-web": "^5.5.0",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",
@@ -191,7 +189,7 @@
"@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "17.0.25",
"@types/react-transition-group": "^4.4.0",
"@types/sanitize-html": "2.11.0",
"@types/sanitize-html": "2.13.0",
"@types/sdp-transform": "^2.4.6",
"@types/seedrandom": "3.0.8",
"@types/tar-js": "^0.3.2",
@@ -199,7 +197,7 @@
"@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0",
"axe-core": "4.9.1",
"axe-core": "4.10.0",
"babel-jest": "^29.0.0",
"blob-polyfill": "^9.0.0",
"eslint": "8.57.0",
@@ -212,13 +210,13 @@
"eslint-plugin-matrix-org": "1.2.1",
"eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^54.0.0",
"eslint-plugin-unicorn": "^55.0.0",
"express": "^4.18.2",
"fake-indexeddb": "^6.0.0",
"fetch-mock-jest": "^1.5.1",
"fs-extra": "^11.0.0",
"glob": "^11.0.0",
"husky": "^8.0.3",
"husky": "^9.0.0",
"jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.6.2",

View File

@@ -249,5 +249,110 @@ test.describe("Composer", () => {
);
});
});
test.describe("Drafts", () => {
test("drafts with rich and plain text", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Type some formatted text
await composer.pressSequentially("my ");
await composer.press(`${CtrlOrMeta}+KeyB`);
await composer.pressSequentially("bold");
// Change to plain text mode
await page.getByRole("button", { name: "Hide formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// assert the markdown
await expect(page.locator("div[contenteditable=true]", { hasText: "my __bold__" })).toBeVisible();
// Change to plain text mode and assert the markdown
await page.getByRole("button", { name: "Show formatting" }).click();
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Send the message and assert the message
await page.getByRole("button", { name: "Send message" }).click();
await expect(page.locator(".mx_EventTile_last .mx_EventTile_body").getByText("my bold")).toBeVisible();
});
test("draft with replies", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply", exact: true }).click();
// Type reply text
await composer.pressSequentially("my reply");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert reply mode and reply text
await expect(page.getByText("Replying")).toBeVisible();
await expect(page.locator("div[contenteditable=true]", { hasText: "my reply" })).toBeVisible();
});
test("draft in threads", async ({ page, app }) => {
// Set up a second room to swtich to, to test drafts
const firstRoomname = "Composing Room";
const secondRoomname = "Second Composing Room";
await app.client.createRoom({ name: secondRoomname });
// Composer is visible
const composer = page.locator("div[contenteditable=true]");
await expect(composer).toBeVisible();
// Send a message
await composer.pressSequentially("my first message");
await page.getByRole("button", { name: "Send message" }).click();
// Click reply
const tile = page.locator(".mx_EventTile_last");
await tile.hover();
await tile.getByRole("button", { name: "Reply in thread" }).click();
const thread = page.locator(".mx_ThreadView");
const threadComposer = thread.locator("div[contenteditable=true]");
// Type threaded text
await threadComposer.pressSequentially("my threaded message");
// Change to another room and back again
await app.viewRoomByName(secondRoomname);
await app.viewRoomByName(firstRoomname);
// Assert threaded draft
await expect(
thread.locator("div[contenteditable=true]", { hasText: "my threaded message" }),
).toBeVisible();
});
});
});
});

View File

@@ -0,0 +1,248 @@
/*
* Copyright 2024 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 { Page } from "@playwright/test";
import { test as base, expect } from "../../element-web-test";
import { Client } from "../../pages/client";
import { ElementAppPage } from "../../pages/ElementAppPage";
import { Bot } from "../../pages/bot";
/**
* Set up for pinned message tests.
*/
export const test = base.extend<{
room1Name?: string;
room1: { name: string; roomId: string };
util: Helpers;
}>({
displayName: "Alice",
botCreateOpts: { displayName: "Other User" },
room1Name: "Room 1",
room1: async ({ room1Name: name, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name, invite: [bot.credentials.userId] });
await use({ name, roomId });
},
util: async ({ page, app, bot }, use) => {
await use(new Helpers(page, app, bot));
},
});
export class Helpers {
constructor(
private page: Page,
private app: ElementAppPage,
private bot: Bot,
) {}
/**
* Sends messages into given room as a bot
* @param room - the name of the room to send messages into
* @param messages - the list of messages to send, these can be strings or implementations of MessageSpec like `editOf`
*/
async receiveMessages(room: string | { name: string }, messages: string[]) {
await this.sendMessageAsClient(this.bot, room, messages);
}
/**
* Use the supplied client to send messages or perform actions as specified by
* the supplied {@link Message} items.
*/
private async sendMessageAsClient(cli: Client, roomName: string | { name: string }, messages: string[]) {
const room = await this.findRoomByName(typeof roomName === "string" ? roomName : roomName.name);
const roomId = await room.evaluate((room) => room.roomId);
for (const message of messages) {
await cli.sendMessage(roomId, { body: message, msgtype: "m.text" });
// TODO: without this wait, some tests that send lots of messages flake
// from time to time. I (andyb) have done some investigation, but it
// needs more work to figure out. The messages do arrive over sync, but
// they never appear in the timeline, and they never fire a
// Room.timeline event. I think this only happens with events that refer
// to other events (e.g. replies), so it might be caused by the
// referring event arriving before the referred-to event.
await this.page.waitForTimeout(100);
}
}
/**
* Find a room by its name
* @param roomName
* @private
*/
private async findRoomByName(roomName: string) {
return this.app.client.evaluateHandle((cli, roomName) => {
return cli.getRooms().find((r) => r.name === roomName);
}, roomName);
}
/**
* Open the room with the supplied name.
*/
async goTo(room: string | { name: string }) {
await this.app.viewRoomByName(typeof room === "string" ? room : room.name);
}
/**
* Pin the given message from the quick actions
* @param message
* @param unpin
*/
async pinMessageFromQuickActions(message: string, unpin = false) {
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
await timelineMessage.hover();
await this.page.getByRole("button", { name: unpin ? "Unpin" : "Pin", exact: true }).click();
}
/**
* Pin the given messages from the quick actions
* @param messages
* @param unpin
*/
async pinMessagesFromQuickActions(messages: string[], unpin = false) {
for (const message of messages) {
await this.pinMessageFromQuickActions(message, unpin);
}
}
/**
* Pin the given message from the contextual menu
* @param message
*/
async pinMessage(message: string) {
const timelineMessage = this.page.locator(".mx_MTextBody", { hasText: message });
await timelineMessage.click({ button: "right" });
await this.page.getByRole("menuitem", { name: "Pin", exact: true }).click();
}
/**
* Pin the given messages
* @param messages
*/
async pinMessages(messages: string[]) {
for (const message of messages) {
await this.pinMessage(message);
}
}
/**
* Open the room info panel
*/
async openRoomInfo() {
await this.page.getByRole("button", { name: "Room info" }).nth(1).click();
}
/**
* Assert that the pinned count in the room info is correct
* Open the room info and check the pinned count
* @param count
*/
async assertPinnedCountInRoomInfo(count: number) {
await expect(this.page.getByRole("menuitem", { name: "Pinned messages" })).toHaveText(
`Pinned messages${count}`,
);
}
/**
* Open the pinned messages list
*/
async openPinnedMessagesList() {
await this.page.getByRole("menuitem", { name: "Pinned messages" }).click();
}
/**
* Return the right panel
* @private
*/
private getRightPanel() {
return this.page.locator("#mx_RightPanel");
}
/**
* Assert that the pinned message list contains the given messages
* @param messages
*/
async assertPinnedMessagesList(messages: string[]) {
const rightPanel = this.getRightPanel();
await expect(rightPanel.getByRole("heading", { name: "Pinned messages" })).toHaveText(
`${messages.length} Pinned messages`,
);
await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-messages-${messages.length}.png`);
const list = rightPanel.getByRole("list");
await expect(list.getByRole("listitem")).toHaveCount(messages.length);
for (const message of messages) {
await expect(list.getByText(message)).toBeVisible();
}
}
/**
* Assert that the pinned message list is empty
*/
async assertEmptyPinnedMessagesList() {
const rightPanel = this.getRightPanel();
await expect(rightPanel).toMatchScreenshot(`pinned-messages-list-empty.png`);
}
/**
* Open the unpin all dialog
*/
async openUnpinAllDialog() {
await this.openRoomInfo();
await this.openPinnedMessagesList();
await this.page.getByRole("button", { name: "Unpin all" }).click();
}
/**
* Return the unpin all dialog
*/
getUnpinAllDialog() {
return this.page.locator(".mx_Dialog", { hasText: "Unpin all messages?" });
}
/**
* Click on the Continue button of the unoin all dialog
*/
async confirmUnpinAllDialog() {
await this.getUnpinAllDialog().getByRole("button", { name: "Continue" }).click();
}
/**
* Go back from the pinned messages list
*/
async backPinnedMessagesList() {
await this.page.locator("#mx_RightPanel").getByTestId("base-card-back-button").click();
}
/**
* Open the contextual menu of a message in the pin message list and click on unpin
* @param message
*/
async unpinMessageFromMessageList(message: string) {
const item = this.getRightPanel().getByRole("list").getByRole("listitem").filter({
hasText: message,
});
await item.getByRole("button").click();
await this.page.getByRole("menu", { name: "Open menu" }).getByRole("menuitem", { name: "Unpin" }).click();
}
}
export { expect };

View File

@@ -0,0 +1,90 @@
/*
* Copyright 2024 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 { test } from "./index";
import { expect } from "../../element-web-test";
test.describe("Pinned messages", () => {
test.use({
labsFlags: ["feature_pinning"],
});
test("should show the empty state when there are no pinned messages", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.openRoomInfo();
await util.assertPinnedCountInRoomInfo(0);
await util.openPinnedMessagesList();
await util.assertEmptyPinnedMessagesList();
});
test("should pin messages and show them in the room info panel", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openRoomInfo();
await util.assertPinnedCountInRoomInfo(3);
});
test("should pin messages and show them in the pinned message panel", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
// Pin the messages
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openRoomInfo();
await util.openPinnedMessagesList();
await util.assertPinnedMessagesList(["Msg1", "Msg2", "Msg4"]);
});
test("should unpin one message", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openRoomInfo();
await util.openPinnedMessagesList();
await util.unpinMessageFromMessageList("Msg2");
await util.assertPinnedMessagesList(["Msg1", "Msg4"]);
await util.backPinnedMessagesList();
await util.assertPinnedCountInRoomInfo(2);
});
test("should unpin all messages", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessages(["Msg1", "Msg2", "Msg4"]);
await util.openUnpinAllDialog();
await expect(util.getUnpinAllDialog()).toMatchScreenshot("unpin-all-dialog.png");
await util.confirmUnpinAllDialog();
await util.assertEmptyPinnedMessagesList();
await util.backPinnedMessagesList();
await util.assertPinnedCountInRoomInfo(0);
});
test("should be able to pin and unpin from the quick actions", async ({ page, app, room1, util }) => {
await util.goTo(room1);
await util.receiveMessages(room1, ["Msg1", "Msg2", "Msg3", "Msg4"]);
await util.pinMessagesFromQuickActions(["Msg1"]);
await util.openRoomInfo();
await util.assertPinnedCountInRoomInfo(1);
await util.pinMessagesFromQuickActions(["Msg1"], true);
await util.assertPinnedCountInRoomInfo(0);
});
});

View File

@@ -25,5 +25,6 @@ test.describe("User Menu", () => {
await expect(menu.locator(".mx_UserMenu_contextMenu_displayName", { hasText: user.displayName })).toBeVisible();
await expect(menu.locator(".mx_UserMenu_contextMenu_userId", { hasText: user.userId })).toBeVisible();
await expect(menu).toMatchScreenshot("user-menu.png");
});
});

View File

@@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand";
// Docker tag to use for synapse docker image.
// We target a specific digest as every now and then a Synapse update will break our CI.
// This digest is updated by the playwright-image-updates.yaml workflow periodically.
const DOCKER_TAG = "develop@sha256:84be20dded1bd6bed7e5c5fbb30ce3b8fb40271d8558a3a81a8485b8960c911b";
const DOCKER_TAG = "develop@sha256:4c891449943a2e7413a47784f3319caabfa185ad2caf96959f2175df34047917";
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
const templateDir = path.join(__dirname, "templates", opts.template);

Binary file not shown.

Before

Width:  |  Height:  |  Size: 215 KiB

After

Width:  |  Height:  |  Size: 213 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 49 KiB

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 70 KiB

After

Width:  |  Height:  |  Size: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 48 KiB

After

Width:  |  Height:  |  Size: 50 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 61 KiB

After

Width:  |  Height:  |  Size: 62 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 54 KiB

After

Width:  |  Height:  |  Size: 56 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

After

Width:  |  Height:  |  Size: 113 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 19 KiB

View File

@@ -604,7 +604,7 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button),
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button),
.mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] {
@@ -624,14 +624,14 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):last-child {
margin-right: 0px;
}
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):focus,
.mx_Dialog input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus {
@@ -643,7 +643,7 @@ legend {
.mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button),
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button),
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest);
@@ -656,7 +656,7 @@ legend {
.mx_Dialog_buttons
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button
),
):not(.mx_UnpinAllDialog button),
.mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary);
@@ -672,7 +672,7 @@ legend {
.mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):disabled,
.mx_Dialog input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled {

View File

@@ -37,7 +37,7 @@
@import "./components/views/messages/shared/_MediaProcessingError.pcss";
@import "./components/views/pips/_WidgetPip.pcss";
@import "./components/views/polls/_PollOption.pcss";
@import "./components/views/settings/_EmailAddressesPhoneNumbers.pcss";
@import "./components/views/settings/_AddRemoveThreepids.pcss";
@import "./components/views/settings/devices/_CurrentDeviceSection.pcss";
@import "./components/views/settings/devices/_DeviceDetailHeading.pcss";
@import "./components/views/settings/devices/_DeviceDetails.pcss";
@@ -167,6 +167,7 @@
@import "./views/dialogs/_SpaceSettingsDialog.pcss";
@import "./views/dialogs/_SpotlightDialog.pcss";
@import "./views/dialogs/_TermsDialog.pcss";
@import "./views/dialogs/_UnpinAllDialog.pcss";
@import "./views/dialogs/_UntrustedDeviceDialog.pcss";
@import "./views/dialogs/_UploadConfirmDialog.pcss";
@import "./views/dialogs/_UserSettingsDialog.pcss";

View File

@@ -21,17 +21,29 @@ limitations under the License.
* tab sensibly and before I can refactor these components.
*/
.mx_EmailAddressesPhoneNumbers_discovery_existing {
.mx_AddRemoveThreepids_existing {
display: flex;
align-items: center;
}
.mx_EmailAddressesPhoneNumbers_discovery_existing_address,
.mx_EmailAddressesPhoneNumbers_discovery_existing_promptText {
.mx_AddRemoveThreepids_existing_address,
.mx_AddRemoveThreepids_existing_promptText {
flex: 1;
margin-right: 10px;
}
.mx_EmailAddressesPhoneNumbers_discovery_existing_button {
.mx_AddRemoveThreepids_existing_button {
margin-left: 5px;
}
.mx_EmailAddressesPhoneNumbers_verify {
display: flex;
}
.mx_EmailAddressesPhoneNumbers_existing_button {
justify-content: right;
}
.mx_EmailAddressesPhoneNumbers_verify_instructions {
flex: 1;
}

View File

@@ -119,8 +119,7 @@ limitations under the License.
h2 {
margin: 0;
font: var(--cpd-font-heading-sm-medium);
font-weight: var(--cpd-font-weight-medium);
font: var(--cpd-font-body-lg-semibold);
display: inline;
width: auto;
}

View File

@@ -111,7 +111,7 @@ limitations under the License.
.mx_UserMenu_contextMenu_displayName,
.mx_UserMenu_contextMenu_userId {
font: var(--cpd-font-heading-sm-regular);
font: var(--cpd-font-body-lg-regular);
/* Automatically grow subelements to fit the container */
flex: 1;

View File

@@ -81,11 +81,11 @@ limitations under the License.
}
.mx_MessageContextMenu_iconPin::before {
mask-image: url("$(res)/img/element-icons/room/pin-upright.svg");
mask-image: url("@vector-im/compound-design-tokens/icons/pin.svg");
}
.mx_MessageContextMenu_iconUnpin::before {
mask-image: url("$(res)/img/element-icons/room/pin.svg");
mask-image: url("@vector-im/compound-design-tokens/icons/unpin.svg");
}
.mx_MessageContextMenu_iconCopy::before {

View File

@@ -0,0 +1,38 @@
/*
* Copyright 2024 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_UnpinAllDialog {
/* 396 is coming from figma and we remove the left and right paddings of the dialog */
width: calc(396px - (var(--cpd-space-10x) * 2));
padding-bottom: var(--cpd-space-2x);
.mx_UnpinAllDialog_title {
/* Override the default heading style */
font: var(--cpd-font-heading-sm-semibold) !important;
margin-bottom: var(--cpd-space-3x);
}
.mx_UnpinAllDialog_buttons {
display: flex;
flex-direction: column;
gap: var(--cpd-space-4x);
margin: var(--cpd-space-8x) var(--cpd-space-2x) 0 var(--cpd-space-2x);
button {
width: 100%;
}
}
}

View File

@@ -60,10 +60,11 @@ limitations under the License.
flex: 1;
.mx_BaseCard_header_title_heading {
color: $primary-content;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font: var(--cpd-font-body-md-medium);
color: var(--cpd-color-text-secondary);
}
.mx_BaseCard_header_title_button--option {

View File

@@ -15,50 +15,40 @@ limitations under the License.
*/
.mx_PinnedMessagesCard {
.mx_PinnedMessagesCard_empty_wrapper {
--unpin-height: 76px;
.mx_PinnedMessagesCard_wrapper {
display: flex;
height: 100%;
flex-direction: column;
padding: var(--cpd-space-4x);
gap: var(--cpd-space-6x);
overflow-y: auto;
.mx_PinnedMessagesCard_empty {
height: max-content;
text-align: center;
margin: auto 40px;
.mx_PinnedMessagesCard_MessageActionBar {
pointer-events: none;
width: max-content;
margin: 0 auto;
/* Cancel the default values for non-interactivity */
position: unset;
visibility: visible;
cursor: unset;
&::before {
content: unset;
}
.mx_MessageActionBar_optionsButton {
background: var(--MessageActionBar-item-hover-background);
border-radius: var(--MessageActionBar-item-hover-borderRadius);
z-index: var(--MessageActionBar-item-hover-zIndex);
color: var(--cpd-color-icon-primary);
}
}
.mx_PinnedMessagesCard_empty_header {
color: $primary-content;
margin-block: $spacing-24 $spacing-20;
}
> span {
font-size: $font-12px;
line-height: $font-15px;
color: $secondary-content;
}
.mx_PinnedMessagesCard_Separator {
min-height: 1px;
/* Override default compound value */
margin-block: 0;
}
}
.mx_PinnedMessagesCard_wrapper_unpin_all {
/* Remove the unpin all button height and the top and bottom padding */
height: calc(100% - var(--unpin-height) - calc(var(--cpd-space-4x) * 2));
}
.mx_PinnedMessagesCard_unpin {
/* Make it float at the bottom of the unpin panel */
position: absolute;
bottom: 0;
width: 100%;
height: var(--unpin-height);
display: flex;
justify-content: center;
align-items: center;
box-shadow: 0 4px 24px 0 rgba(27, 29, 34, 0.1);
background: var(--cpd-color-bg-canvas-default);
}
.mx_EventTile_body {
word-break: break-word;
}

View File

@@ -97,7 +97,7 @@ limitations under the License.
h2 {
text-transform: uppercase;
color: $tertiary-content;
font: var(--cpd-font-heading-sm-semibold);
font: var(--cpd-font-body-md-semibold);
font-weight: var(--cpd-font-weight-semibold);
margin: $spacing-4 0;
}

View File

@@ -15,95 +15,50 @@ limitations under the License.
*/
.mx_PinnedEventTile {
min-height: 40px;
width: 100%;
padding: 0 4px 12px;
display: flex;
gap: var(--cpd-space-4x);
align-items: flex-start;
display: grid;
grid-template-areas:
"avatar name remove"
"content content content"
"footer footer footer";
grid-template-rows: max-content auto max-content;
grid-template-columns: 24px auto 24px;
grid-row-gap: 12px;
grid-column-gap: 8px;
.mx_PinnedEventTile_wrapper {
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
width: 100%;
& + .mx_PinnedEventTile {
padding: 12px 4px;
border-top: 1px solid $menu-border-color;
}
.mx_PinnedEventTile_top {
display: flex;
gap: var(--cpd-space-1x);
justify-content: space-between;
align-items: center;
.mx_PinnedEventTile_senderAvatar,
.mx_PinnedEventTile_sender,
.mx_PinnedEventTile_unpinButton,
.mx_PinnedEventTile_message,
.mx_PinnedEventTile_footer {
min-width: 0; /* Prevent a grid blowout */
}
.mx_PinnedEventTile_senderAvatar {
grid-area: avatar;
}
.mx_PinnedEventTile_sender {
grid-area: name;
font-weight: var(--cpd-font-weight-semibold);
font-size: $font-15px;
line-height: $font-24px;
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
.mx_PinnedEventTile_unpinButton {
visibility: hidden;
grid-area: remove;
position: relative;
width: 24px;
height: 24px;
border-radius: 8px;
&:hover {
background-color: $roomheader-addroom-bg-color;
.mx_PinnedEventTile_sender {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
&::before {
content: "";
position: absolute;
height: inherit;
width: inherit;
background: $secondary-content;
mask-position: center;
mask-size: 8px;
mask-repeat: no-repeat;
mask-image: url("$(res)/img/image-view/close.svg");
}
}
.mx_PinnedEventTile_thread {
display: flex;
gap: var(--cpd-space-2x);
font: var(--cpd-font-body-sm-regular);
.mx_PinnedEventTile_message {
grid-area: content;
}
svg {
width: 20px;
fill: var(--cpd-color-icon-tertiary);
}
.mx_PinnedEventTile_footer {
grid-area: footer;
font-size: $font-10px;
line-height: 12px;
span {
display: flex;
color: var(--cpd-color-text-secondary);
}
.mx_PinnedEventTile_timestamp {
color: $secondary-content;
display: unset;
width: unset; /* Cancel the default width value */
}
.mx_AccessibleButton_kind_link {
margin-left: 12px;
}
}
&:hover {
.mx_PinnedEventTile_unpinButton {
visibility: visible;
button {
background: transparent;
border: none;
cursor: pointer;
text-decoration: underline;
}
}
}
}

View File

@@ -44,6 +44,10 @@ limitations under the License.
transition: all 500ms;
.mx_UserOnboardingTask_title {
font: var(--cpd-font-body-md-medium);
}
.mx_UserOnboardingTask_description {
font-size: $font-12px;
}

View File

@@ -271,9 +271,7 @@ export default class AddThreepid {
* with a "message" property which contains a human-readable message detailing why
* the request failed.
*/
public async haveMsisdnToken(
msisdnToken: string,
): Promise<[success?: boolean, result?: IAuthData | Error | null] | undefined> {
public async haveMsisdnToken(msisdnToken: string): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
const authClient = new IdentityAuthClient();
if (this.submitUrl) {
@@ -301,13 +299,14 @@ export default class AddThreepid {
id_server: getIdServerDomain(this.matrixClient),
id_access_token: await authClient.getAccessToken(),
});
return [true];
} else {
try {
await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly
// implemented it without, so this may just succeed and that's OK.
return;
return [true];
} catch (err) {
if (!(err instanceof MatrixError) || err.httpStatus !== 401 || !err.data || !err.data.flows) {
// doesn't look like an interactive-auth failure

View File

@@ -18,6 +18,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import { MatrixClientPeg } from "./MatrixClientPeg";
import { EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/SendMessageComposer";
import { WYSIWYG_EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/MessageComposer";
// The key used to persist the the timestamp we last cleaned up drafts
export const DRAFT_LAST_CLEANUP_KEY = "mx_draft_cleanup";
@@ -61,14 +62,21 @@ function shouldCleanupDrafts(): boolean {
}
/**
* Clear all drafts for the CIDER editor if the room does not exist in the known rooms.
* Clear all drafts for the CIDER and WYSIWYG editors if the room does not exist in the known rooms.
*/
function cleaupDrafts(): void {
for (let i = 0; i < localStorage.length; i++) {
const keyName = localStorage.key(i);
if (!keyName?.startsWith(EDITOR_STATE_STORAGE_PREFIX)) continue;
if (!keyName) continue;
let roomId: string | undefined = undefined;
if (keyName.startsWith(EDITOR_STATE_STORAGE_PREFIX)) {
roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
}
if (keyName.startsWith(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX)) {
roomId = keyName.slice(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
}
if (!roomId) continue;
// Remove the prefix and the optional event id suffix to leave the room id
const roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) {
logger.debug(`Removing draft for unknown room with key ${keyName}`);

View File

@@ -41,7 +41,6 @@ import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientB
import * as StorageManager from "./utils/StorageManager";
import IdentityAuthClient from "./IdentityAuthClient";
import { crossSigningCallbacks } from "./SecurityManager";
import { ModuleRunner } from "./modules/ModuleRunner";
import { SlidingSyncManager } from "./SlidingSyncManager";
import { _t, UserFriendlyError } from "./languageHandler";
import { SettingLevel } from "./settings/SettingLevel";
@@ -452,11 +451,6 @@ class MatrixClientPegClass implements IMatrixClientPeg {
},
};
const dehydrationKeyCallback = ModuleRunner.instance.extensions.cryptoSetup.getDehydrationKeyCallback();
if (dehydrationKeyCallback) {
opts.cryptoCallbacks!.getDehydrationKey = dehydrationKeyCallback;
}
this.matrixClient = createMatrixClient(opts);
this.matrixClient.setGuest(Boolean(creds.guest));

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { Crypto, ICryptoCallbacks, encodeBase64, SecretStorage } from "matrix-js-sdk/src/matrix";
import { ICryptoCallbacks, SecretStorage } from "matrix-js-sdk/src/matrix";
import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase";
import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey";
import { logger } from "matrix-js-sdk/src/logger";
@@ -39,11 +39,6 @@ let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
let secretStorageBeingAccessed = false;
let dehydrationCache: {
key?: Uint8Array;
keyInfo?: SecretStorage.SecretStorageKeyDescription;
} = {};
/**
* This can be used by other components to check if secret storage access is in
* progress, so that we can e.g. avoid intermittently showing toasts during
@@ -119,14 +114,6 @@ async function getSecretStorageKey({
return [keyId, secretStorageKeys[keyId]];
}
if (dehydrationCache.key) {
if (await MatrixClientPeg.safeGet().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
logger.debug("getSecretStorageKey: returning key from dehydration cache");
cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
return [keyId, dehydrationCache.key];
}
}
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
if (keyFromCustomisations) {
logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
@@ -171,56 +158,6 @@ async function getSecretStorageKey({
return [keyId, key];
}
export async function getDehydrationKey(
keyInfo: SecretStorage.SecretStorageKeyDescription,
checkFunc: (data: Uint8Array) => void,
): Promise<Uint8Array> {
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
if (keyFromCustomisations) {
logger.log("CryptoSetupExtension: Using key from extension (dehydration)");
return keyFromCustomisations;
}
const inputToKey = makeInputToKey(keyInfo);
const { finished } = Modal.createDialog(
AccessSecretStorageDialog,
/* props= */
{
keyInfo,
checkPrivateKey: async (input: KeyParams): Promise<boolean> => {
const key = await inputToKey(input);
try {
checkFunc(key);
return true;
} catch (e) {
return false;
}
},
},
/* className= */ undefined,
/* isPriorityModal= */ false,
/* isStaticModal= */ false,
/* options= */ {
onBeforeClose: async (reason): Promise<boolean> => {
if (reason === "backgroundClick") {
return confirmToDismiss();
}
return true;
},
},
);
const [input] = await finished;
if (!input) {
throw new AccessCancelledError();
}
const key = await inputToKey(input);
// need to copy the key because rehydration (unpickling) will clobber it
dehydrationCache = { key: new Uint8Array(key), keyInfo };
return key;
}
function cacheSecretStorageKey(
keyId: string,
keyInfo: SecretStorage.SecretStorageKeyDescription,
@@ -232,50 +169,9 @@ function cacheSecretStorageKey(
}
}
async function onSecretRequested(
userId: string,
deviceId: string,
requestId: string,
name: string,
deviceTrust: Crypto.DeviceVerificationStatus,
): Promise<string | undefined> {
logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
const client = MatrixClientPeg.safeGet();
if (userId !== client.getUserId()) {
return;
}
if (!deviceTrust?.isVerified()) {
logger.log(`Ignoring secret request from untrusted device ${deviceId}`);
return;
}
if (
name === "m.cross_signing.master" ||
name === "m.cross_signing.self_signing" ||
name === "m.cross_signing.user_signing"
) {
const callbacks = client.getCrossSigningCacheCallbacks();
if (!callbacks?.getCrossSigningKeyCache) return;
const keyId = name.replace("m.cross_signing.", "");
const key = await callbacks.getCrossSigningKeyCache(keyId);
if (!key) {
logger.log(`${keyId} requested by ${deviceId}, but not found in cache`);
}
return key ? encodeBase64(key) : undefined;
} else if (name === "m.megolm_backup.v1") {
const key = await client.crypto?.getSessionBackupPrivateKey();
if (!key) {
logger.log(`session backup key requested by ${deviceId}, but not found in cache`);
}
return key ? encodeBase64(key) : undefined;
}
logger.warn("onSecretRequested didn't recognise the secret named ", name);
}
export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey,
cacheSecretStorageKey,
onSecretRequested,
getDehydrationKey,
};
/**

View File

@@ -34,7 +34,7 @@ import ThreadView from "./ThreadView";
import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier";
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
import { PinnedMessagesCard } from "../views/right_panel/PinnedMessagesCard";
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import { E2EStatus } from "../../utils/ShieldUtils";
import TimelineCard from "../views/right_panel/TimelineCard";

View File

@@ -161,7 +161,7 @@ interface IRoomProps {
// This defines the content of the mainSplit.
// If the mainSplit does not contain the Timeline, the chat is shown in the right panel.
enum MainSplitContentType {
export enum MainSplitContentType {
Timeline,
MaximisedWidget,
Call,

View File

@@ -485,6 +485,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
fragmentAfterLogin={this.props.fragmentAfterLogin}
primary={!this.state.flows?.find((flow) => flow.type === "m.login.password")}
action={SSOAction.LOGIN}
disabled={this.isBusy()}
/>
);
};
@@ -558,6 +559,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
<ServerPicker
serverConfig={this.props.serverConfig}
onServerConfigChange={this.props.onServerConfigChange}
disabled={this.isBusy()}
/>
{this.renderLoginComponentForFlows()}
{footer}

View File

@@ -26,6 +26,7 @@ import DateSeparator from "../../views/messages/DateSeparator";
import HistoryTile from "../../views/rooms/HistoryTile";
import EventListSummary from "../../views/elements/EventListSummary";
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
import SettingsStore from "../../../settings/SettingsStore";
const groupedStateEvents = [
EventType.RoomMember,
@@ -97,6 +98,12 @@ export class MainGrouper extends BaseGrouper {
// absorb hidden events to not split the summary
return;
}
if (ev.getType() === EventType.RoomPinnedEvents && !SettingsStore.getValue("feature_pinning")) {
// If pinned messages are disabled, don't show the summary
return;
}
this.events.push(wrappedEvent);
}

View File

@@ -833,7 +833,7 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
};
private onReceiveMessage = (event: MessageEvent): void => {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
if (event.data === "authDone" && event.source === this.popupWindow) {
if (this.popupWindow) {
this.popupWindow.close();
this.popupWindow = null;
@@ -950,7 +950,7 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
};
private onReceiveMessage = (event: MessageEvent): void => {
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
if (event.data === "authDone" && event.source === this.popupWindow) {
this.props.submitAuthDict({});
}
};

View File

@@ -36,9 +36,8 @@ import Modal from "../../../Modal";
import Resend from "../../../Resend";
import SettingsStore from "../../../settings/SettingsStore";
import { isUrlPermitted } from "../../../HtmlUtils";
import { canEditContent, canPinEvent, editEvent, isContentActionable } from "../../../utils/EventUtils";
import { canEditContent, editEvent, isContentActionable } from "../../../utils/EventUtils";
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { ReadPinsEventId } from "../right_panel/types";
import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { ButtonEvent } from "../elements/AccessibleButton";
@@ -60,6 +59,7 @@ import { getForwardableEvent } from "../../../events/forward/getForwardableEvent
import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { CardContext } from "../right_panel/context";
import PinningUtils from "../../../utils/PinningUtils";
interface IReplyInThreadButton {
mxEvent: MatrixEvent;
@@ -177,24 +177,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
this.props.mxEvent.getType() !== EventType.RoomServerAcl &&
this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin =
!!room?.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli) &&
canPinEvent(this.props.mxEvent);
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
const canPin = PinningUtils.canPinOrUnpin(cli, this.props.mxEvent);
this.setState({ canRedact, canPin });
};
private isPinned(): boolean {
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
const pinnedEvent = room?.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
}
private canEndPoll(mxEvent: MatrixEvent): boolean {
return (
M_POLL_START.matches(mxEvent.getType()) &&
@@ -257,22 +244,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
};
private onPinClick = (): void => {
const cli = MatrixClientPeg.safeGet();
const room = cli.getRoom(this.props.mxEvent.getRoomId());
if (!room) return;
const eventId = this.props.mxEvent.getId();
const pinnedIds = room.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];
if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
pinnedIds.push(eventId);
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
});
}
cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
// Pin or unpin in background
PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
this.closeMenu();
};
@@ -452,17 +425,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
let pinButton: JSX.Element | undefined;
if (contentActionable && this.state.canPin) {
pinButton = (
<IconizedContextMenuOption
iconClassName="mx_MessageContextMenu_iconPin"
label={this.isPinned() ? _t("action|unpin") : _t("action|pin")}
onClick={this.onPinClick}
/>
);
}
// This is specifically not behind the developerMode flag to give people insight into the Matrix
const viewSourceButton = (
<IconizedContextMenuOption
@@ -649,6 +611,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
);
}
let pinButton: JSX.Element | undefined;
if (rightClick && this.state.canPin) {
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
pinButton = (
<IconizedContextMenuOption
iconClassName={isPinned ? "mx_MessageContextMenu_iconUnpin" : "mx_MessageContextMenu_iconPin"}
label={isPinned ? _t("action|unpin") : _t("action|pin")}
onClick={this.onPinClick}
/>
);
}
let viewInRoomButton: JSX.Element | undefined;
if (isThreadRootEvent) {
viewInRoomButton = (
@@ -671,13 +645,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
}
let quickItemsList: JSX.Element | undefined;
if (editButton || replyButton || reactButton) {
if (editButton || replyButton || reactButton || pinButton) {
quickItemsList = (
<IconizedContextMenuOptionList>
{reactButton}
{replyButton}
{replyInThreadButton}
{editButton}
{pinButton}
</IconizedContextMenuOptionList>
);
}
@@ -688,7 +663,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
{openInMapSiteButton}
{endPollButton}
{forwardButton}
{pinButton}
{permalinkButton}
{reportEventButton}
{externalURLButton}

View File

@@ -0,0 +1,77 @@
/*
* Copyright 2024 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, { JSX } from "react";
import { Button, Text } from "@vector-im/compound-web";
import { EventType, MatrixClient } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import BaseDialog from "../dialogs/BaseDialog";
import { _t } from "../../../languageHandler";
/**
* Properties for {@link UnpinAllDialog}.
*/
interface UnpinAllDialogProps {
/*
* The matrix client to use.
*/
matrixClient: MatrixClient;
/*
* The room ID to unpin all events in.
*/
roomId: string;
/*
* Callback for when the dialog is closed.
*/
onFinished: () => void;
}
/**
* A dialog that asks the user to confirm unpinning all events in a room.
*/
export function UnpinAllDialog({ matrixClient, roomId, onFinished }: UnpinAllDialogProps): JSX.Element {
return (
<BaseDialog
hasCancel={true}
title={_t("right_panel|pinned_messages|unpin_all|title")}
titleClass="mx_UnpinAllDialog_title"
className="mx_UnpinAllDialog"
onFinished={onFinished}
fixedWidth={false}
>
<Text as="span">{_t("right_panel|pinned_messages|unpin_all|content")}</Text>
<div className="mx_UnpinAllDialog_buttons">
<Button
destructive={true}
onClick={async () => {
try {
await matrixClient.sendStateEvent(roomId, EventType.RoomPinnedEvents, { pinned: [] }, "");
} catch (e) {
logger.error("Failed to unpin all events:", e);
}
onFinished();
}}
>
{_t("action|continue")}
</Button>
<Button kind="tertiary" onClick={onFinished}>
{_t("action|cancel")}
</Button>
</div>
</BaseDialog>
);
}

View File

@@ -151,11 +151,20 @@ interface IProps {
fragmentAfterLogin?: string;
primary?: boolean;
action?: SSOAction;
disabled?: boolean;
}
const MAX_PER_ROW = 6;
const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => {
const SSOButtons: React.FC<IProps> = ({
matrixClient,
flow,
loginType,
fragmentAfterLogin,
primary,
action,
disabled,
}) => {
const providers = flow.identity_providers || [];
if (providers.length < 2) {
return (
@@ -168,6 +177,7 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA
primary={primary}
action={action}
flow={flow}
disabled={disabled}
/>
</div>
);

View File

@@ -29,6 +29,7 @@ interface IProps {
title?: string;
dialogTitle?: string;
serverConfig: ValidatedServerConfig;
disabled?: boolean;
onServerConfigChange?(config: ValidatedServerConfig): void;
}
@@ -55,7 +56,7 @@ const onHelpClick = (): void => {
);
};
const ServerPicker: React.FC<IProps> = ({ title, dialogTitle, serverConfig, onServerConfigChange }) => {
const ServerPicker: React.FC<IProps> = ({ title, dialogTitle, serverConfig, onServerConfigChange, disabled }) => {
const disableCustomUrls = SdkConfig.get("disable_custom_urls");
let editBtn;
@@ -68,7 +69,7 @@ const ServerPicker: React.FC<IProps> = ({ title, dialogTitle, serverConfig, onSe
});
};
editBtn = (
<AccessibleButton className="mx_ServerPicker_change" kind="link" onClick={onClick}>
<AccessibleButton className="mx_ServerPicker_change" kind="link" onClick={onClick} disabled={disabled}>
{_t("action|edit")}
</AccessibleButton>
);

View File

@@ -26,6 +26,8 @@ import {
M_BEACON_INFO,
} from "matrix-js-sdk/src/matrix";
import classNames from "classnames";
import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin.svg";
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";
import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.svg";
@@ -61,6 +63,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa
import { GetRelationsForEvent, IEventTileType } from "../rooms/EventTile";
import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types";
import { ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils";
interface IOptionsButtonProps {
mxEvent: MatrixEvent;
@@ -384,6 +387,17 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
);
};
/**
* Pin or unpin the event.
*/
private onPinClick = async (event: ButtonEvent): Promise<void> => {
// Don't open the regular browser or our context menu on right-click
event.preventDefault();
event.stopPropagation();
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
};
public render(): React.ReactNode {
const toolbarOpts: JSX.Element[] = [];
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
@@ -401,6 +415,22 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
);
}
if (PinningUtils.canPinOrUnpin(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
toolbarOpts.push(
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"
title={isPinned ? _t("action|unpin") : _t("action|pin")}
onClick={this.onPinClick}
onContextMenu={this.onPinClick}
key="pin"
placement="left"
>
{isPinned ? <UnpinIcon /> : <PinIcon />}
</RovingAccessibleButton>,
);
}
const cancelSendingButton = (
<RovingAccessibleButton
className="mx_MessageActionBar_iconButton"

View File

@@ -14,41 +14,62 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useContext, useEffect, useState } from "react";
import { Room, RoomEvent, RoomStateEvent, MatrixEvent, EventType, RelationType } from "matrix-js-sdk/src/matrix";
import React, { useCallback, useEffect, useState, JSX } from "react";
import {
Room,
RoomEvent,
RoomStateEvent,
MatrixEvent,
EventType,
RelationType,
EventTimeline,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Button, Separator } from "@vector-im/compound-web";
import classNames from "classnames";
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
import { Icon as EmojiIcon } from "../../../../res/img/element-icons/room/message-bar/emoji.svg";
import { Icon as ReplyIcon } from "../../../../res/img/element-icons/room/message-bar/reply.svg";
import { _t } from "../../../languageHandler";
import BaseCard from "./BaseCard";
import Spinner from "../elements/Spinner";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import PinningUtils from "../../../utils/PinningUtils";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import PinnedEventTile from "../rooms/PinnedEventTile";
import { PinnedEventTile } from "../rooms/PinnedEventTile";
import { useRoomState } from "../../../hooks/useRoomState";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
import { ReadPinsEventId } from "./types";
import Heading from "../typography/Heading";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { filterBoolean } from "../../../utils/arrays";
import Modal from "../../../Modal";
import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
import EmptyState from "./EmptyState";
interface IProps {
room: Room;
permalinkCreator: RoomPermalinkCreator;
onClose(): void;
}
/**
* Get the pinned event IDs from a room.
* @param room
*/
function getPinnedEventIds(room?: Room): string[] {
return room?.currentState.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent()?.pinned ?? [];
return (
room
?.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "")
?.getContent()?.pinned ?? []
);
}
/**
* Get the pinned event IDs from a room.
* @param room
*/
export const usePinnedEvents = (room?: Room): string[] => {
const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
// Update the pinned events when the room state changes
// Filter out events that are not pinned events
const update = useCallback(
(ev?: MatrixEvent) => {
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
@@ -57,7 +78,7 @@ export const usePinnedEvents = (room?: Room): string[] => {
[room],
);
useTypedEventEmitter(room?.currentState, RoomStateEvent.Events, update);
useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
useEffect(() => {
setPinnedEvents(getPinnedEventIds(room));
return () => {
@@ -67,13 +88,23 @@ export const usePinnedEvents = (room?: Room): string[] => {
return pinnedEvents;
};
/**
* Get the read pinned event IDs from a room.
* @param room
*/
function getReadPinnedEventIds(room?: Room): Set<string> {
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
}
/**
* Get the read pinned event IDs from a room.
* @param room
*/
export const useReadPinnedEvents = (room?: Room): Set<string> => {
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
// Update the read pinned events when the room state changes
// Filter out events that are not read pinned events
const update = useCallback(
(ev?: MatrixEvent) => {
if (ev && ev.getType() !== ReadPinsEventId) return;
@@ -92,36 +123,36 @@ export const useReadPinnedEvents = (room?: Room): Set<string> => {
return readPinnedEvents;
};
const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator }) => {
const cli = useContext(MatrixClientContext);
const roomContext = useContext(RoomContext);
const canUnpin = useRoomState(room, (state) => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli));
const pinnedEventIds = usePinnedEvents(room);
const readPinnedEvents = useReadPinnedEvents(room);
/**
* Fetch the pinned events
* @param room
* @param pinnedEventIds
*/
function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
const cli = useMatrixClientContext();
useEffect(() => {
if (!cli || cli.isGuest()) return; // nothing to do
const newlyRead = pinnedEventIds.filter((id) => !readPinnedEvents.has(id));
if (newlyRead.length > 0) {
// clear out any read pinned events which no longer are pinned
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: pinnedEventIds,
});
}
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
const pinnedEvents = useAsyncMemo(
return useAsyncMemo(
() => {
const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
const timelineSet = room.getUnfilteredTimelineSet();
// Get the event from the local timeline
const localEvent = timelineSet
?.getTimelineForEvent(eventId)
?.getEvents()
.find((e) => e.getId() === eventId);
// Decrypt the event if it's encrypted
// Can happen when the tab is refreshed and the pinned events card is opened directly
if (localEvent?.isEncrypted()) {
await cli.decryptEventIfNeeded(localEvent);
}
// If the event is available locally, return it if it's pinnable
// Otherwise, return null
if (localEvent) return PinningUtils.isPinnable(localEvent) ? localEvent : null;
try {
// Fetch the event and latest edit in parallel
// The event is not available locally, so we fetch the event and latest edit in parallel
const [
evJson,
{
@@ -131,10 +162,15 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
cli.fetchRoomEvent(room.roomId, eventId),
cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }),
]);
const event = new MatrixEvent(evJson);
// Decrypt the event if it's encrypted
if (event.isEncrypted()) {
await cli.decryptEventIfNeeded(event); // TODO await?
await cli.decryptEventIfNeeded(event);
}
// Handle poll events
await room.processPollEvents([event]);
const senderUserId = event.getSender();
@@ -158,62 +194,59 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
[cli, room, pinnedEventIds],
null,
);
}
let content: JSX.Element[] | JSX.Element | undefined;
/**
* List the pinned messages in a room inside a Card.
*/
interface PinnedMessagesCardProps {
/**
* The room to list the pinned messages for.
*/
room: Room;
/**
* Permalink of the room.
*/
permalinkCreator: RoomPermalinkCreator;
/**
* Callback for when the card is closed.
*/
onClose(): void;
}
export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMessagesCardProps): JSX.Element {
const cli = useMatrixClientContext();
const roomContext = useRoomContext();
const pinnedEventIds = usePinnedEvents(room);
const readPinnedEvents = useReadPinnedEvents(room);
const pinnedEvents = useFetchedPinnedEvents(room, pinnedEventIds);
useEffect(() => {
if (!cli || cli.isGuest()) return; // nothing to do
const newlyRead = pinnedEventIds.filter((id) => !readPinnedEvents.has(id));
if (newlyRead.length > 0) {
// clear out any read pinned events which no longer are pinned
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: pinnedEventIds,
});
}
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
let content: JSX.Element;
if (!pinnedEventIds.length) {
content = (
<div className="mx_PinnedMessagesCard_empty_wrapper">
<div className="mx_PinnedMessagesCard_empty">
{/* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */}
<div className="mx_MessageActionBar mx_PinnedMessagesCard_MessageActionBar">
<div className="mx_MessageActionBar_iconButton">
<EmojiIcon />
</div>
<div className="mx_MessageActionBar_iconButton">
<ReplyIcon />
</div>
<div className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton">
<ContextMenuIcon />
</div>
</div>
<Heading size="4" className="mx_PinnedMessagesCard_empty_header">
{_t("right_panel|pinned_messages|empty")}
</Heading>
{_t(
"right_panel|pinned_messages|explainer",
{},
{
b: (sub) => <b>{sub}</b>,
},
)}
</div>
</div>
<EmptyState
Icon={PinIcon}
title={_t("right_panel|pinned_messages|empty_title")}
description={_t("right_panel|pinned_messages|empty_description", {
pinAction: _t("action|pin"),
})}
/>
);
} else if (pinnedEvents?.length) {
const onUnpinClicked = async (event: MatrixEvent): Promise<void> => {
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
if (pinnedEvents?.getContent()?.pinned) {
const pinned = pinnedEvents.getContent().pinned;
const index = pinned.indexOf(event.getId());
if (index !== -1) {
pinned.splice(index, 1);
await cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
}
}
};
// show them in reverse, with latest pinned at the top
content = filterBoolean(pinnedEvents)
.reverse()
.map((ev) => (
<PinnedEventTile
key={ev.getId()}
event={ev}
onUnpinClicked={canUnpin ? () => onUnpinClicked(ev) : undefined}
permalinkCreator={permalinkCreator}
/>
));
content = (
<PinnedMessages events={filterBoolean(pinnedEvents)} room={room} permalinkCreator={permalinkCreator} />
);
} else {
content = <Spinner />;
}
@@ -223,7 +256,7 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
header={
<div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading">
{_t("right_panel|pinned_messages|title")}
{_t("right_panel|pinned_messages|header", { count: pinnedEventIds.length })}
</Heading>
</div>
}
@@ -240,6 +273,79 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
</RoomContext.Provider>
</BaseCard>
);
};
}
export default PinnedMessagesCard;
/**
* The pinned messages in a room.
*/
interface PinnedMessagesProps {
/**
* The pinned events.
*/
events: MatrixEvent[];
/**
* The room the events are in.
*/
room: Room;
/**
* The permalink creator to use.
*/
permalinkCreator: RoomPermalinkCreator;
}
/**
* The pinned messages in a room.
*/
function PinnedMessages({ events, room, permalinkCreator }: PinnedMessagesProps): JSX.Element {
const matrixClient = useMatrixClientContext();
/**
* Whether the client can unpin events from the room.
*/
const canUnpin = useRoomState(room, (state) =>
state.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
);
/**
* Opens the unpin all dialog.
*/
const onUnpinAll = useCallback(async (): Promise<void> => {
Modal.createDialog(UnpinAllDialog, {
roomId: room.roomId,
matrixClient,
});
}, [room, matrixClient]);
return (
<>
<div
className={classNames("mx_PinnedMessagesCard_wrapper", {
mx_PinnedMessagesCard_wrapper_unpin_all: canUnpin,
})}
role="list"
>
{events.reverse().map((event, i) => (
<>
<PinnedEventTile
key={event.getId()}
event={event}
permalinkCreator={permalinkCreator}
room={room}
/>
{/* Add a separator if this isn't the last pinned message */}
{events.length - 1 !== i && (
<Separator key={`separator-${event.getId()}`} className="mx_PinnedMessagesCard_Separator" />
)}
</>
))}
</div>
{canUnpin && (
<div className="mx_PinnedMessagesCard_unpin">
<Button kind="tertiary" onClick={onUnpinAll}>
{_t("right_panel|pinned_messages|unpin_all|button")}
</Button>
</div>
)}
</>
);
}

View File

@@ -26,6 +26,7 @@ import {
} from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk";
import { Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -65,6 +66,9 @@ import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStart
import { UIFeature } from "../../../settings/UIFeature";
import { formatTimeLeft } from "../../../DateUtils";
// The prefix used when persisting editor drafts to localstorage.
export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_";
let instanceCount = 0;
interface ISendButtonProps {
@@ -109,6 +113,12 @@ interface IState {
initialComposerContent: string;
}
type WysiwygComposerState = {
content: string;
isRichText: boolean;
replyEventId?: string;
};
export class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef?: string;
private messageComposerInput = createRef<SendMessageComposerClass>();
@@ -129,11 +139,32 @@ export class MessageComposer extends React.Component<IProps, IState> {
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.context = context; // otherwise React will only set it prior to render due to type def above
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
window.addEventListener("beforeunload", this.saveWysiwygEditorState);
const isWysiwygLabEnabled = SettingsStore.getValue<boolean>("feature_wysiwyg_composer");
let isRichTextEnabled = true;
let initialComposerContent = "";
if (isWysiwygLabEnabled) {
const wysiwygState = this.restoreWysiwygEditorState();
if (wysiwygState) {
isRichTextEnabled = wysiwygState.isRichText;
initialComposerContent = wysiwygState.content;
if (wysiwygState.replyEventId) {
dis.dispatch({
action: "reply_to_event",
event: this.props.room.findEventById(wysiwygState.replyEventId),
context: this.context.timelineRenderingType,
});
}
}
}
this.state = {
isComposerEmpty: true,
composerContent: "",
isComposerEmpty: initialComposerContent?.length === 0,
composerContent: initialComposerContent,
haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false,
@@ -141,9 +172,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
isWysiwygLabEnabled: SettingsStore.getValue<boolean>("feature_wysiwyg_composer"),
isRichTextEnabled: true,
initialComposerContent: "",
isWysiwygLabEnabled: isWysiwygLabEnabled,
isRichTextEnabled: isRichTextEnabled,
initialComposerContent: initialComposerContent,
};
this.instanceId = instanceCount++;
@@ -154,6 +185,52 @@ export class MessageComposer extends React.Component<IProps, IState> {
SettingsStore.monitorSetting("feature_wysiwyg_composer", null);
}
private get editorStateKey(): string {
let key = WYSIWYG_EDITOR_STATE_STORAGE_PREFIX + this.props.room.roomId;
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
key += `_${this.props.relation.event_id}`;
}
return key;
}
private restoreWysiwygEditorState(): WysiwygComposerState | undefined {
const json = localStorage.getItem(this.editorStateKey);
if (json) {
try {
const state: WysiwygComposerState = JSON.parse(json);
return state;
} catch (e) {
logger.error(e);
}
}
return undefined;
}
private saveWysiwygEditorState = (): void => {
if (this.shouldSaveWysiwygEditorState()) {
const { isRichTextEnabled, composerContent } = this.state;
const replyEventId = this.props.replyToEvent ? this.props.replyToEvent.getId() : undefined;
const item: WysiwygComposerState = {
content: composerContent,
isRichText: isRichTextEnabled,
replyEventId: replyEventId,
};
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
} else {
this.clearStoredEditorState();
}
};
// should save state when wysiwyg is enabled and has contents or reply is open
private shouldSaveWysiwygEditorState = (): boolean => {
const { isWysiwygLabEnabled, isComposerEmpty } = this.state;
return isWysiwygLabEnabled && (!isComposerEmpty || !!this.props.replyToEvent);
};
private clearStoredEditorState(): void {
localStorage.removeItem(this.editorStateKey);
}
private get voiceRecording(): Optional<VoiceMessageRecording> {
return this._voiceRecording;
}
@@ -265,6 +342,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
window.removeEventListener("beforeunload", this.saveWysiwygEditorState);
this.saveWysiwygEditorState();
// clean up our listeners by setting our cached recording to falsy (see internal setter)
this.voiceRecording = null;
}

View File

@@ -15,112 +15,241 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React from "react";
import { MatrixEvent, EventType, RelationType, Relations } from "matrix-js-sdk/src/matrix";
import React, { JSX, useCallback, useState } from "react";
import { EventTimeline, EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { IconButton, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
import { Icon as ViewIcon } from "@vector-im/compound-design-tokens/icons/visibility-on.svg";
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";
import { Icon as ForwardIcon } from "@vector-im/compound-design-tokens/icons/forward.svg";
import { Icon as TriggerIcon } from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
import { Icon as ThreadIcon } from "@vector-im/compound-design-tokens/icons/threads.svg";
import classNames from "classnames";
import dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from "../../../languageHandler";
import { formatDate } from "../../../DateUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useRoomState } from "../../../hooks/useRoomState";
import { isContentActionable } from "../../../utils/EventUtils";
import { getForwardableEvent } from "../../../events";
import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload";
import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
interface IProps {
const AVATAR_SIZE = "32px";
/**
* Properties for {@link PinnedEventTile}.
*/
interface PinnedEventTileProps {
/**
* The event to display.
*/
event: MatrixEvent;
/**
* The permalink creator to use.
*/
permalinkCreator: RoomPermalinkCreator;
onUnpinClicked?(): void;
/**
* The room the event is in.
*/
room: Room;
}
const AVATAR_SIZE = "24px";
/**
* A pinned event tile.
*/
export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTileProps): JSX.Element {
const sender = event.getSender();
if (!sender) {
throw new Error("Pinned event unexpectedly has no sender");
}
export default class PinnedEventTile extends React.Component<IProps> {
public static contextType = MatrixClientContext;
public declare context: React.ContextType<typeof MatrixClientContext>;
const isInThread = Boolean(event.threadRootId);
const displayThreadInfo = !event.isThreadRoot && isInThread;
private onTileClicked = (): void => {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: this.props.event.getId(),
highlighted: true,
room_id: this.props.event.getRoomId(),
metricsTrigger: undefined, // room doesn't change
});
};
// For event types like polls that use relations, we fetch those manually on
// mount and store them here, exposing them through getRelationsForEvent
private relations = new Map<string, Map<string, Relations>>();
private getRelationsForEvent = (
eventId: string,
relationType: RelationType | string,
eventType: EventType | string,
): Relations | undefined => {
if (eventId === this.props.event.getId()) {
return this.relations.get(relationType)?.get(eventType);
}
};
public render(): React.ReactNode {
const sender = this.props.event.getSender();
if (!sender) {
throw new Error("Pinned event unexpectedly has no sender");
}
let unpinButton: JSX.Element | undefined;
if (this.props.onUnpinClicked) {
unpinButton = (
<AccessibleButton
onClick={this.props.onUnpinClicked}
className="mx_PinnedEventTile_unpinButton"
title={_t("action|unpin")}
/>
);
}
return (
<div className="mx_PinnedEventTile">
return (
<div className="mx_PinnedEventTile" role="listitem">
<div>
<MemberAvatar
className="mx_PinnedEventTile_senderAvatar"
member={this.props.event.sender}
member={event.sender}
size={AVATAR_SIZE}
fallbackUserId={sender}
/>
<span className={"mx_PinnedEventTile_sender " + getUserNameColorClass(sender)}>
{this.props.event.sender?.name || sender}
</span>
{unpinButton}
<div className="mx_PinnedEventTile_message">
<MessageEvent
mxEvent={this.props.event}
getRelationsForEvent={this.getRelationsForEvent}
// @ts-ignore - complaining that className is invalid when it's not
className="mx_PinnedEventTile_body"
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
permalinkCreator={this.props.permalinkCreator}
replacingEventId={this.props.event.replacingEventId()}
/>
</div>
<div className="mx_PinnedEventTile_footer">
<span className="mx_MessageTimestamp mx_PinnedEventTile_timestamp">
{formatDate(new Date(this.props.event.getTs()))}
</span>
<AccessibleButton onClick={this.onTileClicked} kind="link">
{_t("common|view_message")}
</AccessibleButton>
</div>
</div>
);
}
<div className="mx_PinnedEventTile_wrapper">
<div className="mx_PinnedEventTile_top">
<Text
weight="semibold"
className={classNames("mx_PinnedEventTile_sender", getUserNameColorClass(sender))}
as="span"
>
{event.sender?.name || sender}
</Text>
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} />
</div>
<MessageEvent
mxEvent={event}
maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently
permalinkCreator={permalinkCreator}
replacingEventId={event.replacingEventId()}
/>
{displayThreadInfo && (
<div className="mx_PinnedEventTile_thread">
<ThreadIcon />
{_t(
"right_panel|pinned_messages|reply_thread",
{},
{
link: (sub) => (
<button
type="button"
onClick={() => {
if (!event.threadRootId) return;
const rootEvent = room.findEventById(event.threadRootId);
if (!rootEvent) return;
dis.dispatch<ShowThreadPayload>({
action: Action.ShowThread,
rootEvent: rootEvent,
push: true,
});
}}
>
{sub}
</button>
),
},
)}
</div>
)}
</div>
</div>
);
}
/**
* Properties for {@link PinMenu}.
*/
interface PinMenuProps extends PinnedEventTileProps {}
/**
* A popover menu with actions on the pinned event
*/
function PinMenu({ event, room, permalinkCreator }: PinMenuProps): JSX.Element {
const [open, setOpen] = useState(false);
const matrixClient = useMatrixClientContext();
/**
* View the event in the timeline.
*/
const onViewInTimeline = useCallback(() => {
dis.dispatch<ViewRoomPayload>({
action: Action.ViewRoom,
event_id: event.getId(),
highlighted: true,
room_id: event.getRoomId(),
metricsTrigger: undefined, // room doesn't change
});
}, [event]);
/**
* Whether the client can unpin the event.
* Pin and unpin are using the same permission.
*/
const canUnpin = useRoomState(room, (state) =>
state.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
);
/**
* Unpin the event.
* @param event
*/
const onUnpin = useCallback(async (): Promise<void> => {
const pinnedEvents = room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "");
if (pinnedEvents?.getContent()?.pinned) {
const pinned = pinnedEvents.getContent().pinned;
const index = pinned.indexOf(event.getId());
if (index !== -1) {
pinned.splice(index, 1);
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
}
}
}, [event, room, matrixClient]);
const contentActionable = isContentActionable(event);
// Get the forwardable event for the given event
const forwardableEvent = contentActionable && getForwardableEvent(event, matrixClient);
/**
* Open the forward dialog.
*/
const onForward = useCallback(() => {
if (forwardableEvent) {
dis.dispatch<OpenForwardDialogPayload>({
action: Action.OpenForwardDialog,
event: forwardableEvent,
permalinkCreator: permalinkCreator,
});
}
}, [forwardableEvent, permalinkCreator]);
/**
* Whether the client can redact the event.
*/
const canRedact =
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.maySendRedactionForEvent(event, matrixClient.getSafeUserId()) &&
event.getType() !== EventType.RoomServerAcl &&
event.getType() !== EventType.RoomEncryption;
/**
* Redact the event.
*/
const onRedact = useCallback(
(): void =>
createRedactEventDialog({
mxEvent: event,
}),
[event],
);
return (
<Menu
open={open}
onOpenChange={setOpen}
showTitle={false}
title={_t("right_panel|pinned_messages|menu")}
side="right"
align="start"
trigger={
<IconButton size="24px" aria-label={_t("right_panel|pinned_messages|menu")}>
<TriggerIcon />
</IconButton>
}
>
<MenuItem Icon={ViewIcon} label={_t("right_panel|pinned_messages|view")} onSelect={onViewInTimeline} />
{canUnpin && <MenuItem Icon={UnpinIcon} label={_t("action|unpin")} onSelect={onUnpin} />}
{forwardableEvent && <MenuItem Icon={ForwardIcon} label={_t("action|forward")} onSelect={onForward} />}
{canRedact && (
<>
<Separator />
<MenuItem kind="critical" Icon={DeleteIcon} label={_t("action|delete")} onSelect={onRedact} />
</>
)}
</Menu>
);
}

View File

@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import React, { useCallback, useMemo, useState } from "react";
import React, { useCallback, useContext, useMemo, useState } from "react";
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg";
import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg";
@@ -50,7 +50,7 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import PosthogTrackers from "../../../PosthogTrackers";
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
import { RoomKnocksBar } from "./RoomKnocksBar";
import { isVideoRoom } from "../../../utils/video-rooms";
import { useIsVideoRoom } from "../../../utils/video-rooms";
import { notificationLevelToIndicator } from "../../../utils/notifications";
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
import { ButtonEvent } from "../elements/AccessibleButton";
@@ -59,6 +59,8 @@ import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnounc
import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore";
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
import { IOOBData } from "../../../stores/ThreepidInviteStore";
import RoomContext from "../../../contexts/RoomContext";
import { MainSplitContentType } from "../../structures/RoomView";
export default function RoomHeader({
room,
@@ -233,6 +235,13 @@ export default function RoomHeader({
const isReleaseAnnouncementOpen = useIsReleaseAnnouncementOpen("newRoomHeader");
const roomContext = useContext(RoomContext);
const isVideoRoom = useIsVideoRoom(room);
const showChatButton =
isVideoRoom ||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
roomContext.mainSplitContentType === MainSplitContentType.Call;
return (
<>
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
@@ -325,14 +334,13 @@ export default function RoomHeader({
})}
{isViewingCall && <CallGuestLinkButton room={room} />}
{((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && <VideoRoomChatButton room={room} />}
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
joinCallButton
) : (
<>
{!isVideoRoom(room) && videoCallButton}
{!useElementCallExclusively && !isVideoRoom(room) && voiceCallButton}
{!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
</>
)}
@@ -347,6 +355,9 @@ export default function RoomHeader({
<RoomInfoIcon />
</IconButton>
</Tooltip>
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}>
<IconButton
indicator={notificationLevelToIndicator(threadNotifications)}

View File

@@ -23,6 +23,7 @@ import { _t } from "../../../languageHandler";
import { PosthogScreenTracker } from "../../../PosthogTrackers";
import SearchWarning, { WarningKind } from "../elements/SearchWarning";
import { SearchInfo, SearchScope } from "../../../Searching";
import InlineSpinner from "../elements/InlineSpinner";
interface Props {
searchInfo?: SearchInfo;
@@ -41,13 +42,15 @@ const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSe
<div className="mx_RoomSearchAuxPanel_summary">
<SearchIcon width="24px" height="24px" />
<div className="mx_RoomSearchAuxPanel_summary_text">
{searchInfo
? _t(
"room|search|summary",
{ count: searchInfo.count ?? 0 },
{ query: () => <b>{searchInfo.term}</b> },
)
: undefined}
{searchInfo?.count !== undefined ? (
_t(
"room|search|summary",
{ count: searchInfo.count },
{ query: () => <b>{searchInfo.term}</b> },
)
) : (
<InlineSpinner />
)}
<SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} />
</div>
</div>

View File

@@ -0,0 +1,534 @@
/*
Copyright 2024 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, { useCallback, useRef, useState } from "react";
import { logger } from "matrix-js-sdk/src/logger";
import {
IRequestMsisdnTokenResponse,
IRequestTokenResponse,
MatrixError,
ThreepidMedium,
} from "matrix-js-sdk/src/matrix";
import AddThreepid, { Binding, ThirdPartyIdentifier } from "../../../AddThreepid";
import { _t, UserFriendlyError } from "../../../languageHandler";
import AccessibleButton, { ButtonEvent } from "../elements/AccessibleButton";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import Modal from "../../../Modal";
import ErrorDialog, { extractErrorMessageFromError } from "../dialogs/ErrorDialog";
import Field from "../elements/Field";
import { looksValid as emailLooksValid } from "../../../email";
import CountryDropdown from "../auth/CountryDropdown";
import { PhoneNumberCountryDefinition } from "../../../phonenumber";
import InlineSpinner from "../elements/InlineSpinner";
// Whether we're adding 3pids to the user's account on the homeserver or sharing them on an identity server
type TheepidControlMode = "hs" | "is";
interface ExistingThreepidProps {
mode: TheepidControlMode;
threepid: ThirdPartyIdentifier;
onChange: (threepid: ThirdPartyIdentifier) => void;
disabled?: boolean;
}
const ExistingThreepid: React.FC<ExistingThreepidProps> = ({ mode, threepid, onChange, disabled }) => {
const [isConfirming, setIsConfirming] = useState(false);
const client = useMatrixClientContext();
const bindTask = useRef<AddThreepid | undefined>();
const [isVerifyingBind, setIsVerifyingBind] = useState(false);
const [continueDisabled, setContinueDisabled] = useState(false);
const [verificationCode, setVerificationCode] = useState("");
const onRemoveClick = useCallback((e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setIsConfirming(true);
}, []);
const onCancelClick = useCallback((e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setIsConfirming(false);
}, []);
const onConfirmRemoveClick = useCallback(
(e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
client
.deleteThreePid(threepid.medium, threepid.address)
.then(() => {
return onChange(threepid);
})
.catch((err) => {
logger.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_remove_3pid"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
},
[client, threepid, onChange],
);
const changeBinding = useCallback(
async ({ bind, label, errorTitle }: Binding) => {
try {
if (bind) {
bindTask.current = new AddThreepid(client);
setContinueDisabled(true);
if (threepid.medium === ThreepidMedium.Email) {
await bindTask.current.bindEmailAddress(threepid.address);
} else {
// XXX: Sydent will accept a number without country code if you add
// a leading plus sign to a number in E.164 format (which the 3PID
// address is), but this goes against the spec.
// See https://github.com/matrix-org/matrix-doc/issues/2222
await bindTask.current.bindMsisdn(null as unknown as string, `+${threepid.address}`);
}
setContinueDisabled(false);
setIsVerifyingBind(true);
} else {
await client.unbindThreePid(threepid.medium, threepid.address);
onChange(threepid);
}
} catch (err) {
logger.error(`changeBinding: Unable to ${label} email address ${threepid.address}`, err);
setIsVerifyingBind(false);
setContinueDisabled(false);
bindTask.current = undefined;
Modal.createDialog(ErrorDialog, {
title: errorTitle,
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
},
[client, threepid, onChange],
);
const onRevokeClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
changeBinding({
bind: false,
label: "revoke",
errorTitle:
threepid.medium === "email"
? _t("settings|general|error_revoke_email_discovery")
: _t("settings|general|error_revoke_msisdn_discovery"),
}).then();
},
[changeBinding, threepid.medium],
);
const onShareClick = useCallback(
(e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
changeBinding({
bind: true,
label: "share",
errorTitle:
threepid.medium === "email"
? _t("settings|general|error_share_email_discovery")
: _t("settings|general|error_share_msisdn_discovery"),
}).then();
},
[changeBinding, threepid.medium],
);
const onContinueClick = useCallback(
async (e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
setContinueDisabled(true);
try {
if (threepid.medium === ThreepidMedium.Email) {
await bindTask.current?.checkEmailLinkClicked();
} else {
await bindTask.current?.haveMsisdnToken(verificationCode);
}
setIsVerifyingBind(false);
onChange(threepid);
bindTask.current = undefined;
} catch (err) {
logger.error(`Unable to verify threepid:`, err);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title:
threepid.medium === "email"
? _t("settings|general|email_not_verified")
: _t("settings|general|error_msisdn_verification"),
description:
threepid.medium === "email"
? _t("settings|general|email_verification_instructions")
: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
} else {
logger.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
} finally {
setContinueDisabled(false);
}
},
[verificationCode, onChange, threepid],
);
const onVerificationCodeChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVerificationCode(e.target.value);
}, []);
if (isConfirming) {
return (
<div className="mx_AddRemoveThreepids_existing">
<span className="mx_AddRemoveThreepids_existing_promptText">
{threepid.medium === ThreepidMedium.Email
? _t("settings|general|remove_email_prompt", { email: threepid.address })
: _t("settings|general|remove_msisdn_prompt", { phone: threepid.address })}
</span>
<AccessibleButton
onClick={onConfirmRemoveClick}
kind="danger_sm"
className="mx_AddRemoveThreepids_existing_button"
>
{_t("action|remove")}
</AccessibleButton>
<AccessibleButton
onClick={onCancelClick}
kind="link_sm"
className="mx_AddRemoveThreepids_existing_button"
>
{_t("action|cancel")}
</AccessibleButton>
</div>
);
}
if (isVerifyingBind) {
if (threepid.medium === ThreepidMedium.Email) {
return (
<div className="mx_EmailAddressesPhoneNumbers_verify">
<span className="mx_EmailAddressesPhoneNumbers_verify_instructions">
{_t("settings|general|discovery_email_verification_instructions")}
</span>
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_existing_button"
kind="primary_sm"
onClick={onContinueClick}
disabled={continueDisabled}
>
{_t("action|complete")}
</AccessibleButton>
</div>
);
} else {
return (
<div className="mx_EmailAddressesPhoneNumbers_verify">
<span className="mx_EmailAddressesPhoneNumbers_verify_instructions">
{_t("settings|general|msisdn_verification_instructions")}
</span>
<form onSubmit={onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={continueDisabled}
value={verificationCode}
onChange={onVerificationCodeChange}
/>
</form>
</div>
);
}
}
return (
<div className="mx_AddRemoveThreepids_existing">
<span className="mx_AddRemoveThreepids_existing_address">{threepid.address}</span>
<AccessibleButton
onClick={mode === "hs" ? onRemoveClick : threepid.bound ? onRevokeClick : onShareClick}
kind={mode === "hs" || threepid.bound ? "danger_sm" : "primary_sm"}
disabled={disabled}
>
{mode === "hs" ? _t("action|remove") : threepid.bound ? _t("action|revoke") : _t("action|share")}
</AccessibleButton>
</div>
);
};
function isMsisdnResponse(
resp: IRequestTokenResponse | IRequestMsisdnTokenResponse,
): resp is IRequestMsisdnTokenResponse {
return (resp as IRequestMsisdnTokenResponse).msisdn !== undefined;
}
const AddThreepidSection: React.FC<{ medium: "email" | "msisdn"; disabled?: boolean; onChange: () => void }> = ({
medium,
disabled,
onChange,
}) => {
const addTask = useRef<AddThreepid | undefined>();
const [newThreepidInput, setNewThreepidInput] = useState("");
const [phoneCountryInput, setPhoneCountryInput] = useState("");
const [verificationCodeInput, setVerificationCodeInput] = useState("");
const [isVerifying, setIsVerifying] = useState(false);
const [continueDisabled, setContinueDisabled] = useState(false);
const [sentToMsisdn, setSentToMsisdn] = useState("");
const client = useMatrixClientContext();
const onPhoneCountryChanged = useCallback((country: PhoneNumberCountryDefinition) => {
setPhoneCountryInput(country.iso2);
}, []);
const onContinueClick = useCallback(
(e: ButtonEvent) => {
e.stopPropagation();
e.preventDefault();
if (!addTask.current) return;
setContinueDisabled(true);
const checkPromise =
medium === "email"
? addTask.current?.checkEmailLinkClicked()
: addTask.current?.haveMsisdnToken(verificationCodeInput);
checkPromise
.then(([finished]) => {
if (finished) {
addTask.current = undefined;
setIsVerifying(false);
setNewThreepidInput("");
onChange();
}
setContinueDisabled(false);
})
.catch((err) => {
logger.error("Unable to verify 3pid: ", err);
setContinueDisabled(false);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (
underlyingError instanceof MatrixError &&
underlyingError.errcode === "M_THREEPID_AUTH_FAILED"
) {
Modal.createDialog(ErrorDialog, {
title:
medium === "email"
? _t("settings|general|email_not_verified")
: _t("settings|general|error_msisdn_verification"),
description: _t("settings|general|email_verification_instructions"),
});
} else {
Modal.createDialog(ErrorDialog, {
title:
medium == "email"
? _t("settings|general|error_email_verification")
: _t("settings|general|error_msisdn_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
});
},
[onChange, medium, verificationCodeInput],
);
const onNewThreepidInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setNewThreepidInput(e.target.value);
}, []);
const onAddClick = useCallback(
(e: React.FormEvent) => {
e.stopPropagation();
e.preventDefault();
if (!newThreepidInput) return;
// TODO: Inline field validation
if (medium === "email" && !emailLooksValid(newThreepidInput)) {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_invalid_email"),
description: _t("settings|general|error_invalid_email_detail"),
});
return;
}
addTask.current = new AddThreepid(client);
setIsVerifying(true);
setContinueDisabled(true);
const addPromise =
medium === "email"
? addTask.current.addEmailAddress(newThreepidInput)
: addTask.current.addMsisdn(phoneCountryInput, newThreepidInput);
addPromise
.then((resp: IRequestTokenResponse | IRequestMsisdnTokenResponse) => {
setContinueDisabled(false);
if (isMsisdnResponse(resp)) {
setSentToMsisdn(resp.msisdn);
}
})
.catch((err) => {
logger.error(`Unable to add threepid ${newThreepidInput}`, err);
setIsVerifying(false);
setContinueDisabled(false);
addTask.current = undefined;
Modal.createDialog(ErrorDialog, {
title: medium === "email" ? _t("settings|general|error_add_email") : _t("common|error"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
},
[client, phoneCountryInput, newThreepidInput, medium],
);
const onVerificationCodeInputChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
setVerificationCodeInput(e.target.value);
}, []);
if (isVerifying && medium === "email") {
return (
<div>
<div>{_t("settings|general|add_email_instructions")}</div>
<AccessibleButton onClick={onContinueClick} kind="primary" disabled={continueDisabled}>
{_t("action|continue")}
</AccessibleButton>
</div>
);
} else if (isVerifying) {
return (
<div>
<div>
{_t("settings|general|add_msisdn_instructions", { msisdn: sentToMsisdn })}
<br />
</div>
<form onSubmit={onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={disabled || continueDisabled}
value={verificationCodeInput}
onChange={onVerificationCodeInputChange}
/>
<AccessibleButton
onClick={onContinueClick}
kind="primary"
disabled={disabled || continueDisabled || verificationCodeInput.length === 0}
>
{_t("action|continue")}
</AccessibleButton>
</form>
</div>
);
}
const phoneCountry =
medium === "msisdn" ? (
<CountryDropdown
onOptionChange={onPhoneCountryChanged}
className="mx_PhoneNumbers_country"
value={phoneCountryInput}
disabled={isVerifying}
isSmall={true}
showPrefix={true}
/>
) : undefined;
return (
<form onSubmit={onAddClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={
medium === "email"
? _t("settings|general|email_address_label")
: _t("settings|general|msisdn_label")
}
autoComplete={medium === "email" ? "email" : "tel-national"}
disabled={disabled || isVerifying}
value={newThreepidInput}
onChange={onNewThreepidInputChange}
prefixComponent={phoneCountry}
/>
<AccessibleButton onClick={onAddClick} kind="primary" disabled={disabled}>
{_t("action|add")}
</AccessibleButton>
</form>
);
};
interface AddRemoveThreepidsProps {
// Whether the control is for adding 3pids to the user's homeserver account or sharing them on an IS
mode: TheepidControlMode;
// Whether the control is for emails or phone numbers
medium: ThreepidMedium;
// The current list of third party identifiers
threepids: ThirdPartyIdentifier[];
// If true, the component is disabled and no third party identifiers can be added or removed
disabled?: boolean;
// Called when changes are made to the list of third party identifiers
onChange: () => void;
// If true, a spinner is shown instead of the component
isLoading: boolean;
}
export const AddRemoveThreepids: React.FC<AddRemoveThreepidsProps> = ({
mode,
medium,
threepids,
disabled,
onChange,
isLoading,
}) => {
if (isLoading) {
return <InlineSpinner />;
}
const existingEmailElements = threepids.map((e) => {
return <ExistingThreepid mode={mode} threepid={e} onChange={onChange} key={e.address} disabled={disabled} />;
});
return (
<>
{existingEmailElements}
{mode === "hs" && <AddThreepidSection medium={medium} disabled={disabled} onChange={onChange} />}
</>
);
};

View File

@@ -18,8 +18,6 @@ import React, { useCallback, useEffect, useState } from "react";
import { ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { Alert } from "@vector-im/compound-web";
import AccountEmailAddresses from "./account/EmailAddresses";
import AccountPhoneNumbers from "./account/PhoneNumbers";
import { _t } from "../../../languageHandler";
import InlineSpinner from "../elements/InlineSpinner";
import SettingsSubsection from "./shared/SettingsSubsection";
@@ -27,6 +25,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../AddThreepid";
import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature";
import { AddRemoveThreepids } from "./AddRemoveThreepids";
type LoadingState = "loading" | "loaded" | "error";
@@ -64,26 +63,28 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
const client = useMatrixClientContext();
useEffect(() => {
(async () => {
try {
const threepids = await client.getThreePids();
setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email));
setPhoneNumbers(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Phone));
setLoadingState("loaded");
} catch (e) {
setLoadingState("error");
}
})();
const updateThreepids = useCallback(async () => {
try {
const threepids = await client.getThreePids();
setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email));
setPhoneNumbers(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Phone));
setLoadingState("loaded");
} catch (e) {
setLoadingState("error");
}
}, [client]);
const onEmailsChange = useCallback((emails: ThirdPartyIdentifier[]) => {
setEmails(emails);
}, []);
useEffect(() => {
updateThreepids().then();
}, [updateThreepids]);
const onMsisdnsChange = useCallback((msisdns: ThirdPartyIdentifier[]) => {
setPhoneNumbers(msisdns);
}, []);
const onEmailsChange = useCallback(() => {
updateThreepids().then();
}, [updateThreepids]);
const onMsisdnsChange = useCallback(() => {
updateThreepids().then();
}, [updateThreepids]);
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
@@ -99,10 +100,13 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
error={_t("settings|general|unable_to_load_emails")}
loadingState={loadingState}
>
<AccountEmailAddresses
emails={emails!}
onEmailsChange={onEmailsChange}
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Email}
threepids={emails!}
onChange={onEmailsChange}
disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/>
</ThreepidSectionWrapper>
</SettingsSubsection>
@@ -116,10 +120,13 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
error={_t("settings|general|unable_to_load_msisdns")}
loadingState={loadingState}
>
<AccountPhoneNumbers
msisdns={phoneNumbers!}
onMsisdnsChange={onMsisdnsChange}
<AddRemoveThreepids
mode="hs"
medium={ThreepidMedium.Phone}
threepids={phoneNumbers!}
onChange={onMsisdnsChange}
disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/>
</ThreepidSectionWrapper>
</SettingsSubsection>

View File

@@ -1,303 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 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 { ThreepidMedium, MatrixError } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Field from "../../elements/Field";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import * as Email from "../../../../email";
import AddThreepid, { ThirdPartyIdentifier } from "../../../../AddThreepid";
import Modal from "../../../../Modal";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
/*
TODO: Improve the UX for everything in here.
It's very much placeholder, but it gets the job done. The old way of handling
email addresses in user settings was to use dialogs to communicate state, however
due to our dialog system overriding dialogs (causing unmounts) this creates problems
for a sane UX. For instance, the user could easily end up entering an email address
and receive a dialog to verify the address, which then causes the component here
to forget what it was doing and ultimately fail. Dialogs are still used in some
places to communicate errors - these should be replaced with inline validation when
that is available.
*/
interface IExistingEmailAddressProps {
email: ThirdPartyIdentifier;
onRemoved: (emails: ThirdPartyIdentifier) => void;
/**
* Disallow removal of this email address when truthy
*/
disabled?: boolean;
}
interface IExistingEmailAddressState {
verifyRemove: boolean;
}
export class ExistingEmailAddress extends React.Component<IExistingEmailAddressProps, IExistingEmailAddressState> {
public constructor(props: IExistingEmailAddressProps) {
super(props);
this.state = {
verifyRemove: false,
};
}
private onRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: true });
};
private onDontRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: false });
};
private onActuallyRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
MatrixClientPeg.safeGet()
.deleteThreePid(this.props.email.medium, this.props.email.address)
.then(() => {
return this.props.onRemoved(this.props.email);
})
.catch((err) => {
logger.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_remove_3pid"),
description: err && err.message ? err.message : _t("invite|failed_generic"),
});
});
};
public render(): React.ReactNode {
if (this.state.verifyRemove) {
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_promptText">
{_t("settings|general|remove_email_prompt", { email: this.props.email.address })}
</span>
<AccessibleButton
onClick={this.onActuallyRemove}
kind="danger_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|remove")}
</AccessibleButton>
<AccessibleButton
onClick={this.onDontRemove}
kind="link_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|cancel")}
</AccessibleButton>
</div>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">
{this.props.email.address}
</span>
<AccessibleButton onClick={this.onRemove} kind="danger_sm" disabled={this.props.disabled}>
{_t("action|remove")}
</AccessibleButton>
</div>
);
}
}
interface IProps {
emails: ThirdPartyIdentifier[];
onEmailsChange: (emails: ThirdPartyIdentifier[]) => void;
/**
* Adding or removing emails is disabled when truthy
*/
disabled?: boolean;
}
interface IState {
verifying: boolean;
addTask: AddThreepid | null;
continueDisabled: boolean;
newEmailAddress: string;
}
export default class EmailAddresses extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
verifying: false,
addTask: null,
continueDisabled: false,
newEmailAddress: "",
};
}
private onRemoved = (address: ThirdPartyIdentifier): void => {
const emails = this.props.emails.filter((e) => e !== address);
this.props.onEmailsChange(emails);
};
private onChangeNewEmailAddress = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newEmailAddress: e.target.value,
});
};
private onAddClick = (e: React.FormEvent): void => {
e.stopPropagation();
e.preventDefault();
if (!this.state.newEmailAddress) return;
const email = this.state.newEmailAddress;
// TODO: Inline field validation
if (!Email.looksValid(email)) {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_invalid_email"),
description: _t("settings|general|error_invalid_email_detail"),
});
return;
}
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({ verifying: true, continueDisabled: true, addTask: task });
task.addEmailAddress(email)
.then(() => {
this.setState({ continueDisabled: false });
})
.catch((err) => {
logger.error("Unable to add email address " + email + " " + err);
this.setState({ verifying: false, continueDisabled: false, addTask: null });
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_add_email"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
};
private onContinueClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ continueDisabled: true });
this.state.addTask
?.checkEmailLinkClicked()
.then(([finished]) => {
let newEmailAddress = this.state.newEmailAddress;
if (finished) {
const email = this.state.newEmailAddress;
const emails = [...this.props.emails, { address: email, medium: ThreepidMedium.Email }];
this.props.onEmailsChange(emails);
newEmailAddress = "";
}
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
newEmailAddress,
});
})
.catch((err) => {
logger.error("Unable to verify email address: ", err);
this.setState({ continueDisabled: false });
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|email_not_verified"),
description: _t("settings|general|email_verification_instructions"),
});
} else {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
});
};
public render(): React.ReactNode {
const existingEmailElements = this.props.emails.map((e) => {
return (
<ExistingEmailAddress
email={e}
onRemoved={this.onRemoved}
key={e.address}
disabled={this.props.disabled}
/>
);
});
let addButton = (
<AccessibleButton onClick={this.onAddClick} kind="primary" disabled={this.props.disabled}>
{_t("action|add")}
</AccessibleButton>
);
if (this.state.verifying) {
addButton = (
<div>
<div>{_t("settings|general|add_email_instructions")}</div>
<AccessibleButton
onClick={this.onContinueClick}
kind="primary"
disabled={this.state.continueDisabled}
>
{_t("action|continue")}
</AccessibleButton>
</div>
);
}
return (
<>
{existingEmailElements}
<form onSubmit={this.onAddClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|email_address_label")}
autoComplete="email"
disabled={this.props.disabled || this.state.verifying}
value={this.state.newEmailAddress}
onChange={this.onChangeNewEmailAddress}
/>
{addButton}
</form>
</>
);
}
}

View File

@@ -1,342 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 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 { IAuthData, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Field from "../../elements/Field";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
import AddThreepid, { ThirdPartyIdentifier } from "../../../../AddThreepid";
import CountryDropdown from "../../auth/CountryDropdown";
import Modal from "../../../../Modal";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
import { PhoneNumberCountryDefinition } from "../../../../phonenumber";
/*
TODO: Improve the UX for everything in here.
This is a copy/paste of EmailAddresses, mostly.
*/
// TODO: Combine EmailAddresses and PhoneNumbers to be 3pid agnostic
interface IExistingPhoneNumberProps {
msisdn: ThirdPartyIdentifier;
onRemoved: (phoneNumber: ThirdPartyIdentifier) => void;
/**
* Disable removing phone number
*/
disabled?: boolean;
}
interface IExistingPhoneNumberState {
verifyRemove: boolean;
}
export class ExistingPhoneNumber extends React.Component<IExistingPhoneNumberProps, IExistingPhoneNumberState> {
public constructor(props: IExistingPhoneNumberProps) {
super(props);
this.state = {
verifyRemove: false,
};
}
private onRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: true });
};
private onDontRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ verifyRemove: false });
};
private onActuallyRemove = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
MatrixClientPeg.safeGet()
.deleteThreePid(this.props.msisdn.medium, this.props.msisdn.address)
.then(() => {
return this.props.onRemoved(this.props.msisdn);
})
.catch((err) => {
logger.error("Unable to remove contact information: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_remove_3pid"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
};
public render(): React.ReactNode {
if (this.state.verifyRemove) {
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_promptText">
{_t("settings|general|remove_msisdn_prompt", { phone: this.props.msisdn.address })}
</span>
<AccessibleButton
onClick={this.onActuallyRemove}
kind="danger_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|remove")}
</AccessibleButton>
<AccessibleButton
onClick={this.onDontRemove}
kind="link_sm"
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
>
{_t("action|cancel")}
</AccessibleButton>
</div>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">
+{this.props.msisdn.address}
</span>
<AccessibleButton onClick={this.onRemove} kind="danger_sm" disabled={this.props.disabled}>
{_t("action|remove")}
</AccessibleButton>
</div>
);
}
}
interface IProps {
msisdns: ThirdPartyIdentifier[];
onMsisdnsChange: (phoneNumbers: ThirdPartyIdentifier[]) => void;
/**
* Adding or removing phone numbers is disabled when truthy
*/
disabled?: boolean;
}
interface IState {
verifying: boolean;
verifyError: string | null;
verifyMsisdn: string;
addTask: AddThreepid | null;
continueDisabled: boolean;
phoneCountry: string;
newPhoneNumber: string;
newPhoneNumberCode: string;
}
export default class PhoneNumbers extends React.Component<IProps, IState> {
public constructor(props: IProps) {
super(props);
this.state = {
verifying: false,
verifyError: null,
verifyMsisdn: "",
addTask: null,
continueDisabled: false,
phoneCountry: "",
newPhoneNumber: "",
newPhoneNumberCode: "",
};
}
private onRemoved = (address: ThirdPartyIdentifier): void => {
const msisdns = this.props.msisdns.filter((e) => e !== address);
this.props.onMsisdnsChange(msisdns);
};
private onChangeNewPhoneNumber = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPhoneNumber: e.target.value,
});
};
private onChangeNewPhoneNumberCode = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
newPhoneNumberCode: e.target.value,
});
};
private onAddClick = (e: ButtonEvent | React.FormEvent): void => {
e.stopPropagation();
e.preventDefault();
if (!this.state.newPhoneNumber) return;
const phoneNumber = this.state.newPhoneNumber;
const phoneCountry = this.state.phoneCountry;
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({ verifying: true, continueDisabled: true, addTask: task });
task.addMsisdn(phoneCountry, phoneNumber)
.then((response) => {
this.setState({ continueDisabled: false, verifyMsisdn: response.msisdn });
})
.catch((err) => {
logger.error("Unable to add phone number " + phoneNumber + " " + err);
this.setState({ verifying: false, continueDisabled: false, addTask: null });
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
});
};
private onContinueClick = (e: ButtonEvent | React.FormEvent): void => {
e.stopPropagation();
e.preventDefault();
this.setState({ continueDisabled: true });
const token = this.state.newPhoneNumberCode;
const address = this.state.verifyMsisdn;
this.state.addTask
?.haveMsisdnToken(token)
.then(([finished]: [success?: boolean, result?: IAuthData | Error | null] = []) => {
let newPhoneNumber = this.state.newPhoneNumber;
if (finished !== false) {
const msisdns = [...this.props.msisdns, { address, medium: ThreepidMedium.Phone }];
this.props.onMsisdnsChange(msisdns);
newPhoneNumber = "";
}
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
verifyMsisdn: "",
verifyError: null,
newPhoneNumber,
newPhoneNumberCode: "",
});
})
.catch((err) => {
logger.error("Unable to verify phone number: " + err);
this.setState({ continueDisabled: false });
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError.errcode !== "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_msisdn_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
} else {
this.setState({ verifyError: _t("settings|general|incorrect_msisdn_verification") });
}
});
};
private onCountryChanged = (country: PhoneNumberCountryDefinition): void => {
this.setState({ phoneCountry: country.iso2 });
};
public render(): React.ReactNode {
const existingPhoneElements = this.props.msisdns.map((p) => {
return (
<ExistingPhoneNumber
msisdn={p}
onRemoved={this.onRemoved}
key={p.address}
disabled={this.props.disabled}
/>
);
});
let addVerifySection = (
<AccessibleButton onClick={this.onAddClick} kind="primary" disabled={this.props.disabled}>
{_t("action|add")}
</AccessibleButton>
);
if (this.state.verifying) {
const msisdn = this.state.verifyMsisdn;
addVerifySection = (
<div>
<div>
{_t("settings|general|add_msisdn_instructions", { msisdn: msisdn })}
<br />
{this.state.verifyError}
</div>
<form onSubmit={this.onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={this.props.disabled || this.state.continueDisabled}
value={this.state.newPhoneNumberCode}
onChange={this.onChangeNewPhoneNumberCode}
/>
<AccessibleButton
onClick={this.onContinueClick}
kind="primary"
disabled={
this.props.disabled ||
this.state.continueDisabled ||
this.state.newPhoneNumberCode.length === 0
}
>
{_t("action|continue")}
</AccessibleButton>
</form>
</div>
);
}
const phoneCountry = (
<CountryDropdown
onOptionChange={this.onCountryChanged}
className="mx_PhoneNumbers_country"
value={this.state.phoneCountry}
disabled={this.state.verifying}
isSmall={true}
showPrefix={true}
/>
);
return (
<>
{existingPhoneElements}
<form onSubmit={this.onAddClick} autoComplete="off" noValidate={true} className="mx_PhoneNumbers_new">
<div className="mx_PhoneNumbers_input">
<Field
type="text"
label={_t("settings|general|msisdn_label")}
autoComplete="tel-national"
disabled={this.props.disabled || this.state.verifying}
prefixComponent={phoneCountry}
value={this.state.newPhoneNumber}
onChange={this.onChangeNewPhoneNumber}
/>
</div>
</form>
{addVerifySection}
</>
);
}
}

View File

@@ -19,8 +19,6 @@ import { SERVICE_TYPES, ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { Alert } from "@vector-im/compound-web";
import DiscoveryEmailAddresses from "../discovery/EmailAddresses";
import DiscoveryPhoneNumbers from "../discovery/PhoneNumbers";
import { getThreepidsWithBindStatus } from "../../../../boundThreepids";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../../AddThreepid";
@@ -36,6 +34,7 @@ import { abbreviateUrl } from "../../../../utils/UrlUtils";
import { useDispatcher } from "../../../../hooks/useDispatcher";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../../dispatcher/payloads";
import { AddRemoveThreepids } from "../AddRemoveThreepids";
type RequiredPolicyInfo =
| {
@@ -56,9 +55,9 @@ type RequiredPolicyInfo =
export const DiscoverySettings: React.FC = () => {
const client = useMatrixClientContext();
const [isLoadingThreepids, setIsLoadingThreepids] = useState<boolean>(true);
const [emails, setEmails] = useState<ThirdPartyIdentifier[]>([]);
const [phoneNumbers, setPhoneNumbers] = useState<ThirdPartyIdentifier[]>([]);
const [loadingState, setLoadingState] = useState<"loading" | "loaded" | "error">("loading");
const [idServerName, setIdServerName] = useState<string | undefined>(abbreviateUrl(client.getIdentityServerUrl()));
const [canMake3pidChanges, setCanMake3pidChanges] = useState<boolean>(false);
@@ -71,9 +70,11 @@ export const DiscoverySettings: React.FC = () => {
const [hasTerms, setHasTerms] = useState<boolean>(false);
const getThreepidState = useCallback(async () => {
setIsLoadingThreepids(true);
const threepids = await getThreepidsWithBindStatus(client);
setEmails(threepids.filter((a) => a.medium === ThreepidMedium.Email));
setPhoneNumbers(threepids.filter((a) => a.medium === ThreepidMedium.Phone));
setIsLoadingThreepids(false);
}, [client]);
useDispatcher(
@@ -133,11 +134,7 @@ export const DiscoverySettings: React.FC = () => {
);
logger.warn(e);
}
setLoadingState("loaded");
} catch (e) {
setLoadingState("error");
}
} catch (e) {}
})();
}, [client, getThreepidState]);
@@ -163,23 +160,44 @@ export const DiscoverySettings: React.FC = () => {
);
}
const threepidSection = idServerName ? (
<>
<DiscoveryEmailAddresses
emails={emails}
isLoading={loadingState === "loading"}
disabled={!canMake3pidChanges}
/>
<DiscoveryPhoneNumbers
msisdns={phoneNumbers}
isLoading={loadingState === "loading"}
disabled={!canMake3pidChanges}
/>
</>
) : null;
let threepidSection;
if (idServerName) {
threepidSection = (
<>
<SettingsSubsection
heading={_t("settings|general|emails_heading")}
description={emails.length === 0 ? _t("settings|general|discovery_email_empty") : undefined}
stretchContent
>
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={emails}
onChange={getThreepidState}
disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/>
</SettingsSubsection>
<SettingsSubsection
heading={_t("settings|general|msisdns_heading")}
description={phoneNumbers.length === 0 ? _t("settings|general|discovery_msisdn_empty") : undefined}
stretchContent
>
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Phone}
threepids={phoneNumbers}
onChange={getThreepidState}
disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/>
</SettingsSubsection>
</>
);
}
return (
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection">
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection" stretchContent>
{threepidSection}
{/* has its own heading as it includes the current identity server */}
<SetIdServer missingTerms={false} />

View File

@@ -1,251 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 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 { logger } from "matrix-js-sdk/src/logger";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Modal from "../../../../Modal";
import AddThreepid, { Binding, ThirdPartyIdentifier } from "../../../../AddThreepid";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
import SettingsSubsection from "../shared/SettingsSubsection";
import InlineSpinner from "../../elements/InlineSpinner";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
/*
TODO: Improve the UX for everything in here.
It's very much placeholder, but it gets the job done. The old way of handling
email addresses in user settings was to use dialogs to communicate state, however
due to our dialog system overriding dialogs (causing unmounts) this creates problems
for a sane UX. For instance, the user could easily end up entering an email address
and receive a dialog to verify the address, which then causes the component here
to forget what it was doing and ultimately fail. Dialogs are still used in some
places to communicate errors - these should be replaced with inline validation when
that is available.
*/
/*
TODO: Reduce all the copying between account vs. discovery components.
*/
interface IEmailAddressProps {
email: ThirdPartyIdentifier;
disabled?: boolean;
}
interface IEmailAddressState {
verifying: boolean;
addTask: AddThreepid | null;
continueDisabled: boolean;
bound?: boolean;
}
export class EmailAddress extends React.Component<IEmailAddressProps, IEmailAddressState> {
public constructor(props: IEmailAddressProps) {
super(props);
const { bound } = props.email;
this.state = {
verifying: false,
addTask: null,
continueDisabled: false,
bound,
};
}
public componentDidUpdate(prevProps: Readonly<IEmailAddressProps>): void {
if (this.props.email !== prevProps.email) {
const { bound } = this.props.email;
this.setState({ bound });
}
}
private async changeBinding({ bind, label, errorTitle }: Binding): Promise<void> {
const { medium, address } = this.props.email;
try {
if (bind) {
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({
verifying: true,
continueDisabled: true,
addTask: task,
});
await task.bindEmailAddress(address);
this.setState({
continueDisabled: false,
});
} else {
await MatrixClientPeg.safeGet().unbindThreePid(medium, address);
}
this.setState({ bound: bind });
} catch (err) {
logger.error(`changeBinding: Unable to ${label} email address ${address}`, err);
this.setState({
verifying: false,
continueDisabled: false,
addTask: null,
});
Modal.createDialog(ErrorDialog, {
title: errorTitle,
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
}
private onRevokeClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: false,
label: "revoke",
errorTitle: _t("settings|general|error_revoke_email_discovery"),
});
};
private onShareClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: true,
label: "share",
errorTitle: _t("settings|general|error_share_email_discovery"),
});
};
private onContinueClick = async (e: ButtonEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
// Prevent the continue button from being pressed multiple times while we're working
this.setState({ continueDisabled: true });
try {
await this.state.addTask?.checkEmailLinkClicked();
this.setState({
addTask: null,
verifying: false,
});
} catch (err) {
logger.error(`Unable to verify email address:`, err);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
if (underlyingError instanceof MatrixError && underlyingError.errcode === "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|email_not_verified"),
description: _t("settings|general|email_verification_instructions"),
});
} else {
logger.error("Unable to verify email address: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_email_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
} finally {
// Re-enable the continue button so the user can retry
this.setState({ continueDisabled: false });
}
};
public render(): React.ReactNode {
const { address } = this.props.email;
const { verifying, bound } = this.state;
let status;
if (verifying) {
status = (
<span>
{_t("settings|general|discovery_email_verification_instructions")}
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="primary_sm"
onClick={this.onContinueClick}
disabled={this.state.continueDisabled}
>
{_t("action|complete")}
</AccessibleButton>
</span>
);
} else if (bound) {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="danger_sm"
onClick={this.onRevokeClick}
disabled={this.props.disabled}
>
{_t("action|revoke")}
</AccessibleButton>
);
} else {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="primary_sm"
onClick={this.onShareClick}
disabled={this.props.disabled}
>
{_t("action|share")}
</AccessibleButton>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">{address}</span>
{status}
</div>
);
}
}
interface IProps {
emails: ThirdPartyIdentifier[];
isLoading?: boolean;
disabled?: boolean;
}
export default class EmailAddresses extends React.Component<IProps> {
public render(): React.ReactNode {
let content;
if (this.props.isLoading) {
content = <InlineSpinner />;
} else if (this.props.emails.length > 0) {
content = this.props.emails.map((e) => {
return <EmailAddress email={e} key={e.address} disabled={this.props.disabled} />;
});
}
const hasEmails = !!this.props.emails.length;
return (
<SettingsSubsection
heading={_t("settings|general|emails_heading")}
description={(!hasEmails && _t("settings|general|discovery_email_empty")) || undefined}
stretchContent
>
{content}
</SettingsSubsection>
);
}
}

View File

@@ -1,263 +0,0 @@
/*
Copyright 2019 New Vector Ltd
Copyright 2019 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 { logger } from "matrix-js-sdk/src/logger";
import { MatrixError } from "matrix-js-sdk/src/matrix";
import { _t, UserFriendlyError } from "../../../../languageHandler";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import Modal from "../../../../Modal";
import AddThreepid, { Binding, ThirdPartyIdentifier } from "../../../../AddThreepid";
import ErrorDialog, { extractErrorMessageFromError } from "../../dialogs/ErrorDialog";
import Field from "../../elements/Field";
import SettingsSubsection from "../shared/SettingsSubsection";
import InlineSpinner from "../../elements/InlineSpinner";
import AccessibleButton, { ButtonEvent } from "../../elements/AccessibleButton";
/*
TODO: Improve the UX for everything in here.
This is a copy/paste of EmailAddresses, mostly.
*/
// TODO: Combine EmailAddresses and PhoneNumbers to be 3pid agnostic
interface IPhoneNumberProps {
msisdn: ThirdPartyIdentifier;
disabled?: boolean;
}
interface IPhoneNumberState {
verifying: boolean;
verificationCode: string;
addTask: AddThreepid | null;
continueDisabled: boolean;
bound?: boolean;
verifyError: string | null;
}
export class PhoneNumber extends React.Component<IPhoneNumberProps, IPhoneNumberState> {
public constructor(props: IPhoneNumberProps) {
super(props);
const { bound } = props.msisdn;
this.state = {
verifying: false,
verificationCode: "",
addTask: null,
continueDisabled: false,
bound,
verifyError: null,
};
}
public componentDidUpdate(prevProps: Readonly<IPhoneNumberProps>): void {
if (this.props.msisdn !== prevProps.msisdn) {
const { bound } = this.props.msisdn;
this.setState({ bound });
}
}
private async changeBinding({ bind, label, errorTitle }: Binding): Promise<void> {
const { medium, address } = this.props.msisdn;
try {
if (bind) {
const task = new AddThreepid(MatrixClientPeg.safeGet());
this.setState({
verifying: true,
continueDisabled: true,
addTask: task,
});
// XXX: Sydent will accept a number without country code if you add
// a leading plus sign to a number in E.164 format (which the 3PID
// address is), but this goes against the spec.
// See https://github.com/matrix-org/matrix-doc/issues/2222
// @ts-ignore
await task.bindMsisdn(null, `+${address}`);
this.setState({
continueDisabled: false,
});
} else {
await MatrixClientPeg.safeGet().unbindThreePid(medium, address);
}
this.setState({ bound: bind });
} catch (err) {
logger.error(`changeBinding: Unable to ${label} phone number ${address}`, err);
this.setState({
verifying: false,
continueDisabled: false,
addTask: null,
});
Modal.createDialog(ErrorDialog, {
title: errorTitle,
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
}
}
private onRevokeClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: false,
label: "revoke",
errorTitle: _t("settings|general|error_revoke_msisdn_discovery"),
});
};
private onShareClick = (e: ButtonEvent): void => {
e.stopPropagation();
e.preventDefault();
this.changeBinding({
bind: true,
label: "share",
errorTitle: _t("settings|general|error_share_msisdn_discovery"),
});
};
private onVerificationCodeChange = (e: React.ChangeEvent<HTMLInputElement>): void => {
this.setState({
verificationCode: e.target.value,
});
};
private onContinueClick = async (e: ButtonEvent | React.FormEvent): Promise<void> => {
e.stopPropagation();
e.preventDefault();
this.setState({ continueDisabled: true });
const token = this.state.verificationCode;
try {
await this.state.addTask?.haveMsisdnToken(token);
this.setState({
addTask: null,
continueDisabled: false,
verifying: false,
verifyError: null,
verificationCode: "",
});
} catch (err) {
logger.error("Unable to verify phone number:", err);
let underlyingError = err;
if (err instanceof UserFriendlyError) {
underlyingError = err.cause;
}
this.setState({ continueDisabled: false });
if (underlyingError instanceof MatrixError && underlyingError.errcode !== "M_THREEPID_AUTH_FAILED") {
Modal.createDialog(ErrorDialog, {
title: _t("settings|general|error_msisdn_verification"),
description: extractErrorMessageFromError(err, _t("invite|failed_generic")),
});
} else {
this.setState({ verifyError: _t("settings|general|incorrect_msisdn_verification") });
}
}
};
public render(): React.ReactNode {
const { address } = this.props.msisdn;
const { verifying, bound } = this.state;
let status;
if (verifying) {
status = (
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_verification">
<span>
{_t("settings|general|msisdn_verification_instructions")}
<br />
{this.state.verifyError}
</span>
<form onSubmit={this.onContinueClick} autoComplete="off" noValidate={true}>
<Field
type="text"
label={_t("settings|general|msisdn_verification_field_label")}
autoComplete="off"
disabled={this.state.continueDisabled}
value={this.state.verificationCode}
onChange={this.onVerificationCodeChange}
/>
</form>
</span>
);
} else if (bound) {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="danger_sm"
onClick={this.onRevokeClick}
disabled={this.props.disabled}
>
{_t("action|revoke")}
</AccessibleButton>
);
} else {
status = (
<AccessibleButton
className="mx_EmailAddressesPhoneNumbers_discovery_existing_button"
kind="primary_sm"
onClick={this.onShareClick}
disabled={this.props.disabled}
>
{_t("action|share")}
</AccessibleButton>
);
}
return (
<div className="mx_EmailAddressesPhoneNumbers_discovery_existing">
<span className="mx_EmailAddressesPhoneNumbers_discovery_existing_address">+{address}</span>
{status}
</div>
);
}
}
interface IProps {
msisdns: ThirdPartyIdentifier[];
isLoading?: boolean;
disabled?: boolean;
}
export default class PhoneNumbers extends React.Component<IProps> {
public render(): React.ReactNode {
let content;
if (this.props.isLoading) {
content = <InlineSpinner />;
} else if (this.props.msisdns.length > 0) {
content = this.props.msisdns.map((e) => {
return <PhoneNumber msisdn={e} key={e.address} disabled={this.props.disabled} />;
});
}
const description = (!content && _t("settings|general|discovery_msisdn_empty")) || undefined;
return (
<SettingsSubsection
data-testid="mx_DiscoveryPhoneNumbers"
heading={_t("settings|general|msisdns_heading")}
description={description}
stretchContent
>
{content}
</SettingsSubsection>
);
}
}

View File

@@ -1792,8 +1792,6 @@
"export_chat_button": "Exportovat chat",
"files_button": "Soubory",
"pinned_messages": {
"empty": "Zatím není nic připnuto",
"explainer": "Pokud máte oprávnění, otevřete nabídku na libovolné zprávě a výběrem možnosti <b>Připnout</b> je sem vložte.",
"limits": {
"other": "Můžete připnout až %(count)s widgetů"
},
@@ -2454,7 +2452,6 @@
"error_share_msisdn_discovery": "Nepovedlo se nasdílet telefonní číslo",
"identity_server_no_token": "Nebyl nalezen žádný přístupový token identity",
"identity_server_not_set": "Server identit není nastaven",
"incorrect_msisdn_verification": "Nesprávný ověřovací kód",
"language_section": "Jazyk a region",
"msisdn_in_use": "Toto telefonní číslo je již používáno",
"msisdn_label": "Telefonní číslo",

View File

@@ -1778,8 +1778,6 @@
"export_chat_button": "Unterhaltung exportieren",
"files_button": "Dateien",
"pinned_messages": {
"empty": "Es ist nichts angepinnt. Noch nicht.",
"explainer": "Sofern du die Berechtigung hast, öffne das Menü einer Nachricht und wähle <b>Anheften</b>, um sie hier aufzubewahren.",
"limits": {
"other": "Du kannst nur %(count)s Widgets anheften"
},
@@ -2433,7 +2431,6 @@
"error_share_msisdn_discovery": "Teilen der Telefonnummer nicht möglich",
"identity_server_no_token": "Kein Identitäts-Zugangs-Token gefunden",
"identity_server_not_set": "Kein Identitäts-Server festgelegt",
"incorrect_msisdn_verification": "Falscher Verifizierungscode",
"language_section": "Sprache und Region",
"msisdn_in_use": "Diese Telefonnummer wird bereits verwendet",
"msisdn_label": "Telefonnummer",

View File

@@ -1427,8 +1427,6 @@
"export_chat_button": "Εξαγωγή συνομιλίας",
"files_button": "Αρχεία",
"pinned_messages": {
"empty": "Δεν έχει καρφιτσωθεί κάτι ακόμα",
"explainer": "Εάν έχετε δικαιώματα, ανοίξτε το μενού σε οποιοδήποτε μήνυμα και επιλέξτε <b>Καρφίτσωμα</b> για να τα κολλήσετε εδώ.",
"limits": {
"other": "Μπορείτε να καρφιτσώσετε μόνο έως %(count)s μικρεοεφαρμογές"
},
@@ -1965,7 +1963,6 @@
"error_revoke_msisdn_discovery": "Αδυναμία ανάκληση της κοινής χρήσης για τον αριθμό τηλεφώνου",
"error_share_email_discovery": "Δεν είναι δυνατή η κοινή χρήση της διεύθυνσης email",
"error_share_msisdn_discovery": "Αδυναμία κοινής χρήσης του αριθμού τηλεφώνου",
"incorrect_msisdn_verification": "Λανθασμένος κωδικός επαλήθευσης",
"language_section": "Γλώσσα και περιοχή",
"msisdn_in_use": "Αυτός ο αριθμός τηλεφώνου είναι ήδη σε χρήση",
"msisdn_label": "Αριθμός Τηλεφώνου",

View File

@@ -1839,12 +1839,25 @@
"files_button": "Files",
"info": "Info",
"pinned_messages": {
"empty": "Nothing pinned, yet",
"explainer": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
"empty_description": "Select a message and choose “%(pinAction)s” to it include here.",
"empty_title": "Pin important messages so that they can be easily discovered",
"header": {
"one": "1 Pinned message",
"other": "%(count)s Pinned messages",
"zero": "Pinned message"
},
"limits": {
"other": "You can only pin up to %(count)s widgets"
},
"title": "Pinned messages"
"menu": "Open menu",
"reply_thread": "Reply to a <link>thread message</link>",
"title": "Pinned messages",
"unpin_all": {
"button": "Unpin all messages",
"content": "Make sure that you really want to remove all pinned messages. This action cant be undone.",
"title": "Unpin all messages?"
},
"view": "View in timeline"
},
"pinned_messages_button": "Pinned messages",
"poll": {
@@ -2532,7 +2545,6 @@
"error_share_msisdn_discovery": "Unable to share phone number",
"identity_server_no_token": "No identity access token found",
"identity_server_not_set": "Identity server not set",
"incorrect_msisdn_verification": "Incorrect verification code",
"language_section": "Language",
"msisdn_in_use": "This phone number is already in use",
"msisdn_label": "Phone Number",

View File

@@ -1290,8 +1290,6 @@
"export_chat_button": "Eksporti babilejon",
"files_button": "Dosieroj",
"pinned_messages": {
"empty": "Ankoraŭ nenio fiksita",
"explainer": "Se vi havas la bezonajn permesojn, malfermu la menuon sur ajna mesaĝo, kaj klaku al <b>Fiksi</b> por meti ĝin ĉi tien.",
"limits": {
"other": "Vi povas fiksi maksimume %(count)s fenestraĵojn"
},
@@ -1754,7 +1752,6 @@
"error_revoke_msisdn_discovery": "Ne povas senvalidigi havigadon je telefonnumero",
"error_share_email_discovery": "Ne povas havigi vian retpoŝtadreson",
"error_share_msisdn_discovery": "Ne povas havigi telefonnumeron",
"incorrect_msisdn_verification": "Malĝusta kontrola kodo",
"language_section": "Lingvo kaj regiono",
"msisdn_in_use": "Tiu ĉi telefonnumero jam estas uzata",
"msisdn_label": "Telefonnumero",

View File

@@ -1651,8 +1651,6 @@
"export_chat_button": "Exportar conversación",
"files_button": "Archivos",
"pinned_messages": {
"empty": "Ningún mensaje fijado… todavía",
"explainer": "Si tienes permisos, abre el menú de cualquier mensaje y selecciona <b>Fijar</b> para colocarlo aquí.",
"limits": {
"other": "Solo puedes anclar hasta %(count)s accesorios"
},
@@ -2248,7 +2246,6 @@
"error_share_email_discovery": "No se logró compartir la dirección de correo electrónico",
"error_share_msisdn_discovery": "No se logró compartir el número de teléfono",
"identity_server_not_set": "Servidor de identidad no configurado",
"incorrect_msisdn_verification": "Verificación de código incorrecta",
"language_section": "Idioma y región",
"msisdn_in_use": "Este número de teléfono ya está en uso",
"msisdn_label": "Número de teléfono",

View File

@@ -1775,8 +1775,6 @@
"export_chat_button": "Ekspordi vestlus",
"files_button": "Failid",
"pinned_messages": {
"empty": "Klammerdatud sõnumeid veel pole",
"explainer": "Kui sul on vastavad õigused olemas, siis ava sõnumi juuresolev menüü ning püsisõnumi tekitamiseks vali <b>Klammerda</b>.",
"limits": {
"other": "Sa saad kinnitada kuni %(count)s vidinat"
},
@@ -2416,7 +2414,6 @@
"error_share_msisdn_discovery": "Telefoninumbri jagamine ei õnnestunud",
"identity_server_no_token": "Ei leidu tunnusluba isikutuvastusserveri jaoks",
"identity_server_not_set": "Isikutuvastusserver on määramata",
"incorrect_msisdn_verification": "Vigane verifikatsioonikood",
"language_section": "Keel ja piirkond",
"msisdn_in_use": "See telefoninumber on juba kasutusel",
"msisdn_label": "Telefoninumber",

View File

@@ -1545,7 +1545,6 @@
"error_share_email_discovery": "به اشتراک‌گذاری آدرس ایمیل ممکن نیست",
"error_share_msisdn_discovery": "امکان به اشتراک‌گذاری شماره تلفن وجود ندارد",
"identity_server_not_set": "سرور هویت تنظیم نشده است",
"incorrect_msisdn_verification": "کد فعال‌سازی اشتباه است",
"language_section": "زبان و جغرافیا",
"msisdn_in_use": "این شماره تلفن در حال استفاده است",
"msisdn_label": "شماره تلفن",

View File

@@ -1547,7 +1547,6 @@
"export_chat_button": "Vie keskustelu",
"files_button": "Tiedostot",
"pinned_messages": {
"empty": "Ei mitään kiinnitetty, ei vielä",
"limits": {
"other": "Voit kiinnittää enintään %(count)s sovelmaa"
},
@@ -2134,7 +2133,6 @@
"error_share_email_discovery": "Sähköpostiosoitetta ei voi jakaa",
"error_share_msisdn_discovery": "Puhelinnumeroa ei voi jakaa",
"identity_server_not_set": "Identiteettipalvelinta ei ole asetettu",
"incorrect_msisdn_verification": "Virheellinen varmennuskoodi",
"language_section": "Kieli ja alue",
"msisdn_in_use": "Puhelinnumero on jo käytössä",
"msisdn_label": "Puhelinnumero",

View File

@@ -1829,8 +1829,6 @@
"export_chat_button": "Exporter la conversation",
"files_button": "Fichiers",
"pinned_messages": {
"empty": "Rien dépinglé, pour linstant",
"explainer": "Si vous avez les permissions, ouvrez le menu de nimporte quel message et sélectionnez <b>Épingler</b> pour les afficher ici.",
"limits": {
"other": "Vous ne pouvez épingler que jusquà %(count)s widgets"
},
@@ -2484,7 +2482,6 @@
"error_share_msisdn_discovery": "Impossible de partager le numéro de téléphone",
"identity_server_no_token": "Aucun jeton daccès didentité trouvé",
"identity_server_not_set": "Serveur d'identité non défini",
"incorrect_msisdn_verification": "Code de vérification incorrect",
"language_section": "Langue et région",
"msisdn_in_use": "Ce numéro de téléphone est déjà utilisé",
"msisdn_label": "Numéro de téléphone",

View File

@@ -1536,8 +1536,6 @@
"export_chat_button": "Exportar chat",
"files_button": "Ficheiros",
"pinned_messages": {
"empty": "Nada fixado, por agora",
"explainer": "Se tes permisos, abre o menú en calquera mensaxe e elixe <b>Fixar</b> para pegalos aquí.",
"limits": {
"other": "Só podes fixar ata %(count)s widgets"
},
@@ -2081,7 +2079,6 @@
"error_revoke_msisdn_discovery": "Non se puido revogar a compartición do número de teléfono",
"error_share_email_discovery": "Non se puido compartir co enderezo de email",
"error_share_msisdn_discovery": "Non se puido compartir o número de teléfono",
"incorrect_msisdn_verification": "Código de verificación incorrecto",
"language_section": "Idioma e rexión",
"msisdn_in_use": "Xa se está a usar este teléfono",
"msisdn_label": "Número de teléfono",

View File

@@ -1233,7 +1233,6 @@
"export_chat_button": "ייצוא צ'אט",
"files_button": "קבצים",
"pinned_messages": {
"empty": "אין הודעות נעוצות, לבינתיים",
"limits": {
"other": "אתה יכול להצמיד עד%(count)s ווידג'טים בלבד"
},
@@ -1672,7 +1671,6 @@
"error_revoke_msisdn_discovery": "לא ניתן לבטל את השיתוף למספר טלפון",
"error_share_email_discovery": "לא ניתן לשתף את כתובת הדוא\"ל",
"error_share_msisdn_discovery": "לא ניתן לשתף מספר טלפון",
"incorrect_msisdn_verification": "קוד אימות שגוי",
"language_section": "שפה ואיזור",
"msisdn_in_use": "מספר הטלפון הזה כבר בשימוש",
"msisdn_label": "מספר טלפון",

View File

@@ -1745,8 +1745,6 @@
"export_chat_button": "Beszélgetés exportálása",
"files_button": "Fájlok",
"pinned_messages": {
"empty": "Még semmi sincs kitűzve",
"explainer": "Ha van hozzá jogosultsága, nyissa meg a menüt bármelyik üzenetben és válassza a <b>Kitűzés</b> menüpontot a kitűzéshez.",
"limits": {
"other": "Csak %(count)s kisalkalmazást tud kitűzni"
},
@@ -2375,7 +2373,6 @@
"error_share_msisdn_discovery": "A telefonszámot nem sikerült megosztani",
"identity_server_no_token": "Nem található személyazonosság-hozzáférési kulcs",
"identity_server_not_set": "Az azonosítási kiszolgáló nincs megadva",
"incorrect_msisdn_verification": "Hibás azonosítási kód",
"language_section": "Nyelv és régió",
"msisdn_in_use": "Ez a telefonszám már használatban van",
"msisdn_label": "Telefonszám",

View File

@@ -1757,8 +1757,6 @@
"export_chat_button": "Ekspor obrolan",
"files_button": "File",
"pinned_messages": {
"empty": "Belum ada yang dipasangi pin",
"explainer": "Jika Anda memiliki izin, buka menunya di pesan apa saja dan pilih <b>Pin</b> untuk menempelkannya di sini.",
"limits": {
"other": "Anda hanya dapat memasang pin sampai %(count)s widget"
},
@@ -2408,7 +2406,6 @@
"error_share_msisdn_discovery": "Tidak dapat membagikan nomor telepon",
"identity_server_no_token": "Tidak ada token akses identitas yang ditemukan",
"identity_server_not_set": "Server identitas tidak diatur",
"incorrect_msisdn_verification": "Kode verifikasi tidak benar",
"language_section": "Bahasa dan wilayah",
"msisdn_in_use": "Nomor telepon ini telah dipakai",
"msisdn_label": "Nomor Telepon",

View File

@@ -1470,7 +1470,6 @@
"export_chat_button": "Flytja út spjall",
"files_button": "Skrár",
"pinned_messages": {
"empty": "Ekkert fest, ennþá",
"limits": {
"other": "Þú getur bara fest allt að %(count)s viðmótshluta"
},
@@ -1968,7 +1967,6 @@
"error_revoke_msisdn_discovery": "Ekki er hægt að afturkalla að deila símanúmeri",
"error_share_email_discovery": "Get ekki deilt tölvupóstfangi",
"error_share_msisdn_discovery": "Ekki er hægt að deila símanúmeri",
"incorrect_msisdn_verification": "Rangur sannvottunarkóði",
"language_section": "Tungumál og landsvæði",
"msisdn_in_use": "Þetta símanúmer er nú þegar í notkun",
"msisdn_label": "Símanúmer",

View File

@@ -1791,8 +1791,6 @@
"export_chat_button": "Esporta conversazione",
"files_button": "File",
"pinned_messages": {
"empty": "Non c'è ancora nulla di ancorato",
"explainer": "Se ne hai il permesso, apri il menu di qualsiasi messaggio e seleziona <b>Fissa</b> per ancorarlo qui.",
"limits": {
"other": "Puoi ancorare al massimo %(count)s widget"
},
@@ -2450,7 +2448,6 @@
"error_share_msisdn_discovery": "Impossibile condividere il numero di telefono",
"identity_server_no_token": "Nessun token di accesso d'identità trovato",
"identity_server_not_set": "Server d'identità non impostato",
"incorrect_msisdn_verification": "Codice di verifica sbagliato",
"language_section": "Lingua e regione",
"msisdn_in_use": "Questo numero di telefono è già in uso",
"msisdn_label": "Numero di telefono",

View File

@@ -1664,8 +1664,6 @@
"export_chat_button": "チャットをエクスポート",
"files_button": "ファイル",
"pinned_messages": {
"empty": "固定メッセージはありません",
"explainer": "権限がある場合は、メッセージのメニューを開いて<b>固定</b>を選択すると、ここにメッセージが表示されます。",
"limits": {
"other": "ウィジェットのピン留めは%(count)s件までです"
},
@@ -2239,7 +2237,6 @@
"error_revoke_msisdn_discovery": "電話番号の共有を取り消せません",
"error_share_email_discovery": "メールアドレスを共有できません",
"error_share_msisdn_discovery": "電話番号を共有できません",
"incorrect_msisdn_verification": "認証コードが誤っています",
"language_section": "言語と地域",
"msisdn_in_use": "この電話番号は既に使用されています",
"msisdn_label": "電話番号",

View File

@@ -1453,8 +1453,6 @@
"export_chat_button": "ສົ່ງການສົນທະນາອອກ",
"files_button": "ໄຟລ໌",
"pinned_messages": {
"empty": "ບໍ່ມີຫຍັງຖືກປັກໝຸດ,",
"explainer": "ຖ້າຫາກທ່ານມີການອະນຸຍາດ, ເປີດເມນູໃນຂໍ້ຄວາມໃດຫນຶ່ງ ແລະ ເລືອກ <b>Pin</b> ເພື່ອຕິດໃຫ້ເຂົາເຈົ້າຢູ່ທີ່ນີ້.",
"limits": {
"other": "ທ່ານສາມາດປັກໝຸດໄດ້ເຖິງ %(count)s widget ເທົ່ານັ້ນ"
},
@@ -1994,7 +1992,6 @@
"error_revoke_msisdn_discovery": "ບໍ່ສາມາດຖອນການແບ່ງປັນສຳລັບເບີໂທລະສັບໄດ້",
"error_share_email_discovery": "ບໍ່ສາມາດແບ່ງປັນທີ່ຢູ່ອີເມວໄດ້",
"error_share_msisdn_discovery": "ບໍ່ສາມາດແບ່ງປັນເບີໂທລະສັບໄດ້",
"incorrect_msisdn_verification": "ລະຫັດຢືນຢັນບໍ່ຖືກຕ້ອງ",
"language_section": "ພາສາ ແລະ ພາກພື້ນ",
"msisdn_in_use": "ເບີໂທນີ້ຖືກໃຊ້ແລ້ວ",
"msisdn_label": "ເບີໂທລະສັບ",

View File

@@ -1084,8 +1084,6 @@
"export_chat_button": "Eksportuoti pokalbį",
"files_button": "Failai",
"pinned_messages": {
"empty": "Kol kas nieko neprisegta",
"explainer": "Jei turite leidimus, atidarykite bet kurios žinutės meniu ir pasirinkite <b>Prisegti</b>, kad juos čia priklijuotumėte.",
"limits": {
"other": "Galite prisegti tik iki %(count)s valdiklių"
},
@@ -1568,7 +1566,6 @@
"error_revoke_msisdn_discovery": "Neina atšaukti telefono numerio bendrinimo",
"error_share_email_discovery": "Nepavyko pasidalinti el. pašto adresu",
"error_share_msisdn_discovery": "Neina bendrinti telefono numerio",
"incorrect_msisdn_verification": "Neteisingas patvirtinimo kodas",
"language_section": "Kalba ir regionas",
"msisdn_in_use": "Šis telefono numeris jau naudojamas",
"msisdn_label": "Telefono Numeris",

View File

@@ -1516,8 +1516,6 @@
"export_chat_button": "Chat exporteren",
"files_button": "Bestanden",
"pinned_messages": {
"empty": "Nog niks vastgeprikt",
"explainer": "Als je de rechten hebt, open dan het menu op elk bericht en selecteer <b>Vastprikken</b> om ze hier te zetten.",
"limits": {
"other": "Je kunt maar %(count)s widgets vastzetten"
},
@@ -2076,7 +2074,6 @@
"error_revoke_msisdn_discovery": "Kan delen voor dit telefoonnummer niet intrekken",
"error_share_email_discovery": "Kan e-mailadres niet delen",
"error_share_msisdn_discovery": "Kan telefoonnummer niet delen",
"incorrect_msisdn_verification": "Onjuiste verificatiecode",
"language_section": "Taal en regio",
"msisdn_in_use": "Dit telefoonnummer is al in gebruik",
"msisdn_label": "Telefoonnummer",

View File

@@ -1629,7 +1629,7 @@
"level_none": "Brak",
"level_notification": "Powiadomienie",
"level_unsent": "Niewysłane",
"mark_all_read": "Oznacz wszystko jako przeczytane",
"mark_all_read": "Oznacz wszystkie jako przeczytane",
"mentions_and_keywords": "@wzmianki & słowa kluczowe",
"mentions_and_keywords_description": "Otrzymuj powiadomienia tylko z wzmiankami i słowami kluczowymi zgodnie z Twoimi <a>ustawieniami</a>",
"mentions_keywords": "Wzmianki i słowa kluczowe",
@@ -1841,8 +1841,6 @@
"files_button": "Pliki",
"info": "Info",
"pinned_messages": {
"empty": "Nie przypięto tu jeszcze niczego",
"explainer": "Jeżeli masz uprawnienia, przejdź do menu dowolnej wiadomości i wybierz <b>Przypnij</b>, aby przyczepić ją tutaj.",
"limits": {
"other": "Możesz przypiąć do %(count)s widżetów"
},
@@ -2008,7 +2006,7 @@
"joining": "Dołączanie…",
"joining_room": "Dołączanie do pokoju…",
"joining_space": "Dołączanie do przestrzeni…",
"jump_read_marker": "Przeskocz do pierwszej nieprzeczytanej wiadomości.",
"jump_read_marker": "Skocz do pierwszej nieprzeczytanej wiadomości.",
"jump_to_bottom_button": "Przewiń do najnowszych wiadomości",
"jump_to_date": "Przeskocz do daty",
"jump_to_date_beginning": "Początek pokoju",
@@ -2426,6 +2424,10 @@
}
},
"settings": {
"account": {
"dialog_title": "<strong>Ustawienia:</strong> Konto",
"title": "Konto"
},
"all_rooms_home": "Pokaż wszystkie pokoje na ekranie głównym",
"all_rooms_home_description": "Wszystkie pokoje w których jesteś zostaną pokazane na ekranie głównym.",
"always_show_message_timestamps": "Zawsze pokazuj znaczniki czasu wiadomości",
@@ -2532,7 +2534,6 @@
"error_share_msisdn_discovery": "Nie udało się udostępnić numeru telefonu",
"identity_server_no_token": "Nie znaleziono tokena dostępu tożsamości",
"identity_server_not_set": "Serwer tożsamości nie jest ustawiony",
"incorrect_msisdn_verification": "Nieprawidłowy kod weryfikujący",
"language_section": "Język",
"msisdn_in_use": "Ten numer telefonu jest już zajęty",
"msisdn_label": "Numer telefonu",
@@ -3896,8 +3897,8 @@
"disable_camera": "Wyłącz kamerę",
"disable_microphone": "Wycisz mikrofon",
"disabled_no_one_here": "Nie ma tu nikogo, do kogo można zadzwonić",
"disabled_no_perms_start_video_call": "Nie posiadasz uprawnień do rozpoczęcia rozmowy wideo",
"disabled_no_perms_start_voice_call": "Nie posiadasz uprawnień do rozpoczęcia rozmowy głosowej",
"disabled_no_perms_start_video_call": "Nie masz uprawnień do rozpoczęcia rozmowy wideo",
"disabled_no_perms_start_voice_call": "Nie masz uprawnień do rozpoczęcia rozmowy głosowej",
"disabled_ongoing_call": "Rozmowa w toku",
"element_call": "Element Call",
"enable_camera": "Włącz kamerę",

View File

@@ -1196,8 +1196,6 @@
"export_chat_button": "Exportar conversa",
"files_button": "Arquivos",
"pinned_messages": {
"empty": "Nada fixado ainda",
"explainer": "Caso você tenha a permissão para isso, abra o menu em qualquer mensagem e selecione <b>Fixar</b> para fixá-la aqui.",
"limits": {
"other": "Você pode fixar até %(count)s widgets"
},
@@ -1664,7 +1662,6 @@
"error_revoke_msisdn_discovery": "Não foi possível revogar o compartilhamento do número de celular",
"error_share_email_discovery": "Não foi possível compartilhar o endereço de e-mail",
"error_share_msisdn_discovery": "Não foi possível compartilhar o número de celular",
"incorrect_msisdn_verification": "Código de confirmação incorreto",
"language_section": "Idioma e região",
"msisdn_in_use": "Este número de telefone já está em uso",
"msisdn_label": "Número de telefone",

View File

@@ -1773,8 +1773,6 @@
"export_chat_button": "Экспорт чата",
"files_button": "Файлы",
"pinned_messages": {
"empty": "Пока ничего не закреплено",
"explainer": "Если у вас есть разрешения, откройте меню на любом сообщении и выберите <b>Закрепить</b>, чтобы поместить их сюда.",
"limits": {
"other": "Вы можете закрепить не более %(count)s виджетов"
},
@@ -2433,7 +2431,6 @@
"error_share_msisdn_discovery": "Не удается предоставить общий доступ к номеру телефона",
"identity_server_no_token": "Не найден токен доступа для идентификации",
"identity_server_not_set": "Сервер идентификации не установлен",
"incorrect_msisdn_verification": "Неверный код подтверждения",
"language_section": "Язык и регион",
"msisdn_in_use": "Этот номер телефона уже используется",
"msisdn_label": "Номер телефона",

View File

@@ -1779,8 +1779,6 @@
"export_chat_button": "Exportovať konverzáciu",
"files_button": "Súbory",
"pinned_messages": {
"empty": "Zatiaľ nie je nič pripnuté",
"explainer": "Ak máte oprávnenia, otvorte ponuku pri ľubovoľnej správe a výberom položky <b>Pripnúť</b> ich sem prilepíte.",
"limits": {
"other": "Môžete pripnúť iba %(count)s widgetov"
},
@@ -2437,7 +2435,6 @@
"error_share_msisdn_discovery": "Nepodarilo sa zdieľanie telefónneho čísla",
"identity_server_no_token": "Nenašiel sa prístupový token totožnosti",
"identity_server_not_set": "Server totožnosti nie je nastavený",
"incorrect_msisdn_verification": "Nesprávny overovací kód",
"language_section": "Jazyk a región",
"msisdn_in_use": "Toto telefónne číslo sa už používa",
"msisdn_label": "Telefónne číslo",

View File

@@ -1680,8 +1680,6 @@
"export_chat_button": "Eksportoni fjalosje",
"files_button": "Kartela",
"pinned_messages": {
"empty": "Ende pa fiksuar gjë",
"explainer": "Nëse keni leje, hapni menunë për çfarëdo mesazhi dhe përzgjidhni <b>Fiksoje</b>, për ta ngjitur këtu.",
"limits": {
"other": "Mundeni të fiksoni deri në %(count)s widget-e"
},
@@ -2300,7 +2298,6 @@
"error_share_msisdn_discovery": "Sarrihet të ndahet numër telefoni",
"identity_server_no_token": "Su gjet token hyrjeje identiteti",
"identity_server_not_set": "Shërbyes identitetesh i paujdisur",
"incorrect_msisdn_verification": "Kod verifikimi i pasaktë",
"language_section": "Gjuhë dhe rajon",
"msisdn_in_use": "Ky numër telefoni është tashmë në përdorim",
"msisdn_label": "Numër Telefoni",

View File

@@ -1791,8 +1791,6 @@
"export_chat_button": "Exportera chatt",
"files_button": "Filer",
"pinned_messages": {
"empty": "Inget fäst än",
"explainer": "Om du har behörighet, öppna menyn på ett meddelande och välj <b>Fäst</b> för att fösta dem här.",
"limits": {
"other": "Du kan bara fästa upp till %(count)s widgets"
},
@@ -2449,7 +2447,6 @@
"error_share_msisdn_discovery": "Kunde inte dela telefonnummer",
"identity_server_no_token": "Ingen identitetsåtkomsttoken hittades",
"identity_server_not_set": "Identitetsserver inte inställd",
"incorrect_msisdn_verification": "Fel verifieringskod",
"language_section": "Språk och region",
"msisdn_in_use": "Detta telefonnummer används redan",
"msisdn_label": "Telefonnummer",

View File

@@ -1735,8 +1735,6 @@
"export_chat_button": "Експортувати бесіду",
"files_button": "Файли",
"pinned_messages": {
"empty": "Наразі нічого не закріплено",
"explainer": "Якщо маєте дозвіл, відкрийте меню будь-якого повідомлення й натисніть <b>Закріпити</b>, щоб додати його сюди.",
"limits": {
"other": "Закріпити можна до %(count)s віджетів"
},
@@ -2374,7 +2372,6 @@
"error_share_msisdn_discovery": "Не вдалося надіслати телефонний номер",
"identity_server_no_token": "Токен доступу до ідентифікації не знайдено",
"identity_server_not_set": "Сервер ідентифікації не налаштовано",
"incorrect_msisdn_verification": "Неправильний код перевірки",
"language_section": "Мова та регіон",
"msisdn_in_use": "Цей телефонний номер вже використовується",
"msisdn_label": "Телефонний номер",

View File

@@ -1583,8 +1583,6 @@
"add_integrations": "Thêm các widget, bridge và bot",
"export_chat_button": "Xuất trò chuyện",
"pinned_messages": {
"empty": "Chưa có gì được ghim",
"explainer": "Nếu bạn có quyền, hãy mở menu trên bất kỳ tin nhắn nào và chọn Ghim <b>Pin</b> để dán chúng vào đây.",
"limits": {
"other": "Bạn chỉ có thể ghim tối đa %(count)s widget"
},
@@ -2174,7 +2172,6 @@
"error_share_msisdn_discovery": "Không thể chia sẻ số điện thoại",
"identity_server_no_token": "Không tìm thấy mã thông báo danh tính",
"identity_server_not_set": "Máy chủ định danh chưa được đặt",
"incorrect_msisdn_verification": "Mã xác minh không chính xác",
"language_section": "Ngôn ngữ và khu vực",
"msisdn_in_use": "Số điện thoại này đã được sử dụng",
"msisdn_label": "Số điện thoại",

View File

@@ -1631,8 +1631,6 @@
"export_chat_button": "导出聊天",
"files_button": "文件",
"pinned_messages": {
"empty": "尚无固定任何东西",
"explainer": "如果你拥有权限,请打开任何消息的菜单并选择<b>固定</b>将它们粘贴至此。",
"limits": {
"other": "你仅能固定 %(count)s 个挂件"
},
@@ -2202,7 +2200,6 @@
"error_share_msisdn_discovery": "无法共享电话号码",
"identity_server_no_token": "找不到身份访问令牌",
"identity_server_not_set": "身份服务器未设置",
"incorrect_msisdn_verification": "验证码错误",
"language_section": "语言与地区",
"msisdn_in_use": "此电话号码已被使用",
"msisdn_label": "电话号码",

View File

@@ -1738,8 +1738,6 @@
"export_chat_button": "匯出聊天",
"files_button": "檔案",
"pinned_messages": {
"empty": "尚未釘選任何東西",
"explainer": "如果您有權限,請開啟任何訊息的選單,並選取<b>釘選</b>以將它們固定到這裡。",
"limits": {
"other": "您最多只能釘選 %(count)s 個小工具"
},
@@ -2376,7 +2374,6 @@
"error_share_msisdn_discovery": "無法分享電話號碼",
"identity_server_no_token": "找不到身分存取權杖",
"identity_server_not_set": "身分伺服器未設定",
"incorrect_msisdn_verification": "驗證碼錯誤",
"language_section": "語言與區域",
"msisdn_in_use": "這個電話號碼已被使用",
"msisdn_label": "電話號碼",

View File

@@ -14,13 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License.
*/
import { MatrixEvent, EventType, M_POLL_START } from "matrix-js-sdk/src/matrix";
import { MatrixEvent, EventType, M_POLL_START, MatrixClient, EventTimeline } from "matrix-js-sdk/src/matrix";
import { canPinEvent, isContentActionable } from "./EventUtils";
import SettingsStore from "../settings/SettingsStore";
import { ReadPinsEventId } from "../components/views/right_panel/types";
export default class PinningUtils {
/**
* Event types that may be pinned.
*/
public static pinnableEventTypes: (EventType | string)[] = [
public static readonly PINNABLE_EVENT_TYPES: (EventType | string)[] = [
EventType.RoomMessage,
M_POLL_START.name,
M_POLL_START.altName,
@@ -33,9 +37,80 @@ export default class PinningUtils {
*/
public static isPinnable(event: MatrixEvent): boolean {
if (!event) return false;
if (!this.pinnableEventTypes.includes(event.getType())) return false;
if (!this.PINNABLE_EVENT_TYPES.includes(event.getType())) return false;
if (event.isRedacted()) return false;
return true;
}
/**
* Determines if the given event is pinned.
* @param matrixClient
* @param mxEvent
*/
public static isPinned(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return false;
const pinnedEvent = room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "");
if (!pinnedEvent) return false;
const content = pinnedEvent.getContent();
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(mxEvent.getId());
}
/**
* Determines if the given event may be pinned or unpinned.
* @param matrixClient
* @param mxEvent
*/
public static canPinOrUnpin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
if (!SettingsStore.getValue("feature_pinning")) return false;
if (!isContentActionable(mxEvent)) return false;
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return false;
return Boolean(
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient) && canPinEvent(mxEvent),
);
}
/**
* Pin or unpin the given event.
* @param matrixClient
* @param mxEvent
*/
public static async pinOrUnpinEvent(matrixClient: MatrixClient, mxEvent: MatrixEvent): Promise<void> {
const room = matrixClient.getRoom(mxEvent.getRoomId());
if (!room) return;
const eventId = mxEvent.getId();
if (!eventId) return;
// Get the current pinned events of the room
const pinnedIds: Array<string> =
room
.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "")
?.getContent().pinned || [];
// If the event is already pinned, unpin it
if (pinnedIds.includes(eventId)) {
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
} else {
// Otherwise, pin it
pinnedIds.push(eventId);
await matrixClient.setRoomAccountData(room.roomId, ReadPinsEventId, {
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
});
}
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
}
}

View File

@@ -16,16 +16,11 @@ limitations under the License.
import { logger } from "matrix-js-sdk/src/logger";
import fetchMockJest from "fetch-mock-jest";
import {
ProvideCryptoSetupExtensions,
SecretStorageKeyDescription,
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions";
import { advanceDateAndTime, stubClient } from "./test-utils";
import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg";
import SettingsStore from "../src/settings/SettingsStore";
import { SettingLevel } from "../src/settings/SettingLevel";
import { ModuleRunner } from "../src/modules/ModuleRunner";
jest.useFakeTimers();
@@ -78,78 +73,6 @@ describe("MatrixClientPeg", () => {
expect(peg.userRegisteredWithinLastHours(24)).toBe(false);
});
describe(".start extensions", () => {
let testPeg: IMatrixClientPeg;
beforeEach(() => {
// instantiate a MatrixClientPegClass instance, with a new MatrixClient
testPeg = new PegClass();
fetchMockJest.get("http://example.com/_matrix/client/versions", {});
});
describe("cryptoSetup extension", () => {
it("should call default cryptoSetup.getDehydrationKeyCallback", async () => {
const mockCryptoSetup = {
SHOW_ENCRYPTION_SETUP_UI: true,
examineLoginResponse: jest.fn(),
persistCredentials: jest.fn(),
getSecretStorageKey: jest.fn(),
createSecretStorageKey: jest.fn(),
catchAccessSecretStorageError: jest.fn(),
setupEncryptionNeeded: jest.fn(),
getDehydrationKeyCallback: jest.fn().mockReturnValue(null),
} as ProvideCryptoSetupExtensions;
// Ensure we have an instance before we set up spies
const instance = ModuleRunner.instance;
jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup);
testPeg.replaceUsingCreds({
accessToken: "SEKRET",
homeserverUrl: "http://example.com",
userId: "@user:example.com",
deviceId: "TEST_DEVICE_ID",
});
expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1);
});
it("should call overridden cryptoSetup.getDehydrationKeyCallback", async () => {
const mockDehydrationKeyCallback = () => Uint8Array.from([0x11, 0x22, 0x33]);
const mockCryptoSetup = {
SHOW_ENCRYPTION_SETUP_UI: true,
examineLoginResponse: jest.fn(),
persistCredentials: jest.fn(),
getSecretStorageKey: jest.fn(),
createSecretStorageKey: jest.fn(),
catchAccessSecretStorageError: jest.fn(),
setupEncryptionNeeded: jest.fn(),
getDehydrationKeyCallback: jest.fn().mockReturnValue(mockDehydrationKeyCallback),
} as ProvideCryptoSetupExtensions;
// Ensure we have an instance before we set up spies
const instance = ModuleRunner.instance;
jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup);
testPeg.replaceUsingCreds({
accessToken: "SEKRET",
homeserverUrl: "http://example.com",
userId: "@user:example.com",
deviceId: "TEST_DEVICE_ID",
});
expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1);
const client = testPeg.get();
const dehydrationKey = await client?.cryptoCallbacks.getDehydrationKey!(
{} as SecretStorageKeyDescription,
(key: Uint8Array) => true,
);
expect(dehydrationKey).toEqual(Uint8Array.from([0x11, 0x22, 0x33]));
});
});
});
describe(".start", () => {
let testPeg: IMatrixClientPeg;

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