1
0
mirror of https://github.com/matrix-org/matrix-react-sdk.git synced 2025-09-04 18:02:20 +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) 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 Fixes for CVE-2024-42347 / GHSA-f83w-wqhc-cfp4

View File

@@ -1,6 +1,6 @@
{ {
"name": "matrix-react-sdk", "name": "matrix-react-sdk",
"version": "3.105.1", "version": "3.107.0",
"description": "SDK for matrix.org using React", "description": "SDK for matrix.org using React",
"author": "matrix.org", "author": "matrix.org",
"repository": { "repository": {
@@ -26,10 +26,8 @@
"engines": { "engines": {
"node": ">=20.0.0" "node": ">=20.0.0"
}, },
"main": "./src/index.ts", "main": "./lib/index.ts",
"matrix_src_main": "./src/index.ts", "typings": "./lib/index.d.ts",
"matrix_lib_main": "./lib/index.ts",
"matrix_lib_typings": "./lib/index.d.ts",
"matrix_i18n_extra_translation_funcs": [ "matrix_i18n_extra_translation_funcs": [
"UserFriendlyError" "UserFriendlyError"
], ],
@@ -82,7 +80,7 @@
"@matrix-org/spec": "^1.7.0", "@matrix-org/spec": "^1.7.0",
"@sentry/browser": "^8.0.0", "@sentry/browser": "^8.0.0",
"@testing-library/react-hooks": "^8.0.1", "@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", "@vector-im/compound-web": "^5.5.0",
"@zxcvbn-ts/core": "^3.0.4", "@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4", "@zxcvbn-ts/language-common": "^3.0.4",
@@ -191,7 +189,7 @@
"@types/react-beautiful-dnd": "^13.0.0", "@types/react-beautiful-dnd": "^13.0.0",
"@types/react-dom": "17.0.25", "@types/react-dom": "17.0.25",
"@types/react-transition-group": "^4.4.0", "@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/sdp-transform": "^2.4.6",
"@types/seedrandom": "3.0.8", "@types/seedrandom": "3.0.8",
"@types/tar-js": "^0.3.2", "@types/tar-js": "^0.3.2",
@@ -199,7 +197,7 @@
"@types/uuid": "^10.0.0", "@types/uuid": "^10.0.0",
"@typescript-eslint/eslint-plugin": "^7.0.0", "@typescript-eslint/eslint-plugin": "^7.0.0",
"@typescript-eslint/parser": "^7.0.0", "@typescript-eslint/parser": "^7.0.0",
"axe-core": "4.9.1", "axe-core": "4.10.0",
"babel-jest": "^29.0.0", "babel-jest": "^29.0.0",
"blob-polyfill": "^9.0.0", "blob-polyfill": "^9.0.0",
"eslint": "8.57.0", "eslint": "8.57.0",
@@ -212,13 +210,13 @@
"eslint-plugin-matrix-org": "1.2.1", "eslint-plugin-matrix-org": "1.2.1",
"eslint-plugin-react": "^7.28.0", "eslint-plugin-react": "^7.28.0",
"eslint-plugin-react-hooks": "^4.3.0", "eslint-plugin-react-hooks": "^4.3.0",
"eslint-plugin-unicorn": "^54.0.0", "eslint-plugin-unicorn": "^55.0.0",
"express": "^4.18.2", "express": "^4.18.2",
"fake-indexeddb": "^6.0.0", "fake-indexeddb": "^6.0.0",
"fetch-mock-jest": "^1.5.1", "fetch-mock-jest": "^1.5.1",
"fs-extra": "^11.0.0", "fs-extra": "^11.0.0",
"glob": "^11.0.0", "glob": "^11.0.0",
"husky": "^8.0.3", "husky": "^9.0.0",
"jest": "^29.6.2", "jest": "^29.6.2",
"jest-canvas-mock": "^2.5.2", "jest-canvas-mock": "^2.5.2",
"jest-environment-jsdom": "^29.6.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_displayName", { hasText: user.displayName })).toBeVisible();
await expect(menu.locator(".mx_UserMenu_contextMenu_userId", { hasText: user.userId })).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. // 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. // 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. // 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">> { async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
const templateDir = path.join(__dirname, "templates", opts.template); 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 .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button), ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button),
.mx_Dialog input[type="submit"], .mx_Dialog input[type="submit"],
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton), .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
.mx_Dialog_buttons input[type="submit"] { .mx_Dialog_buttons input[type="submit"] {
@@ -624,14 +624,14 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .mx_UserProfileSettings button
):not(.mx_ThemeChoicePanel_CustomTheme button):last-child { ):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):last-child {
margin-right: 0px; margin-right: 0px;
} }
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .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 input[type="submit"]:focus,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
.mx_Dialog_buttons input[type="submit"]:focus { .mx_Dialog_buttons input[type="submit"]:focus {
@@ -643,7 +643,7 @@ legend {
.mx_Dialog_buttons .mx_Dialog_buttons
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not( button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .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 { .mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
color: var(--cpd-color-text-on-solid-primary); color: var(--cpd-color-text-on-solid-primary);
background-color: var(--cpd-color-bg-action-primary-rest); background-color: var(--cpd-color-bg-action-primary-rest);
@@ -656,7 +656,7 @@ legend {
.mx_Dialog_buttons .mx_Dialog_buttons
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not( button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
.mx_ThemeChoicePanel_CustomTheme button .mx_ThemeChoicePanel_CustomTheme button
), ):not(.mx_UnpinAllDialog button),
.mx_Dialog_buttons input[type="submit"].danger { .mx_Dialog_buttons input[type="submit"].danger {
background-color: var(--cpd-color-bg-critical-primary); background-color: var(--cpd-color-bg-critical-primary);
border: solid 1px var(--cpd-color-bg-critical-primary); border: solid 1px var(--cpd-color-bg-critical-primary);
@@ -672,7 +672,7 @@ legend {
.mx_Dialog .mx_Dialog
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not( button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
.mx_UserProfileSettings button .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 input[type="submit"]:disabled,
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled, .mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
.mx_Dialog_buttons input[type="submit"]:disabled { .mx_Dialog_buttons input[type="submit"]:disabled {

View File

@@ -37,7 +37,7 @@
@import "./components/views/messages/shared/_MediaProcessingError.pcss"; @import "./components/views/messages/shared/_MediaProcessingError.pcss";
@import "./components/views/pips/_WidgetPip.pcss"; @import "./components/views/pips/_WidgetPip.pcss";
@import "./components/views/polls/_PollOption.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/_CurrentDeviceSection.pcss";
@import "./components/views/settings/devices/_DeviceDetailHeading.pcss"; @import "./components/views/settings/devices/_DeviceDetailHeading.pcss";
@import "./components/views/settings/devices/_DeviceDetails.pcss"; @import "./components/views/settings/devices/_DeviceDetails.pcss";
@@ -167,6 +167,7 @@
@import "./views/dialogs/_SpaceSettingsDialog.pcss"; @import "./views/dialogs/_SpaceSettingsDialog.pcss";
@import "./views/dialogs/_SpotlightDialog.pcss"; @import "./views/dialogs/_SpotlightDialog.pcss";
@import "./views/dialogs/_TermsDialog.pcss"; @import "./views/dialogs/_TermsDialog.pcss";
@import "./views/dialogs/_UnpinAllDialog.pcss";
@import "./views/dialogs/_UntrustedDeviceDialog.pcss"; @import "./views/dialogs/_UntrustedDeviceDialog.pcss";
@import "./views/dialogs/_UploadConfirmDialog.pcss"; @import "./views/dialogs/_UploadConfirmDialog.pcss";
@import "./views/dialogs/_UserSettingsDialog.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. * tab sensibly and before I can refactor these components.
*/ */
.mx_EmailAddressesPhoneNumbers_discovery_existing { .mx_AddRemoveThreepids_existing {
display: flex; display: flex;
align-items: center; align-items: center;
} }
.mx_EmailAddressesPhoneNumbers_discovery_existing_address, .mx_AddRemoveThreepids_existing_address,
.mx_EmailAddressesPhoneNumbers_discovery_existing_promptText { .mx_AddRemoveThreepids_existing_promptText {
flex: 1; flex: 1;
margin-right: 10px; margin-right: 10px;
} }
.mx_EmailAddressesPhoneNumbers_discovery_existing_button { .mx_AddRemoveThreepids_existing_button {
margin-left: 5px; 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 { h2 {
margin: 0; margin: 0;
font: var(--cpd-font-heading-sm-medium); font: var(--cpd-font-body-lg-semibold);
font-weight: var(--cpd-font-weight-medium);
display: inline; display: inline;
width: auto; width: auto;
} }

View File

@@ -111,7 +111,7 @@ limitations under the License.
.mx_UserMenu_contextMenu_displayName, .mx_UserMenu_contextMenu_displayName,
.mx_UserMenu_contextMenu_userId { .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 */ /* Automatically grow subelements to fit the container */
flex: 1; flex: 1;

View File

@@ -81,11 +81,11 @@ limitations under the License.
} }
.mx_MessageContextMenu_iconPin::before { .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 { .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 { .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; flex: 1;
.mx_BaseCard_header_title_heading { .mx_BaseCard_header_title_heading {
color: $primary-content;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
font: var(--cpd-font-body-md-medium);
color: var(--cpd-color-text-secondary);
} }
.mx_BaseCard_header_title_button--option { .mx_BaseCard_header_title_button--option {

View File

@@ -15,48 +15,38 @@ limitations under the License.
*/ */
.mx_PinnedMessagesCard { .mx_PinnedMessagesCard {
.mx_PinnedMessagesCard_empty_wrapper { --unpin-height: 76px;
.mx_PinnedMessagesCard_wrapper {
display: flex; display: flex;
height: 100%; flex-direction: column;
padding: var(--cpd-space-4x);
gap: var(--cpd-space-6x);
overflow-y: auto;
.mx_PinnedMessagesCard_empty { .mx_PinnedMessagesCard_Separator {
height: max-content; min-height: 1px;
text-align: center; /* Override default compound value */
margin: auto 40px; margin-block: 0;
.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 { .mx_PinnedMessagesCard_wrapper_unpin_all {
color: $primary-content; /* Remove the unpin all button height and the top and bottom padding */
margin-block: $spacing-24 $spacing-20; height: calc(100% - var(--unpin-height) - calc(var(--cpd-space-4x) * 2));
} }
> span { .mx_PinnedMessagesCard_unpin {
font-size: $font-12px; /* Make it float at the bottom of the unpin panel */
line-height: $font-15px; position: absolute;
color: $secondary-content; 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 { .mx_EventTile_body {

View File

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

View File

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

View File

@@ -44,6 +44,10 @@ limitations under the License.
transition: all 500ms; transition: all 500ms;
.mx_UserOnboardingTask_title {
font: var(--cpd-font-body-md-medium);
}
.mx_UserOnboardingTask_description { .mx_UserOnboardingTask_description {
font-size: $font-12px; 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 * with a "message" property which contains a human-readable message detailing why
* the request failed. * the request failed.
*/ */
public async haveMsisdnToken( public async haveMsisdnToken(msisdnToken: string): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
msisdnToken: string,
): Promise<[success?: boolean, result?: IAuthData | Error | null] | undefined> {
const authClient = new IdentityAuthClient(); const authClient = new IdentityAuthClient();
if (this.submitUrl) { if (this.submitUrl) {
@@ -301,13 +299,14 @@ export default class AddThreepid {
id_server: getIdServerDomain(this.matrixClient), id_server: getIdServerDomain(this.matrixClient),
id_access_token: await authClient.getAccessToken(), id_access_token: await authClient.getAccessToken(),
}); });
return [true];
} else { } else {
try { try {
await this.makeAddThreepidOnlyRequest(); await this.makeAddThreepidOnlyRequest();
// The spec has always required this to use UI auth but synapse briefly // 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. // implemented it without, so this may just succeed and that's OK.
return; return [true];
} catch (err) { } catch (err) {
if (!(err instanceof MatrixError) || err.httpStatus !== 401 || !err.data || !err.data.flows) { if (!(err instanceof MatrixError) || err.httpStatus !== 401 || !err.data || !err.data.flows) {
// doesn't look like an interactive-auth failure // 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 { MatrixClientPeg } from "./MatrixClientPeg";
import { EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/SendMessageComposer"; 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 // The key used to persist the the timestamp we last cleaned up drafts
export const DRAFT_LAST_CLEANUP_KEY = "mx_draft_cleanup"; 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 { function cleaupDrafts(): void {
for (let i = 0; i < localStorage.length; i++) { for (let i = 0; i < localStorage.length; i++) {
const keyName = localStorage.key(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 // 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); const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (!room) { if (!room) {
logger.debug(`Removing draft for unknown room with key ${keyName}`); 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 * as StorageManager from "./utils/StorageManager";
import IdentityAuthClient from "./IdentityAuthClient"; import IdentityAuthClient from "./IdentityAuthClient";
import { crossSigningCallbacks } from "./SecurityManager"; import { crossSigningCallbacks } from "./SecurityManager";
import { ModuleRunner } from "./modules/ModuleRunner";
import { SlidingSyncManager } from "./SlidingSyncManager"; import { SlidingSyncManager } from "./SlidingSyncManager";
import { _t, UserFriendlyError } from "./languageHandler"; import { _t, UserFriendlyError } from "./languageHandler";
import { SettingLevel } from "./settings/SettingLevel"; 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 = createMatrixClient(opts);
this.matrixClient.setGuest(Boolean(creds.guest)); 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. 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 { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase";
import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey"; import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
@@ -39,11 +39,6 @@ let secretStorageKeys: Record<string, Uint8Array> = {};
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {}; let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
let secretStorageBeingAccessed = false; 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 * 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 * progress, so that we can e.g. avoid intermittently showing toasts during
@@ -119,14 +114,6 @@ async function getSecretStorageKey({
return [keyId, secretStorageKeys[keyId]]; 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(); const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
if (keyFromCustomisations) { if (keyFromCustomisations) {
logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension"); logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
@@ -171,56 +158,6 @@ async function getSecretStorageKey({
return [keyId, key]; 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( function cacheSecretStorageKey(
keyId: string, keyId: string,
keyInfo: SecretStorage.SecretStorageKeyDescription, 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 = { export const crossSigningCallbacks: ICryptoCallbacks = {
getSecretStorageKey, getSecretStorageKey,
cacheSecretStorageKey, cacheSecretStorageKey,
onSecretRequested,
getDehydrationKey,
}; };
/** /**

View File

@@ -34,7 +34,7 @@ import ThreadView from "./ThreadView";
import ThreadPanel from "./ThreadPanel"; import ThreadPanel from "./ThreadPanel";
import NotificationPanel from "./NotificationPanel"; import NotificationPanel from "./NotificationPanel";
import ResizeNotifier from "../../utils/ResizeNotifier"; 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 { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
import { E2EStatus } from "../../utils/ShieldUtils"; import { E2EStatus } from "../../utils/ShieldUtils";
import TimelineCard from "../views/right_panel/TimelineCard"; import TimelineCard from "../views/right_panel/TimelineCard";

View File

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

View File

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

View File

@@ -26,6 +26,7 @@ import DateSeparator from "../../views/messages/DateSeparator";
import HistoryTile from "../../views/rooms/HistoryTile"; import HistoryTile from "../../views/rooms/HistoryTile";
import EventListSummary from "../../views/elements/EventListSummary"; import EventListSummary from "../../views/elements/EventListSummary";
import { SeparatorKind } from "../../views/messages/TimelineSeparator"; import { SeparatorKind } from "../../views/messages/TimelineSeparator";
import SettingsStore from "../../../settings/SettingsStore";
const groupedStateEvents = [ const groupedStateEvents = [
EventType.RoomMember, EventType.RoomMember,
@@ -97,6 +98,12 @@ export class MainGrouper extends BaseGrouper {
// absorb hidden events to not split the summary // absorb hidden events to not split the summary
return; 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); this.events.push(wrappedEvent);
} }

View File

@@ -833,7 +833,7 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
}; };
private onReceiveMessage = (event: MessageEvent): void => { 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) { if (this.popupWindow) {
this.popupWindow.close(); this.popupWindow.close();
this.popupWindow = null; this.popupWindow = null;
@@ -950,7 +950,7 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
}; };
private onReceiveMessage = (event: MessageEvent): void => { 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({}); this.props.submitAuthDict({});
} }
}; };

View File

@@ -36,9 +36,8 @@ import Modal from "../../../Modal";
import Resend from "../../../Resend"; import Resend from "../../../Resend";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { isUrlPermitted } from "../../../HtmlUtils"; 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 IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
import { ReadPinsEventId } from "../right_panel/types";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { ButtonEvent } from "../elements/AccessibleButton"; import { ButtonEvent } from "../elements/AccessibleButton";
@@ -60,6 +59,7 @@ import { getForwardableEvent } from "../../../events/forward/getForwardableEvent
import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent"; import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent";
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload"; import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
import { CardContext } from "../right_panel/context"; import { CardContext } from "../right_panel/context";
import PinningUtils from "../../../utils/PinningUtils";
interface IReplyInThreadButton { interface IReplyInThreadButton {
mxEvent: MatrixEvent; 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.RoomServerAcl &&
this.props.mxEvent.getType() !== EventType.RoomEncryption; this.props.mxEvent.getType() !== EventType.RoomEncryption;
let canPin = const canPin = PinningUtils.canPinOrUnpin(cli, this.props.mxEvent);
!!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;
this.setState({ canRedact, canPin }); 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 { private canEndPoll(mxEvent: MatrixEvent): boolean {
return ( return (
M_POLL_START.matches(mxEvent.getType()) && M_POLL_START.matches(mxEvent.getType()) &&
@@ -257,22 +244,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
}; };
private onPinClick = (): void => { private onPinClick = (): void => {
const cli = MatrixClientPeg.safeGet(); // Pin or unpin in background
const room = cli.getRoom(this.props.mxEvent.getRoomId()); PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
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 }, "");
this.closeMenu(); 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 // This is specifically not behind the developerMode flag to give people insight into the Matrix
const viewSourceButton = ( const viewSourceButton = (
<IconizedContextMenuOption <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; let viewInRoomButton: JSX.Element | undefined;
if (isThreadRootEvent) { if (isThreadRootEvent) {
viewInRoomButton = ( viewInRoomButton = (
@@ -671,13 +645,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
} }
let quickItemsList: JSX.Element | undefined; let quickItemsList: JSX.Element | undefined;
if (editButton || replyButton || reactButton) { if (editButton || replyButton || reactButton || pinButton) {
quickItemsList = ( quickItemsList = (
<IconizedContextMenuOptionList> <IconizedContextMenuOptionList>
{reactButton} {reactButton}
{replyButton} {replyButton}
{replyInThreadButton} {replyInThreadButton}
{editButton} {editButton}
{pinButton}
</IconizedContextMenuOptionList> </IconizedContextMenuOptionList>
); );
} }
@@ -688,7 +663,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
{openInMapSiteButton} {openInMapSiteButton}
{endPollButton} {endPollButton}
{forwardButton} {forwardButton}
{pinButton}
{permalinkButton} {permalinkButton}
{reportEventButton} {reportEventButton}
{externalURLButton} {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; fragmentAfterLogin?: string;
primary?: boolean; primary?: boolean;
action?: SSOAction; action?: SSOAction;
disabled?: boolean;
} }
const MAX_PER_ROW = 6; 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 || []; const providers = flow.identity_providers || [];
if (providers.length < 2) { if (providers.length < 2) {
return ( return (
@@ -168,6 +177,7 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA
primary={primary} primary={primary}
action={action} action={action}
flow={flow} flow={flow}
disabled={disabled}
/> />
</div> </div>
); );

View File

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

View File

@@ -26,6 +26,8 @@ import {
M_BEACON_INFO, M_BEACON_INFO,
} from "matrix-js-sdk/src/matrix"; } from "matrix-js-sdk/src/matrix";
import classNames from "classnames"; 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 ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.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 { GetRelationsForEvent, IEventTileType } from "../rooms/EventTile";
import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types"; import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types";
import { ButtonEvent } from "../elements/AccessibleButton"; import { ButtonEvent } from "../elements/AccessibleButton";
import PinningUtils from "../../../utils/PinningUtils";
interface IOptionsButtonProps { interface IOptionsButtonProps {
mxEvent: MatrixEvent; 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 { public render(): React.ReactNode {
const toolbarOpts: JSX.Element[] = []; const toolbarOpts: JSX.Element[] = [];
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) { 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 = ( const cancelSendingButton = (
<RovingAccessibleButton <RovingAccessibleButton
className="mx_MessageActionBar_iconButton" className="mx_MessageActionBar_iconButton"

View File

@@ -14,41 +14,62 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React, { useCallback, useContext, useEffect, useState } from "react"; import React, { useCallback, useEffect, useState, JSX } from "react";
import { Room, RoomEvent, RoomStateEvent, MatrixEvent, EventType, RelationType } from "matrix-js-sdk/src/matrix"; import {
Room,
RoomEvent,
RoomStateEvent,
MatrixEvent,
EventType,
RelationType,
EventTimeline,
} from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger"; 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 { _t } from "../../../languageHandler";
import BaseCard from "./BaseCard"; import BaseCard from "./BaseCard";
import Spinner from "../elements/Spinner"; import Spinner from "../elements/Spinner";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter"; import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
import PinningUtils from "../../../utils/PinningUtils"; import PinningUtils from "../../../utils/PinningUtils";
import { useAsyncMemo } from "../../../hooks/useAsyncMemo"; import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
import PinnedEventTile from "../rooms/PinnedEventTile"; import { PinnedEventTile } from "../rooms/PinnedEventTile";
import { useRoomState } from "../../../hooks/useRoomState"; import { useRoomState } from "../../../hooks/useRoomState";
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
import { ReadPinsEventId } from "./types"; import { ReadPinsEventId } from "./types";
import Heading from "../typography/Heading"; import Heading from "../typography/Heading";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
import { filterBoolean } from "../../../utils/arrays"; import { filterBoolean } from "../../../utils/arrays";
import Modal from "../../../Modal";
import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
import EmptyState from "./EmptyState";
interface IProps { /**
room: Room; * Get the pinned event IDs from a room.
permalinkCreator: RoomPermalinkCreator; * @param room
onClose(): void; */
}
function getPinnedEventIds(room?: Room): string[] { 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[] => { export const usePinnedEvents = (room?: Room): string[] => {
const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room)); 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( const update = useCallback(
(ev?: MatrixEvent) => { (ev?: MatrixEvent) => {
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return; if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
@@ -57,7 +78,7 @@ export const usePinnedEvents = (room?: Room): string[] => {
[room], [room],
); );
useTypedEventEmitter(room?.currentState, RoomStateEvent.Events, update); useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
useEffect(() => { useEffect(() => {
setPinnedEvents(getPinnedEventIds(room)); setPinnedEvents(getPinnedEventIds(room));
return () => { return () => {
@@ -67,13 +88,23 @@ export const usePinnedEvents = (room?: Room): string[] => {
return pinnedEvents; return pinnedEvents;
}; };
/**
* Get the read pinned event IDs from a room.
* @param room
*/
function getReadPinnedEventIds(room?: Room): Set<string> { function getReadPinnedEventIds(room?: Room): Set<string> {
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []); 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> => { export const useReadPinnedEvents = (room?: Room): Set<string> => {
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set()); 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( const update = useCallback(
(ev?: MatrixEvent) => { (ev?: MatrixEvent) => {
if (ev && ev.getType() !== ReadPinsEventId) return; if (ev && ev.getType() !== ReadPinsEventId) return;
@@ -92,36 +123,36 @@ export const useReadPinnedEvents = (room?: Room): Set<string> => {
return readPinnedEvents; return readPinnedEvents;
}; };
const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator }) => { /**
const cli = useContext(MatrixClientContext); * Fetch the pinned events
const roomContext = useContext(RoomContext); * @param room
const canUnpin = useRoomState(room, (state) => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli)); * @param pinnedEventIds
const pinnedEventIds = usePinnedEvents(room); */
const readPinnedEvents = useReadPinnedEvents(room); function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
const cli = useMatrixClientContext();
useEffect(() => { return useAsyncMemo(
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(
() => { () => {
const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => { const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
const timelineSet = room.getUnfilteredTimelineSet(); const timelineSet = room.getUnfilteredTimelineSet();
// Get the event from the local timeline
const localEvent = timelineSet const localEvent = timelineSet
?.getTimelineForEvent(eventId) ?.getTimelineForEvent(eventId)
?.getEvents() ?.getEvents()
.find((e) => e.getId() === eventId); .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; if (localEvent) return PinningUtils.isPinnable(localEvent) ? localEvent : null;
try { 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 [ const [
evJson, evJson,
{ {
@@ -131,10 +162,15 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
cli.fetchRoomEvent(room.roomId, eventId), cli.fetchRoomEvent(room.roomId, eventId),
cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }), cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }),
]); ]);
const event = new MatrixEvent(evJson); const event = new MatrixEvent(evJson);
// Decrypt the event if it's encrypted
if (event.isEncrypted()) { if (event.isEncrypted()) {
await cli.decryptEventIfNeeded(event); // TODO await? await cli.decryptEventIfNeeded(event);
} }
// Handle poll events
await room.processPollEvents([event]); await room.processPollEvents([event]);
const senderUserId = event.getSender(); const senderUserId = event.getSender();
@@ -158,62 +194,59 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
[cli, room, pinnedEventIds], [cli, room, pinnedEventIds],
null, 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) { if (!pinnedEventIds.length) {
content = ( content = (
<div className="mx_PinnedMessagesCard_empty_wrapper"> <EmptyState
<div className="mx_PinnedMessagesCard_empty"> Icon={PinIcon}
{/* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */} title={_t("right_panel|pinned_messages|empty_title")}
<div className="mx_MessageActionBar mx_PinnedMessagesCard_MessageActionBar"> description={_t("right_panel|pinned_messages|empty_description", {
<div className="mx_MessageActionBar_iconButton"> pinAction: _t("action|pin"),
<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>
); );
} else if (pinnedEvents?.length) { } else if (pinnedEvents?.length) {
const onUnpinClicked = async (event: MatrixEvent): Promise<void> => { content = (
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, ""); <PinnedMessages events={filterBoolean(pinnedEvents)} room={room} permalinkCreator={permalinkCreator} />
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}
/>
));
} else { } else {
content = <Spinner />; content = <Spinner />;
} }
@@ -223,7 +256,7 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
header={ header={
<div className="mx_BaseCard_header_title"> <div className="mx_BaseCard_header_title">
<Heading size="4" className="mx_BaseCard_header_title_heading"> <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> </Heading>
</div> </div>
} }
@@ -240,6 +273,79 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
</RoomContext.Provider> </RoomContext.Provider>
</BaseCard> </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"; } from "matrix-js-sdk/src/matrix";
import { Optional } from "matrix-events-sdk"; import { Optional } from "matrix-events-sdk";
import { Tooltip } from "@vector-im/compound-web"; import { Tooltip } from "@vector-im/compound-web";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { MatrixClientPeg } from "../../../MatrixClientPeg"; import { MatrixClientPeg } from "../../../MatrixClientPeg";
@@ -65,6 +66,9 @@ import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStart
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import { formatTimeLeft } from "../../../DateUtils"; 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; let instanceCount = 0;
interface ISendButtonProps { interface ISendButtonProps {
@@ -109,6 +113,12 @@ interface IState {
initialComposerContent: string; initialComposerContent: string;
} }
type WysiwygComposerState = {
content: string;
isRichText: boolean;
replyEventId?: string;
};
export class MessageComposer extends React.Component<IProps, IState> { export class MessageComposer extends React.Component<IProps, IState> {
private dispatcherRef?: string; private dispatcherRef?: string;
private messageComposerInput = createRef<SendMessageComposerClass>(); 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>) { public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context); 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); 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 = { this.state = {
isComposerEmpty: true, isComposerEmpty: initialComposerContent?.length === 0,
composerContent: "", composerContent: initialComposerContent,
haveRecording: false, haveRecording: false,
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
isMenuOpen: false, isMenuOpen: false,
@@ -141,9 +172,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"), showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"), showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
isWysiwygLabEnabled: SettingsStore.getValue<boolean>("feature_wysiwyg_composer"), isWysiwygLabEnabled: isWysiwygLabEnabled,
isRichTextEnabled: true, isRichTextEnabled: isRichTextEnabled,
initialComposerContent: "", initialComposerContent: initialComposerContent,
}; };
this.instanceId = instanceCount++; this.instanceId = instanceCount++;
@@ -154,6 +185,52 @@ export class MessageComposer extends React.Component<IProps, IState> {
SettingsStore.monitorSetting("feature_wysiwyg_composer", null); 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> { private get voiceRecording(): Optional<VoiceMessageRecording> {
return this._voiceRecording; return this._voiceRecording;
} }
@@ -265,6 +342,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`); UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize); 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) // clean up our listeners by setting our cached recording to falsy (see internal setter)
this.voiceRecording = null; this.voiceRecording = null;
} }

View File

@@ -15,112 +15,241 @@ See the License for the specific language governing permissions and
limitations under the License. limitations under the License.
*/ */
import React from "react"; import React, { JSX, useCallback, useState } from "react";
import { MatrixEvent, EventType, RelationType, Relations } from "matrix-js-sdk/src/matrix"; 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 dis from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions"; import { Action } from "../../../dispatcher/actions";
import AccessibleButton from "../elements/AccessibleButton";
import MessageEvent from "../messages/MessageEvent"; import MessageEvent from "../messages/MessageEvent";
import MemberAvatar from "../avatars/MemberAvatar"; import MemberAvatar from "../avatars/MemberAvatar";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import { formatDate } from "../../../DateUtils";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { getUserNameColorClass } from "../../../utils/FormattingUtils"; import { getUserNameColorClass } from "../../../utils/FormattingUtils";
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks"; 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; event: MatrixEvent;
/**
* The permalink creator to use.
*/
permalinkCreator: RoomPermalinkCreator; permalinkCreator: RoomPermalinkCreator;
onUnpinClicked?(): void; /**
* The room the event is in.
*/
room: Room;
} }
const AVATAR_SIZE = "24px"; /**
* A pinned event tile.
export default class PinnedEventTile extends React.Component<IProps> { */
public static contextType = MatrixClientContext; export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTileProps): JSX.Element {
public declare context: React.ContextType<typeof MatrixClientContext>; const sender = event.getSender();
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) { if (!sender) {
throw new Error("Pinned event unexpectedly has no sender"); throw new Error("Pinned event unexpectedly has no sender");
} }
let unpinButton: JSX.Element | undefined; const isInThread = Boolean(event.threadRootId);
if (this.props.onUnpinClicked) { const displayThreadInfo = !event.isThreadRoot && isInThread;
unpinButton = (
<AccessibleButton
onClick={this.props.onUnpinClicked}
className="mx_PinnedEventTile_unpinButton"
title={_t("action|unpin")}
/>
);
}
return ( return (
<div className="mx_PinnedEventTile"> <div className="mx_PinnedEventTile" role="listitem">
<div>
<MemberAvatar <MemberAvatar
className="mx_PinnedEventTile_senderAvatar" className="mx_PinnedEventTile_senderAvatar"
member={this.props.event.sender} member={event.sender}
size={AVATAR_SIZE} size={AVATAR_SIZE}
fallbackUserId={sender} fallbackUserId={sender}
/> />
</div>
<span className={"mx_PinnedEventTile_sender " + getUserNameColorClass(sender)}> <div className="mx_PinnedEventTile_wrapper">
{this.props.event.sender?.name || sender} <div className="mx_PinnedEventTile_top">
</span> <Text
weight="semibold"
{unpinButton} className={classNames("mx_PinnedEventTile_sender", getUserNameColorClass(sender))}
as="span"
<div className="mx_PinnedEventTile_message"> >
{event.sender?.name || sender}
</Text>
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} />
</div>
<MessageEvent <MessageEvent
mxEvent={this.props.event} mxEvent={event}
getRelationsForEvent={this.getRelationsForEvent}
// @ts-ignore - complaining that className is invalid when it's not
className="mx_PinnedEventTile_body"
maxImageHeight={150} maxImageHeight={150}
onHeightChanged={() => {}} // we need to give this, apparently onHeightChanged={() => {}} // we need to give this, apparently
permalinkCreator={this.props.permalinkCreator} permalinkCreator={permalinkCreator}
replacingEventId={this.props.event.replacingEventId()} 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 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> </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. 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 { 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 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"; 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 PosthogTrackers from "../../../PosthogTrackers";
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton"; import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
import { RoomKnocksBar } from "./RoomKnocksBar"; import { RoomKnocksBar } from "./RoomKnocksBar";
import { isVideoRoom } from "../../../utils/video-rooms"; import { useIsVideoRoom } from "../../../utils/video-rooms";
import { notificationLevelToIndicator } from "../../../utils/notifications"; import { notificationLevelToIndicator } from "../../../utils/notifications";
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton"; import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
import { ButtonEvent } from "../elements/AccessibleButton"; import { ButtonEvent } from "../elements/AccessibleButton";
@@ -59,6 +59,8 @@ import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnounc
import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore"; import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore";
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator"; import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
import { IOOBData } from "../../../stores/ThreepidInviteStore"; import { IOOBData } from "../../../stores/ThreepidInviteStore";
import RoomContext from "../../../contexts/RoomContext";
import { MainSplitContentType } from "../../structures/RoomView";
export default function RoomHeader({ export default function RoomHeader({
room, room,
@@ -233,6 +235,13 @@ export default function RoomHeader({
const isReleaseAnnouncementOpen = useIsReleaseAnnouncementOpen("newRoomHeader"); const isReleaseAnnouncementOpen = useIsReleaseAnnouncementOpen("newRoomHeader");
const roomContext = useContext(RoomContext);
const isVideoRoom = useIsVideoRoom(room);
const showChatButton =
isVideoRoom ||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
roomContext.mainSplitContentType === MainSplitContentType.Call;
return ( return (
<> <>
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel"> <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} />} {isViewingCall && <CallGuestLinkButton room={room} />}
{((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && <VideoRoomChatButton room={room} />}
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? ( {hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
joinCallButton joinCallButton
) : ( ) : (
<> <>
{!isVideoRoom(room) && videoCallButton} {!isVideoRoom && videoCallButton}
{!useElementCallExclusively && !isVideoRoom(room) && voiceCallButton} {!useElementCallExclusively && !isVideoRoom && voiceCallButton}
</> </>
)} )}
@@ -347,6 +355,9 @@ export default function RoomHeader({
<RoomInfoIcon /> <RoomInfoIcon />
</IconButton> </IconButton>
</Tooltip> </Tooltip>
{showChatButton && <VideoRoomChatButton room={room} />}
<Tooltip label={_t("common|threads")}> <Tooltip label={_t("common|threads")}>
<IconButton <IconButton
indicator={notificationLevelToIndicator(threadNotifications)} indicator={notificationLevelToIndicator(threadNotifications)}

View File

@@ -23,6 +23,7 @@ import { _t } from "../../../languageHandler";
import { PosthogScreenTracker } from "../../../PosthogTrackers"; import { PosthogScreenTracker } from "../../../PosthogTrackers";
import SearchWarning, { WarningKind } from "../elements/SearchWarning"; import SearchWarning, { WarningKind } from "../elements/SearchWarning";
import { SearchInfo, SearchScope } from "../../../Searching"; import { SearchInfo, SearchScope } from "../../../Searching";
import InlineSpinner from "../elements/InlineSpinner";
interface Props { interface Props {
searchInfo?: SearchInfo; searchInfo?: SearchInfo;
@@ -41,13 +42,15 @@ const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSe
<div className="mx_RoomSearchAuxPanel_summary"> <div className="mx_RoomSearchAuxPanel_summary">
<SearchIcon width="24px" height="24px" /> <SearchIcon width="24px" height="24px" />
<div className="mx_RoomSearchAuxPanel_summary_text"> <div className="mx_RoomSearchAuxPanel_summary_text">
{searchInfo {searchInfo?.count !== undefined ? (
? _t( _t(
"room|search|summary", "room|search|summary",
{ count: searchInfo.count ?? 0 }, { count: searchInfo.count },
{ query: () => <b>{searchInfo.term}</b> }, { query: () => <b>{searchInfo.term}</b> },
) )
: undefined} ) : (
<InlineSpinner />
)}
<SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} /> <SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} />
</div> </div>
</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 { ThreepidMedium } from "matrix-js-sdk/src/matrix";
import { Alert } from "@vector-im/compound-web"; import { Alert } from "@vector-im/compound-web";
import AccountEmailAddresses from "./account/EmailAddresses";
import AccountPhoneNumbers from "./account/PhoneNumbers";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import InlineSpinner from "../elements/InlineSpinner"; import InlineSpinner from "../elements/InlineSpinner";
import SettingsSubsection from "./shared/SettingsSubsection"; import SettingsSubsection from "./shared/SettingsSubsection";
@@ -27,6 +25,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../AddThreepid"; import { ThirdPartyIdentifier } from "../../../AddThreepid";
import SettingsStore from "../../../settings/SettingsStore"; import SettingsStore from "../../../settings/SettingsStore";
import { UIFeature } from "../../../settings/UIFeature"; import { UIFeature } from "../../../settings/UIFeature";
import { AddRemoveThreepids } from "./AddRemoveThreepids";
type LoadingState = "loading" | "loaded" | "error"; type LoadingState = "loading" | "loaded" | "error";
@@ -64,8 +63,7 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
const client = useMatrixClientContext(); const client = useMatrixClientContext();
useEffect(() => { const updateThreepids = useCallback(async () => {
(async () => {
try { try {
const threepids = await client.getThreePids(); const threepids = await client.getThreePids();
setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email)); setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email));
@@ -74,16 +72,19 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
} catch (e) { } catch (e) {
setLoadingState("error"); setLoadingState("error");
} }
})();
}, [client]); }, [client]);
const onEmailsChange = useCallback((emails: ThirdPartyIdentifier[]) => { useEffect(() => {
setEmails(emails); updateThreepids().then();
}, []); }, [updateThreepids]);
const onMsisdnsChange = useCallback((msisdns: ThirdPartyIdentifier[]) => { const onEmailsChange = useCallback(() => {
setPhoneNumbers(msisdns); updateThreepids().then();
}, []); }, [updateThreepids]);
const onMsisdnsChange = useCallback(() => {
updateThreepids().then();
}, [updateThreepids]);
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null; 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")} error={_t("settings|general|unable_to_load_emails")}
loadingState={loadingState} loadingState={loadingState}
> >
<AccountEmailAddresses <AddRemoveThreepids
emails={emails!} mode="hs"
onEmailsChange={onEmailsChange} medium={ThreepidMedium.Email}
threepids={emails!}
onChange={onEmailsChange}
disabled={!canMake3pidChanges} disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/> />
</ThreepidSectionWrapper> </ThreepidSectionWrapper>
</SettingsSubsection> </SettingsSubsection>
@@ -116,10 +120,13 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
error={_t("settings|general|unable_to_load_msisdns")} error={_t("settings|general|unable_to_load_msisdns")}
loadingState={loadingState} loadingState={loadingState}
> >
<AccountPhoneNumbers <AddRemoveThreepids
msisdns={phoneNumbers!} mode="hs"
onMsisdnsChange={onMsisdnsChange} medium={ThreepidMedium.Phone}
threepids={phoneNumbers!}
onChange={onMsisdnsChange}
disabled={!canMake3pidChanges} disabled={!canMake3pidChanges}
isLoading={loadingState === "loading"}
/> />
</ThreepidSectionWrapper> </ThreepidSectionWrapper>
</SettingsSubsection> </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 { logger } from "matrix-js-sdk/src/logger";
import { Alert } from "@vector-im/compound-web"; import { Alert } from "@vector-im/compound-web";
import DiscoveryEmailAddresses from "../discovery/EmailAddresses";
import DiscoveryPhoneNumbers from "../discovery/PhoneNumbers";
import { getThreepidsWithBindStatus } from "../../../../boundThreepids"; import { getThreepidsWithBindStatus } from "../../../../boundThreepids";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
import { ThirdPartyIdentifier } from "../../../../AddThreepid"; import { ThirdPartyIdentifier } from "../../../../AddThreepid";
@@ -36,6 +34,7 @@ import { abbreviateUrl } from "../../../../utils/UrlUtils";
import { useDispatcher } from "../../../../hooks/useDispatcher"; import { useDispatcher } from "../../../../hooks/useDispatcher";
import defaultDispatcher from "../../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { ActionPayload } from "../../../../dispatcher/payloads"; import { ActionPayload } from "../../../../dispatcher/payloads";
import { AddRemoveThreepids } from "../AddRemoveThreepids";
type RequiredPolicyInfo = type RequiredPolicyInfo =
| { | {
@@ -56,9 +55,9 @@ type RequiredPolicyInfo =
export const DiscoverySettings: React.FC = () => { export const DiscoverySettings: React.FC = () => {
const client = useMatrixClientContext(); const client = useMatrixClientContext();
const [isLoadingThreepids, setIsLoadingThreepids] = useState<boolean>(true);
const [emails, setEmails] = useState<ThirdPartyIdentifier[]>([]); const [emails, setEmails] = useState<ThirdPartyIdentifier[]>([]);
const [phoneNumbers, setPhoneNumbers] = 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 [idServerName, setIdServerName] = useState<string | undefined>(abbreviateUrl(client.getIdentityServerUrl()));
const [canMake3pidChanges, setCanMake3pidChanges] = useState<boolean>(false); const [canMake3pidChanges, setCanMake3pidChanges] = useState<boolean>(false);
@@ -71,9 +70,11 @@ export const DiscoverySettings: React.FC = () => {
const [hasTerms, setHasTerms] = useState<boolean>(false); const [hasTerms, setHasTerms] = useState<boolean>(false);
const getThreepidState = useCallback(async () => { const getThreepidState = useCallback(async () => {
setIsLoadingThreepids(true);
const threepids = await getThreepidsWithBindStatus(client); const threepids = await getThreepidsWithBindStatus(client);
setEmails(threepids.filter((a) => a.medium === ThreepidMedium.Email)); setEmails(threepids.filter((a) => a.medium === ThreepidMedium.Email));
setPhoneNumbers(threepids.filter((a) => a.medium === ThreepidMedium.Phone)); setPhoneNumbers(threepids.filter((a) => a.medium === ThreepidMedium.Phone));
setIsLoadingThreepids(false);
}, [client]); }, [client]);
useDispatcher( useDispatcher(
@@ -133,11 +134,7 @@ export const DiscoverySettings: React.FC = () => {
); );
logger.warn(e); logger.warn(e);
} }
} catch (e) {}
setLoadingState("loaded");
} catch (e) {
setLoadingState("error");
}
})(); })();
}, [client, getThreepidState]); }, [client, getThreepidState]);
@@ -163,23 +160,44 @@ export const DiscoverySettings: React.FC = () => {
); );
} }
const threepidSection = idServerName ? ( let threepidSection;
if (idServerName) {
threepidSection = (
<> <>
<DiscoveryEmailAddresses <SettingsSubsection
emails={emails} heading={_t("settings|general|emails_heading")}
isLoading={loadingState === "loading"} description={emails.length === 0 ? _t("settings|general|discovery_email_empty") : undefined}
stretchContent
>
<AddRemoveThreepids
mode="is"
medium={ThreepidMedium.Email}
threepids={emails}
onChange={getThreepidState}
disabled={!canMake3pidChanges} disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/> />
<DiscoveryPhoneNumbers </SettingsSubsection>
msisdns={phoneNumbers} <SettingsSubsection
isLoading={loadingState === "loading"} 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} disabled={!canMake3pidChanges}
isLoading={isLoadingThreepids}
/> />
</SettingsSubsection>
</> </>
) : null; );
}
return ( return (
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection"> <SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection" stretchContent>
{threepidSection} {threepidSection}
{/* has its own heading as it includes the current identity server */} {/* has its own heading as it includes the current identity server */}
<SetIdServer missingTerms={false} /> <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", "export_chat_button": "Exportovat chat",
"files_button": "Soubory", "files_button": "Soubory",
"pinned_messages": { "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": { "limits": {
"other": "Můžete připnout až %(count)s widgetů" "other": "Můžete připnout až %(count)s widgetů"
}, },
@@ -2454,7 +2452,6 @@
"error_share_msisdn_discovery": "Nepovedlo se nasdílet telefonní číslo", "error_share_msisdn_discovery": "Nepovedlo se nasdílet telefonní číslo",
"identity_server_no_token": "Nebyl nalezen žádný přístupový token identity", "identity_server_no_token": "Nebyl nalezen žádný přístupový token identity",
"identity_server_not_set": "Server identit není nastaven", "identity_server_not_set": "Server identit není nastaven",
"incorrect_msisdn_verification": "Nesprávný ověřovací kód",
"language_section": "Jazyk a region", "language_section": "Jazyk a region",
"msisdn_in_use": "Toto telefonní číslo je již používáno", "msisdn_in_use": "Toto telefonní číslo je již používáno",
"msisdn_label": "Telefonní číslo", "msisdn_label": "Telefonní číslo",

View File

@@ -1778,8 +1778,6 @@
"export_chat_button": "Unterhaltung exportieren", "export_chat_button": "Unterhaltung exportieren",
"files_button": "Dateien", "files_button": "Dateien",
"pinned_messages": { "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": { "limits": {
"other": "Du kannst nur %(count)s Widgets anheften" "other": "Du kannst nur %(count)s Widgets anheften"
}, },
@@ -2433,7 +2431,6 @@
"error_share_msisdn_discovery": "Teilen der Telefonnummer nicht möglich", "error_share_msisdn_discovery": "Teilen der Telefonnummer nicht möglich",
"identity_server_no_token": "Kein Identitäts-Zugangs-Token gefunden", "identity_server_no_token": "Kein Identitäts-Zugangs-Token gefunden",
"identity_server_not_set": "Kein Identitäts-Server festgelegt", "identity_server_not_set": "Kein Identitäts-Server festgelegt",
"incorrect_msisdn_verification": "Falscher Verifizierungscode",
"language_section": "Sprache und Region", "language_section": "Sprache und Region",
"msisdn_in_use": "Diese Telefonnummer wird bereits verwendet", "msisdn_in_use": "Diese Telefonnummer wird bereits verwendet",
"msisdn_label": "Telefonnummer", "msisdn_label": "Telefonnummer",

View File

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

View File

@@ -1839,12 +1839,25 @@
"files_button": "Files", "files_button": "Files",
"info": "Info", "info": "Info",
"pinned_messages": { "pinned_messages": {
"empty": "Nothing pinned, yet", "empty_description": "Select a message and choose “%(pinAction)s” to it include here.",
"explainer": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them 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": { "limits": {
"other": "You can only pin up to %(count)s widgets" "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", "pinned_messages_button": "Pinned messages",
"poll": { "poll": {
@@ -2532,7 +2545,6 @@
"error_share_msisdn_discovery": "Unable to share phone number", "error_share_msisdn_discovery": "Unable to share phone number",
"identity_server_no_token": "No identity access token found", "identity_server_no_token": "No identity access token found",
"identity_server_not_set": "Identity server not set", "identity_server_not_set": "Identity server not set",
"incorrect_msisdn_verification": "Incorrect verification code",
"language_section": "Language", "language_section": "Language",
"msisdn_in_use": "This phone number is already in use", "msisdn_in_use": "This phone number is already in use",
"msisdn_label": "Phone Number", "msisdn_label": "Phone Number",

View File

@@ -1290,8 +1290,6 @@
"export_chat_button": "Eksporti babilejon", "export_chat_button": "Eksporti babilejon",
"files_button": "Dosieroj", "files_button": "Dosieroj",
"pinned_messages": { "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": { "limits": {
"other": "Vi povas fiksi maksimume %(count)s fenestraĵojn" "other": "Vi povas fiksi maksimume %(count)s fenestraĵojn"
}, },
@@ -1754,7 +1752,6 @@
"error_revoke_msisdn_discovery": "Ne povas senvalidigi havigadon je telefonnumero", "error_revoke_msisdn_discovery": "Ne povas senvalidigi havigadon je telefonnumero",
"error_share_email_discovery": "Ne povas havigi vian retpoŝtadreson", "error_share_email_discovery": "Ne povas havigi vian retpoŝtadreson",
"error_share_msisdn_discovery": "Ne povas havigi telefonnumeron", "error_share_msisdn_discovery": "Ne povas havigi telefonnumeron",
"incorrect_msisdn_verification": "Malĝusta kontrola kodo",
"language_section": "Lingvo kaj regiono", "language_section": "Lingvo kaj regiono",
"msisdn_in_use": "Tiu ĉi telefonnumero jam estas uzata", "msisdn_in_use": "Tiu ĉi telefonnumero jam estas uzata",
"msisdn_label": "Telefonnumero", "msisdn_label": "Telefonnumero",

View File

@@ -1651,8 +1651,6 @@
"export_chat_button": "Exportar conversación", "export_chat_button": "Exportar conversación",
"files_button": "Archivos", "files_button": "Archivos",
"pinned_messages": { "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": { "limits": {
"other": "Solo puedes anclar hasta %(count)s accesorios" "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_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", "error_share_msisdn_discovery": "No se logró compartir el número de teléfono",
"identity_server_not_set": "Servidor de identidad no configurado", "identity_server_not_set": "Servidor de identidad no configurado",
"incorrect_msisdn_verification": "Verificación de código incorrecta",
"language_section": "Idioma y región", "language_section": "Idioma y región",
"msisdn_in_use": "Este número de teléfono ya está en uso", "msisdn_in_use": "Este número de teléfono ya está en uso",
"msisdn_label": "Número de teléfono", "msisdn_label": "Número de teléfono",

View File

@@ -1775,8 +1775,6 @@
"export_chat_button": "Ekspordi vestlus", "export_chat_button": "Ekspordi vestlus",
"files_button": "Failid", "files_button": "Failid",
"pinned_messages": { "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": { "limits": {
"other": "Sa saad kinnitada kuni %(count)s vidinat" "other": "Sa saad kinnitada kuni %(count)s vidinat"
}, },
@@ -2416,7 +2414,6 @@
"error_share_msisdn_discovery": "Telefoninumbri jagamine ei õnnestunud", "error_share_msisdn_discovery": "Telefoninumbri jagamine ei õnnestunud",
"identity_server_no_token": "Ei leidu tunnusluba isikutuvastusserveri jaoks", "identity_server_no_token": "Ei leidu tunnusluba isikutuvastusserveri jaoks",
"identity_server_not_set": "Isikutuvastusserver on määramata", "identity_server_not_set": "Isikutuvastusserver on määramata",
"incorrect_msisdn_verification": "Vigane verifikatsioonikood",
"language_section": "Keel ja piirkond", "language_section": "Keel ja piirkond",
"msisdn_in_use": "See telefoninumber on juba kasutusel", "msisdn_in_use": "See telefoninumber on juba kasutusel",
"msisdn_label": "Telefoninumber", "msisdn_label": "Telefoninumber",

View File

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

View File

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

View File

@@ -1829,8 +1829,6 @@
"export_chat_button": "Exporter la conversation", "export_chat_button": "Exporter la conversation",
"files_button": "Fichiers", "files_button": "Fichiers",
"pinned_messages": { "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": { "limits": {
"other": "Vous ne pouvez épingler que jusquà %(count)s widgets" "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", "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_no_token": "Aucun jeton daccès didentité trouvé",
"identity_server_not_set": "Serveur d'identité non défini", "identity_server_not_set": "Serveur d'identité non défini",
"incorrect_msisdn_verification": "Code de vérification incorrect",
"language_section": "Langue et région", "language_section": "Langue et région",
"msisdn_in_use": "Ce numéro de téléphone est déjà utilisé", "msisdn_in_use": "Ce numéro de téléphone est déjà utilisé",
"msisdn_label": "Numéro de téléphone", "msisdn_label": "Numéro de téléphone",

View File

@@ -1536,8 +1536,6 @@
"export_chat_button": "Exportar chat", "export_chat_button": "Exportar chat",
"files_button": "Ficheiros", "files_button": "Ficheiros",
"pinned_messages": { "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": { "limits": {
"other": "Só podes fixar ata %(count)s widgets" "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_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_email_discovery": "Non se puido compartir co enderezo de email",
"error_share_msisdn_discovery": "Non se puido compartir o número de teléfono", "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", "language_section": "Idioma e rexión",
"msisdn_in_use": "Xa se está a usar este teléfono", "msisdn_in_use": "Xa se está a usar este teléfono",
"msisdn_label": "Número de teléfono", "msisdn_label": "Número de teléfono",

View File

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

View File

@@ -1745,8 +1745,6 @@
"export_chat_button": "Beszélgetés exportálása", "export_chat_button": "Beszélgetés exportálása",
"files_button": "Fájlok", "files_button": "Fájlok",
"pinned_messages": { "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": { "limits": {
"other": "Csak %(count)s kisalkalmazást tud kitűzni" "other": "Csak %(count)s kisalkalmazást tud kitűzni"
}, },
@@ -2375,7 +2373,6 @@
"error_share_msisdn_discovery": "A telefonszámot nem sikerült megosztani", "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_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", "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ó", "language_section": "Nyelv és régió",
"msisdn_in_use": "Ez a telefonszám már használatban van", "msisdn_in_use": "Ez a telefonszám már használatban van",
"msisdn_label": "Telefonszám", "msisdn_label": "Telefonszám",

View File

@@ -1757,8 +1757,6 @@
"export_chat_button": "Ekspor obrolan", "export_chat_button": "Ekspor obrolan",
"files_button": "File", "files_button": "File",
"pinned_messages": { "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": { "limits": {
"other": "Anda hanya dapat memasang pin sampai %(count)s widget" "other": "Anda hanya dapat memasang pin sampai %(count)s widget"
}, },
@@ -2408,7 +2406,6 @@
"error_share_msisdn_discovery": "Tidak dapat membagikan nomor telepon", "error_share_msisdn_discovery": "Tidak dapat membagikan nomor telepon",
"identity_server_no_token": "Tidak ada token akses identitas yang ditemukan", "identity_server_no_token": "Tidak ada token akses identitas yang ditemukan",
"identity_server_not_set": "Server identitas tidak diatur", "identity_server_not_set": "Server identitas tidak diatur",
"incorrect_msisdn_verification": "Kode verifikasi tidak benar",
"language_section": "Bahasa dan wilayah", "language_section": "Bahasa dan wilayah",
"msisdn_in_use": "Nomor telepon ini telah dipakai", "msisdn_in_use": "Nomor telepon ini telah dipakai",
"msisdn_label": "Nomor Telepon", "msisdn_label": "Nomor Telepon",

View File

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

View File

@@ -1791,8 +1791,6 @@
"export_chat_button": "Esporta conversazione", "export_chat_button": "Esporta conversazione",
"files_button": "File", "files_button": "File",
"pinned_messages": { "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": { "limits": {
"other": "Puoi ancorare al massimo %(count)s widget" "other": "Puoi ancorare al massimo %(count)s widget"
}, },
@@ -2450,7 +2448,6 @@
"error_share_msisdn_discovery": "Impossibile condividere il numero di telefono", "error_share_msisdn_discovery": "Impossibile condividere il numero di telefono",
"identity_server_no_token": "Nessun token di accesso d'identità trovato", "identity_server_no_token": "Nessun token di accesso d'identità trovato",
"identity_server_not_set": "Server d'identità non impostato", "identity_server_not_set": "Server d'identità non impostato",
"incorrect_msisdn_verification": "Codice di verifica sbagliato",
"language_section": "Lingua e regione", "language_section": "Lingua e regione",
"msisdn_in_use": "Questo numero di telefono è già in uso", "msisdn_in_use": "Questo numero di telefono è già in uso",
"msisdn_label": "Numero di telefono", "msisdn_label": "Numero di telefono",

View File

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

View File

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

View File

@@ -1084,8 +1084,6 @@
"export_chat_button": "Eksportuoti pokalbį", "export_chat_button": "Eksportuoti pokalbį",
"files_button": "Failai", "files_button": "Failai",
"pinned_messages": { "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": { "limits": {
"other": "Galite prisegti tik iki %(count)s valdiklių" "other": "Galite prisegti tik iki %(count)s valdiklių"
}, },
@@ -1568,7 +1566,6 @@
"error_revoke_msisdn_discovery": "Neina atšaukti telefono numerio bendrinimo", "error_revoke_msisdn_discovery": "Neina atšaukti telefono numerio bendrinimo",
"error_share_email_discovery": "Nepavyko pasidalinti el. pašto adresu", "error_share_email_discovery": "Nepavyko pasidalinti el. pašto adresu",
"error_share_msisdn_discovery": "Neina bendrinti telefono numerio", "error_share_msisdn_discovery": "Neina bendrinti telefono numerio",
"incorrect_msisdn_verification": "Neteisingas patvirtinimo kodas",
"language_section": "Kalba ir regionas", "language_section": "Kalba ir regionas",
"msisdn_in_use": "Šis telefono numeris jau naudojamas", "msisdn_in_use": "Šis telefono numeris jau naudojamas",
"msisdn_label": "Telefono Numeris", "msisdn_label": "Telefono Numeris",

View File

@@ -1516,8 +1516,6 @@
"export_chat_button": "Chat exporteren", "export_chat_button": "Chat exporteren",
"files_button": "Bestanden", "files_button": "Bestanden",
"pinned_messages": { "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": { "limits": {
"other": "Je kunt maar %(count)s widgets vastzetten" "other": "Je kunt maar %(count)s widgets vastzetten"
}, },
@@ -2076,7 +2074,6 @@
"error_revoke_msisdn_discovery": "Kan delen voor dit telefoonnummer niet intrekken", "error_revoke_msisdn_discovery": "Kan delen voor dit telefoonnummer niet intrekken",
"error_share_email_discovery": "Kan e-mailadres niet delen", "error_share_email_discovery": "Kan e-mailadres niet delen",
"error_share_msisdn_discovery": "Kan telefoonnummer niet delen", "error_share_msisdn_discovery": "Kan telefoonnummer niet delen",
"incorrect_msisdn_verification": "Onjuiste verificatiecode",
"language_section": "Taal en regio", "language_section": "Taal en regio",
"msisdn_in_use": "Dit telefoonnummer is al in gebruik", "msisdn_in_use": "Dit telefoonnummer is al in gebruik",
"msisdn_label": "Telefoonnummer", "msisdn_label": "Telefoonnummer",

View File

@@ -1629,7 +1629,7 @@
"level_none": "Brak", "level_none": "Brak",
"level_notification": "Powiadomienie", "level_notification": "Powiadomienie",
"level_unsent": "Niewysłane", "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": "@wzmianki & słowa kluczowe",
"mentions_and_keywords_description": "Otrzymuj powiadomienia tylko z wzmiankami i słowami kluczowymi zgodnie z Twoimi <a>ustawieniami</a>", "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", "mentions_keywords": "Wzmianki i słowa kluczowe",
@@ -1841,8 +1841,6 @@
"files_button": "Pliki", "files_button": "Pliki",
"info": "Info", "info": "Info",
"pinned_messages": { "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": { "limits": {
"other": "Możesz przypiąć do %(count)s widżetów" "other": "Możesz przypiąć do %(count)s widżetów"
}, },
@@ -2008,7 +2006,7 @@
"joining": "Dołączanie…", "joining": "Dołączanie…",
"joining_room": "Dołączanie do pokoju…", "joining_room": "Dołączanie do pokoju…",
"joining_space": "Dołączanie do przestrzeni…", "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_bottom_button": "Przewiń do najnowszych wiadomości",
"jump_to_date": "Przeskocz do daty", "jump_to_date": "Przeskocz do daty",
"jump_to_date_beginning": "Początek pokoju", "jump_to_date_beginning": "Początek pokoju",
@@ -2426,6 +2424,10 @@
} }
}, },
"settings": { "settings": {
"account": {
"dialog_title": "<strong>Ustawienia:</strong> Konto",
"title": "Konto"
},
"all_rooms_home": "Pokaż wszystkie pokoje na ekranie głównym", "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.", "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", "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", "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_no_token": "Nie znaleziono tokena dostępu tożsamości",
"identity_server_not_set": "Serwer tożsamości nie jest ustawiony", "identity_server_not_set": "Serwer tożsamości nie jest ustawiony",
"incorrect_msisdn_verification": "Nieprawidłowy kod weryfikujący",
"language_section": "Język", "language_section": "Język",
"msisdn_in_use": "Ten numer telefonu jest już zajęty", "msisdn_in_use": "Ten numer telefonu jest już zajęty",
"msisdn_label": "Numer telefonu", "msisdn_label": "Numer telefonu",
@@ -3896,8 +3897,8 @@
"disable_camera": "Wyłącz kamerę", "disable_camera": "Wyłącz kamerę",
"disable_microphone": "Wycisz mikrofon", "disable_microphone": "Wycisz mikrofon",
"disabled_no_one_here": "Nie ma tu nikogo, do kogo można zadzwonić", "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_video_call": "Nie masz 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_voice_call": "Nie masz uprawnień do rozpoczęcia rozmowy głosowej",
"disabled_ongoing_call": "Rozmowa w toku", "disabled_ongoing_call": "Rozmowa w toku",
"element_call": "Element Call", "element_call": "Element Call",
"enable_camera": "Włącz kamerę", "enable_camera": "Włącz kamerę",

View File

@@ -1196,8 +1196,6 @@
"export_chat_button": "Exportar conversa", "export_chat_button": "Exportar conversa",
"files_button": "Arquivos", "files_button": "Arquivos",
"pinned_messages": { "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": { "limits": {
"other": "Você pode fixar até %(count)s widgets" "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_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_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", "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", "language_section": "Idioma e região",
"msisdn_in_use": "Este número de telefone já está em uso", "msisdn_in_use": "Este número de telefone já está em uso",
"msisdn_label": "Número de telefone", "msisdn_label": "Número de telefone",

View File

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

View File

@@ -1779,8 +1779,6 @@
"export_chat_button": "Exportovať konverzáciu", "export_chat_button": "Exportovať konverzáciu",
"files_button": "Súbory", "files_button": "Súbory",
"pinned_messages": { "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": { "limits": {
"other": "Môžete pripnúť iba %(count)s widgetov" "other": "Môžete pripnúť iba %(count)s widgetov"
}, },
@@ -2437,7 +2435,6 @@
"error_share_msisdn_discovery": "Nepodarilo sa zdieľanie telefónneho čísla", "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_no_token": "Nenašiel sa prístupový token totožnosti",
"identity_server_not_set": "Server totožnosti nie je nastavený", "identity_server_not_set": "Server totožnosti nie je nastavený",
"incorrect_msisdn_verification": "Nesprávny overovací kód",
"language_section": "Jazyk a región", "language_section": "Jazyk a región",
"msisdn_in_use": "Toto telefónne číslo sa už používa", "msisdn_in_use": "Toto telefónne číslo sa už používa",
"msisdn_label": "Telefónne číslo", "msisdn_label": "Telefónne číslo",

View File

@@ -1680,8 +1680,6 @@
"export_chat_button": "Eksportoni fjalosje", "export_chat_button": "Eksportoni fjalosje",
"files_button": "Kartela", "files_button": "Kartela",
"pinned_messages": { "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": { "limits": {
"other": "Mundeni të fiksoni deri në %(count)s widget-e" "other": "Mundeni të fiksoni deri në %(count)s widget-e"
}, },
@@ -2300,7 +2298,6 @@
"error_share_msisdn_discovery": "Sarrihet të ndahet numër telefoni", "error_share_msisdn_discovery": "Sarrihet të ndahet numër telefoni",
"identity_server_no_token": "Su gjet token hyrjeje identiteti", "identity_server_no_token": "Su gjet token hyrjeje identiteti",
"identity_server_not_set": "Shërbyes identitetesh i paujdisur", "identity_server_not_set": "Shërbyes identitetesh i paujdisur",
"incorrect_msisdn_verification": "Kod verifikimi i pasaktë",
"language_section": "Gjuhë dhe rajon", "language_section": "Gjuhë dhe rajon",
"msisdn_in_use": "Ky numër telefoni është tashmë në përdorim", "msisdn_in_use": "Ky numër telefoni është tashmë në përdorim",
"msisdn_label": "Numër Telefoni", "msisdn_label": "Numër Telefoni",

View File

@@ -1791,8 +1791,6 @@
"export_chat_button": "Exportera chatt", "export_chat_button": "Exportera chatt",
"files_button": "Filer", "files_button": "Filer",
"pinned_messages": { "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": { "limits": {
"other": "Du kan bara fästa upp till %(count)s widgets" "other": "Du kan bara fästa upp till %(count)s widgets"
}, },
@@ -2449,7 +2447,6 @@
"error_share_msisdn_discovery": "Kunde inte dela telefonnummer", "error_share_msisdn_discovery": "Kunde inte dela telefonnummer",
"identity_server_no_token": "Ingen identitetsåtkomsttoken hittades", "identity_server_no_token": "Ingen identitetsåtkomsttoken hittades",
"identity_server_not_set": "Identitetsserver inte inställd", "identity_server_not_set": "Identitetsserver inte inställd",
"incorrect_msisdn_verification": "Fel verifieringskod",
"language_section": "Språk och region", "language_section": "Språk och region",
"msisdn_in_use": "Detta telefonnummer används redan", "msisdn_in_use": "Detta telefonnummer används redan",
"msisdn_label": "Telefonnummer", "msisdn_label": "Telefonnummer",

View File

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

View File

@@ -1583,8 +1583,6 @@
"add_integrations": "Thêm các widget, bridge và bot", "add_integrations": "Thêm các widget, bridge và bot",
"export_chat_button": "Xuất trò chuyện", "export_chat_button": "Xuất trò chuyện",
"pinned_messages": { "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": { "limits": {
"other": "Bạn chỉ có thể ghim tối đa %(count)s widget" "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", "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_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", "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", "language_section": "Ngôn ngữ và khu vực",
"msisdn_in_use": "Số điện thoại này đã được sử dụng", "msisdn_in_use": "Số điện thoại này đã được sử dụng",
"msisdn_label": "Số điện thoại", "msisdn_label": "Số điện thoại",

View File

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

View File

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

View File

@@ -14,13 +14,17 @@ See the License for the specific language governing permissions and
limitations under the License. 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 { export default class PinningUtils {
/** /**
* Event types that may be pinned. * Event types that may be pinned.
*/ */
public static pinnableEventTypes: (EventType | string)[] = [ public static readonly PINNABLE_EVENT_TYPES: (EventType | string)[] = [
EventType.RoomMessage, EventType.RoomMessage,
M_POLL_START.name, M_POLL_START.name,
M_POLL_START.altName, M_POLL_START.altName,
@@ -33,9 +37,80 @@ export default class PinningUtils {
*/ */
public static isPinnable(event: MatrixEvent): boolean { public static isPinnable(event: MatrixEvent): boolean {
if (!event) return false; 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; if (event.isRedacted()) return false;
return true; 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 { logger } from "matrix-js-sdk/src/logger";
import fetchMockJest from "fetch-mock-jest"; 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 { advanceDateAndTime, stubClient } from "./test-utils";
import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg"; import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg";
import SettingsStore from "../src/settings/SettingsStore"; import SettingsStore from "../src/settings/SettingsStore";
import { SettingLevel } from "../src/settings/SettingLevel"; import { SettingLevel } from "../src/settings/SettingLevel";
import { ModuleRunner } from "../src/modules/ModuleRunner";
jest.useFakeTimers(); jest.useFakeTimers();
@@ -78,78 +73,6 @@ describe("MatrixClientPeg", () => {
expect(peg.userRegisteredWithinLastHours(24)).toBe(false); 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", () => { describe(".start", () => {
let testPeg: IMatrixClientPeg; let testPeg: IMatrixClientPeg;

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