Merge branch 'develop' into florianduros/fix/spotlight-click
34
CHANGELOG.md
@@ -1,3 +1,37 @@
|
||||
Changes in [3.107.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.107.0) (2024-08-20)
|
||||
=======================================================================================================
|
||||
* No changes
|
||||
|
||||
|
||||
Changes in [3.106.0](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.106.0) (2024-08-13)
|
||||
=======================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Invite dialog: display MXID on its own line ([#11756](https://github.com/matrix-org/matrix-react-sdk/pull/11756)). Contributed by @AndrewFerr.
|
||||
* Align RoomSummaryCard styles with Figma ([#12793](https://github.com/matrix-org/matrix-react-sdk/pull/12793)). Contributed by @t3chguy.
|
||||
* Extract Extensions into their own right panel tab ([#12844](https://github.com/matrix-org/matrix-react-sdk/pull/12844)). Contributed by @t3chguy.
|
||||
* Remove topic from new room header and expand right panel topic ([#12842](https://github.com/matrix-org/matrix-react-sdk/pull/12842)). Contributed by @t3chguy.
|
||||
* Rework how the onboarding notifications task works ([#12839](https://github.com/matrix-org/matrix-react-sdk/pull/12839)). Contributed by @t3chguy.
|
||||
* Update toast styles to match Figma ([#12833](https://github.com/matrix-org/matrix-react-sdk/pull/12833)). Contributed by @t3chguy.
|
||||
* Warn users on unsupported browsers before they lack features ([#12830](https://github.com/matrix-org/matrix-react-sdk/pull/12830)). Contributed by @t3chguy.
|
||||
* Add sign out button to settings profile section ([#12666](https://github.com/matrix-org/matrix-react-sdk/pull/12666)). Contributed by @dbkr.
|
||||
* Remove MatrixRTC realted import ES lint exceptions using a index.ts for matrixrtc ([#12780](https://github.com/matrix-org/matrix-react-sdk/pull/12780)). Contributed by @toger5.
|
||||
* Fix unwanted ringing of other devices even though the user is already connected to the call. ([#12742](https://github.com/matrix-org/matrix-react-sdk/pull/12742)). Contributed by @toger5.
|
||||
* Acknowledge `DeviceMute` widget actions ([#12790](https://github.com/matrix-org/matrix-react-sdk/pull/12790)). Contributed by @toger5.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix formatting of rich text emotes ([#12862](https://github.com/matrix-org/matrix-react-sdk/pull/12862)). Contributed by @dbkr.
|
||||
* Fixed custom emotes background color #27745 ([#12798](https://github.com/matrix-org/matrix-react-sdk/pull/12798)). Contributed by @asimdelvi.
|
||||
* Ignore permalink\_prefix when serializing pills ([#11726](https://github.com/matrix-org/matrix-react-sdk/pull/11726)). Contributed by @herkulessi.
|
||||
* Deflake the chat export test ([#12854](https://github.com/matrix-org/matrix-react-sdk/pull/12854)). Contributed by @dbkr.
|
||||
* Fix alignment of RTL messages ([#12837](https://github.com/matrix-org/matrix-react-sdk/pull/12837)). Contributed by @dbkr.
|
||||
* Handle media download errors better ([#12848](https://github.com/matrix-org/matrix-react-sdk/pull/12848)). Contributed by @t3chguy.
|
||||
* Make micIcon display on primary ([#11908](https://github.com/matrix-org/matrix-react-sdk/pull/11908)). Contributed by @kdanielm.
|
||||
* Fix compound typography font component issues ([#12826](https://github.com/matrix-org/matrix-react-sdk/pull/12826)). Contributed by @t3chguy.
|
||||
* Allow Chrome page translator to translate messages in rooms ([#11113](https://github.com/matrix-org/matrix-react-sdk/pull/11113)). Contributed by @lukaszpolowczyk.
|
||||
|
||||
|
||||
Changes in [3.105.1](https://github.com/matrix-org/matrix-react-sdk/releases/tag/v3.105.1) (2024-08-06)
|
||||
=======================================================================================================
|
||||
Fixes for CVE-2024-42347 / GHSA-f83w-wqhc-cfp4
|
||||
|
18
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "matrix-react-sdk",
|
||||
"version": "3.105.1",
|
||||
"version": "3.107.0",
|
||||
"description": "SDK for matrix.org using React",
|
||||
"author": "matrix.org",
|
||||
"repository": {
|
||||
@@ -26,10 +26,8 @@
|
||||
"engines": {
|
||||
"node": ">=20.0.0"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"matrix_src_main": "./src/index.ts",
|
||||
"matrix_lib_main": "./lib/index.ts",
|
||||
"matrix_lib_typings": "./lib/index.d.ts",
|
||||
"main": "./lib/index.ts",
|
||||
"typings": "./lib/index.d.ts",
|
||||
"matrix_i18n_extra_translation_funcs": [
|
||||
"UserFriendlyError"
|
||||
],
|
||||
@@ -82,7 +80,7 @@
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@sentry/browser": "^8.0.0",
|
||||
"@testing-library/react-hooks": "^8.0.1",
|
||||
"@vector-im/compound-design-tokens": "^1.6.1",
|
||||
"@vector-im/compound-design-tokens": "^1.8.0",
|
||||
"@vector-im/compound-web": "^5.5.0",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
@@ -191,7 +189,7 @@
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "17.0.25",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/sanitize-html": "2.11.0",
|
||||
"@types/sanitize-html": "2.13.0",
|
||||
"@types/sdp-transform": "^2.4.6",
|
||||
"@types/seedrandom": "3.0.8",
|
||||
"@types/tar-js": "^0.3.2",
|
||||
@@ -199,7 +197,7 @@
|
||||
"@types/uuid": "^10.0.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.0.0",
|
||||
"@typescript-eslint/parser": "^7.0.0",
|
||||
"axe-core": "4.9.1",
|
||||
"axe-core": "4.10.0",
|
||||
"babel-jest": "^29.0.0",
|
||||
"blob-polyfill": "^9.0.0",
|
||||
"eslint": "8.57.0",
|
||||
@@ -212,13 +210,13 @@
|
||||
"eslint-plugin-matrix-org": "1.2.1",
|
||||
"eslint-plugin-react": "^7.28.0",
|
||||
"eslint-plugin-react-hooks": "^4.3.0",
|
||||
"eslint-plugin-unicorn": "^54.0.0",
|
||||
"eslint-plugin-unicorn": "^55.0.0",
|
||||
"express": "^4.18.2",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
"fs-extra": "^11.0.0",
|
||||
"glob": "^11.0.0",
|
||||
"husky": "^8.0.3",
|
||||
"husky": "^9.0.0",
|
||||
"jest": "^29.6.2",
|
||||
"jest-canvas-mock": "^2.5.2",
|
||||
"jest-environment-jsdom": "^29.6.2",
|
||||
|
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
248
playwright/e2e/pinned-messages/index.ts
Normal 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 };
|
90
playwright/e2e/pinned-messages/pinned-messages.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
@@ -25,5 +25,6 @@ test.describe("User Menu", () => {
|
||||
|
||||
await expect(menu.locator(".mx_UserMenu_contextMenu_displayName", { hasText: user.displayName })).toBeVisible();
|
||||
await expect(menu.locator(".mx_UserMenu_contextMenu_userId", { hasText: user.userId })).toBeVisible();
|
||||
await expect(menu).toMatchScreenshot("user-menu.png");
|
||||
});
|
||||
});
|
||||
|
@@ -28,7 +28,7 @@ import { randB64Bytes } from "../../utils/rand";
|
||||
// Docker tag to use for synapse docker image.
|
||||
// We target a specific digest as every now and then a Synapse update will break our CI.
|
||||
// This digest is updated by the playwright-image-updates.yaml workflow periodically.
|
||||
const DOCKER_TAG = "develop@sha256:84be20dded1bd6bed7e5c5fbb30ce3b8fb40271d8558a3a81a8485b8960c911b";
|
||||
const DOCKER_TAG = "develop@sha256:4c891449943a2e7413a47784f3319caabfa185ad2caf96959f2175df34047917";
|
||||
|
||||
async function cfgDirFromTemplate(opts: StartHomeserverOpts): Promise<Omit<HomeserverConfig, "dockerUrl">> {
|
||||
const templateDir = path.join(__dirname, "templates", opts.template);
|
||||
|
Before Width: | Height: | Size: 215 KiB After Width: | Height: | Size: 213 KiB |
Before Width: | Height: | Size: 49 KiB After Width: | Height: | Size: 49 KiB |
After Width: | Height: | Size: 79 KiB |
After Width: | Height: | Size: 13 KiB |
After Width: | Height: | Size: 16 KiB |
After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 22 KiB After Width: | Height: | Size: 22 KiB |
Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 70 KiB After Width: | Height: | Size: 69 KiB |
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 48 KiB |
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 50 KiB |
Before Width: | Height: | Size: 61 KiB After Width: | Height: | Size: 62 KiB |
Before Width: | Height: | Size: 54 KiB After Width: | Height: | Size: 56 KiB |
Before Width: | Height: | Size: 19 KiB After Width: | Height: | Size: 20 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 13 KiB After Width: | Height: | Size: 13 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
After Width: | Height: | Size: 11 KiB |
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 113 KiB |
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 19 KiB |
@@ -604,7 +604,7 @@ legend {
|
||||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button),
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button),
|
||||
.mx_Dialog input[type="submit"],
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton),
|
||||
.mx_Dialog_buttons input[type="submit"] {
|
||||
@@ -624,14 +624,14 @@ legend {
|
||||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):last-child {
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):last-child {
|
||||
margin-right: 0px;
|
||||
}
|
||||
|
||||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):focus,
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):focus,
|
||||
.mx_Dialog input[type="submit"]:focus,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):focus,
|
||||
.mx_Dialog_buttons input[type="submit"]:focus {
|
||||
@@ -643,7 +643,7 @@ legend {
|
||||
.mx_Dialog_buttons
|
||||
button.mx_Dialog_primary:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button),
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button),
|
||||
.mx_Dialog_buttons input[type="submit"].mx_Dialog_primary {
|
||||
color: var(--cpd-color-text-on-solid-primary);
|
||||
background-color: var(--cpd-color-bg-action-primary-rest);
|
||||
@@ -656,7 +656,7 @@ legend {
|
||||
.mx_Dialog_buttons
|
||||
button.danger:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):not(.mx_UserProfileSettings button):not(
|
||||
.mx_ThemeChoicePanel_CustomTheme button
|
||||
),
|
||||
):not(.mx_UnpinAllDialog button),
|
||||
.mx_Dialog_buttons input[type="submit"].danger {
|
||||
background-color: var(--cpd-color-bg-critical-primary);
|
||||
border: solid 1px var(--cpd-color-bg-critical-primary);
|
||||
@@ -672,7 +672,7 @@ legend {
|
||||
.mx_Dialog
|
||||
button:not(.mx_Dialog_nonDialogButton):not([class|="maplibregl"]):not(.mx_AccessibleButton):not(
|
||||
.mx_UserProfileSettings button
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):disabled,
|
||||
):not(.mx_ThemeChoicePanel_CustomTheme button):not(.mx_UnpinAllDialog button):disabled,
|
||||
.mx_Dialog input[type="submit"]:disabled,
|
||||
.mx_Dialog_buttons button:not(.mx_Dialog_nonDialogButton):not(.mx_AccessibleButton):disabled,
|
||||
.mx_Dialog_buttons input[type="submit"]:disabled {
|
||||
|
@@ -37,7 +37,7 @@
|
||||
@import "./components/views/messages/shared/_MediaProcessingError.pcss";
|
||||
@import "./components/views/pips/_WidgetPip.pcss";
|
||||
@import "./components/views/polls/_PollOption.pcss";
|
||||
@import "./components/views/settings/_EmailAddressesPhoneNumbers.pcss";
|
||||
@import "./components/views/settings/_AddRemoveThreepids.pcss";
|
||||
@import "./components/views/settings/devices/_CurrentDeviceSection.pcss";
|
||||
@import "./components/views/settings/devices/_DeviceDetailHeading.pcss";
|
||||
@import "./components/views/settings/devices/_DeviceDetails.pcss";
|
||||
@@ -167,6 +167,7 @@
|
||||
@import "./views/dialogs/_SpaceSettingsDialog.pcss";
|
||||
@import "./views/dialogs/_SpotlightDialog.pcss";
|
||||
@import "./views/dialogs/_TermsDialog.pcss";
|
||||
@import "./views/dialogs/_UnpinAllDialog.pcss";
|
||||
@import "./views/dialogs/_UntrustedDeviceDialog.pcss";
|
||||
@import "./views/dialogs/_UploadConfirmDialog.pcss";
|
||||
@import "./views/dialogs/_UserSettingsDialog.pcss";
|
||||
|
@@ -21,17 +21,29 @@ limitations under the License.
|
||||
* tab sensibly and before I can refactor these components.
|
||||
*/
|
||||
|
||||
.mx_EmailAddressesPhoneNumbers_discovery_existing {
|
||||
.mx_AddRemoveThreepids_existing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.mx_EmailAddressesPhoneNumbers_discovery_existing_address,
|
||||
.mx_EmailAddressesPhoneNumbers_discovery_existing_promptText {
|
||||
.mx_AddRemoveThreepids_existing_address,
|
||||
.mx_AddRemoveThreepids_existing_promptText {
|
||||
flex: 1;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.mx_EmailAddressesPhoneNumbers_discovery_existing_button {
|
||||
.mx_AddRemoveThreepids_existing_button {
|
||||
margin-left: 5px;
|
||||
}
|
||||
|
||||
.mx_EmailAddressesPhoneNumbers_verify {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.mx_EmailAddressesPhoneNumbers_existing_button {
|
||||
justify-content: right;
|
||||
}
|
||||
|
||||
.mx_EmailAddressesPhoneNumbers_verify_instructions {
|
||||
flex: 1;
|
||||
}
|
@@ -119,8 +119,7 @@ limitations under the License.
|
||||
|
||||
h2 {
|
||||
margin: 0;
|
||||
font: var(--cpd-font-heading-sm-medium);
|
||||
font-weight: var(--cpd-font-weight-medium);
|
||||
font: var(--cpd-font-body-lg-semibold);
|
||||
display: inline;
|
||||
width: auto;
|
||||
}
|
||||
|
@@ -111,7 +111,7 @@ limitations under the License.
|
||||
|
||||
.mx_UserMenu_contextMenu_displayName,
|
||||
.mx_UserMenu_contextMenu_userId {
|
||||
font: var(--cpd-font-heading-sm-regular);
|
||||
font: var(--cpd-font-body-lg-regular);
|
||||
|
||||
/* Automatically grow subelements to fit the container */
|
||||
flex: 1;
|
||||
|
@@ -81,11 +81,11 @@ limitations under the License.
|
||||
}
|
||||
|
||||
.mx_MessageContextMenu_iconPin::before {
|
||||
mask-image: url("$(res)/img/element-icons/room/pin-upright.svg");
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/pin.svg");
|
||||
}
|
||||
|
||||
.mx_MessageContextMenu_iconUnpin::before {
|
||||
mask-image: url("$(res)/img/element-icons/room/pin.svg");
|
||||
mask-image: url("@vector-im/compound-design-tokens/icons/unpin.svg");
|
||||
}
|
||||
|
||||
.mx_MessageContextMenu_iconCopy::before {
|
||||
|
38
res/css/views/dialogs/_UnpinAllDialog.pcss
Normal 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%;
|
||||
}
|
||||
}
|
||||
}
|
@@ -60,10 +60,11 @@ limitations under the License.
|
||||
flex: 1;
|
||||
|
||||
.mx_BaseCard_header_title_heading {
|
||||
color: $primary-content;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.mx_BaseCard_header_title_button--option {
|
||||
|
@@ -15,50 +15,40 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_PinnedMessagesCard {
|
||||
.mx_PinnedMessagesCard_empty_wrapper {
|
||||
--unpin-height: 76px;
|
||||
|
||||
.mx_PinnedMessagesCard_wrapper {
|
||||
display: flex;
|
||||
height: 100%;
|
||||
flex-direction: column;
|
||||
padding: var(--cpd-space-4x);
|
||||
gap: var(--cpd-space-6x);
|
||||
overflow-y: auto;
|
||||
|
||||
.mx_PinnedMessagesCard_empty {
|
||||
height: max-content;
|
||||
text-align: center;
|
||||
margin: auto 40px;
|
||||
|
||||
.mx_PinnedMessagesCard_MessageActionBar {
|
||||
pointer-events: none;
|
||||
width: max-content;
|
||||
margin: 0 auto;
|
||||
|
||||
/* Cancel the default values for non-interactivity */
|
||||
position: unset;
|
||||
visibility: visible;
|
||||
cursor: unset;
|
||||
|
||||
&::before {
|
||||
content: unset;
|
||||
}
|
||||
|
||||
.mx_MessageActionBar_optionsButton {
|
||||
background: var(--MessageActionBar-item-hover-background);
|
||||
border-radius: var(--MessageActionBar-item-hover-borderRadius);
|
||||
z-index: var(--MessageActionBar-item-hover-zIndex);
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_PinnedMessagesCard_empty_header {
|
||||
color: $primary-content;
|
||||
margin-block: $spacing-24 $spacing-20;
|
||||
}
|
||||
|
||||
> span {
|
||||
font-size: $font-12px;
|
||||
line-height: $font-15px;
|
||||
color: $secondary-content;
|
||||
}
|
||||
.mx_PinnedMessagesCard_Separator {
|
||||
min-height: 1px;
|
||||
/* Override default compound value */
|
||||
margin-block: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_PinnedMessagesCard_wrapper_unpin_all {
|
||||
/* Remove the unpin all button height and the top and bottom padding */
|
||||
height: calc(100% - var(--unpin-height) - calc(var(--cpd-space-4x) * 2));
|
||||
}
|
||||
|
||||
.mx_PinnedMessagesCard_unpin {
|
||||
/* Make it float at the bottom of the unpin panel */
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
height: var(--unpin-height);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 24px 0 rgba(27, 29, 34, 0.1);
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
|
||||
.mx_EventTile_body {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
@@ -97,7 +97,7 @@ limitations under the License.
|
||||
h2 {
|
||||
text-transform: uppercase;
|
||||
color: $tertiary-content;
|
||||
font: var(--cpd-font-heading-sm-semibold);
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
margin: $spacing-4 0;
|
||||
}
|
||||
|
@@ -15,95 +15,50 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
.mx_PinnedEventTile {
|
||||
min-height: 40px;
|
||||
width: 100%;
|
||||
padding: 0 4px 12px;
|
||||
display: flex;
|
||||
gap: var(--cpd-space-4x);
|
||||
align-items: flex-start;
|
||||
|
||||
display: grid;
|
||||
grid-template-areas:
|
||||
"avatar name remove"
|
||||
"content content content"
|
||||
"footer footer footer";
|
||||
grid-template-rows: max-content auto max-content;
|
||||
grid-template-columns: 24px auto 24px;
|
||||
grid-row-gap: 12px;
|
||||
grid-column-gap: 8px;
|
||||
.mx_PinnedEventTile_wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--cpd-space-1x);
|
||||
width: 100%;
|
||||
|
||||
& + .mx_PinnedEventTile {
|
||||
padding: 12px 4px;
|
||||
border-top: 1px solid $menu-border-color;
|
||||
}
|
||||
.mx_PinnedEventTile_top {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-1x);
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
.mx_PinnedEventTile_senderAvatar,
|
||||
.mx_PinnedEventTile_sender,
|
||||
.mx_PinnedEventTile_unpinButton,
|
||||
.mx_PinnedEventTile_message,
|
||||
.mx_PinnedEventTile_footer {
|
||||
min-width: 0; /* Prevent a grid blowout */
|
||||
}
|
||||
|
||||
.mx_PinnedEventTile_senderAvatar {
|
||||
grid-area: avatar;
|
||||
}
|
||||
|
||||
.mx_PinnedEventTile_sender {
|
||||
grid-area: name;
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
font-size: $font-15px;
|
||||
line-height: $font-24px;
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.mx_PinnedEventTile_unpinButton {
|
||||
visibility: hidden;
|
||||
grid-area: remove;
|
||||
position: relative;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 8px;
|
||||
|
||||
&:hover {
|
||||
background-color: $roomheader-addroom-bg-color;
|
||||
.mx_PinnedEventTile_sender {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
height: inherit;
|
||||
width: inherit;
|
||||
background: $secondary-content;
|
||||
mask-position: center;
|
||||
mask-size: 8px;
|
||||
mask-repeat: no-repeat;
|
||||
mask-image: url("$(res)/img/image-view/close.svg");
|
||||
}
|
||||
}
|
||||
.mx_PinnedEventTile_thread {
|
||||
display: flex;
|
||||
gap: var(--cpd-space-2x);
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
|
||||
.mx_PinnedEventTile_message {
|
||||
grid-area: content;
|
||||
}
|
||||
svg {
|
||||
width: 20px;
|
||||
fill: var(--cpd-color-icon-tertiary);
|
||||
}
|
||||
|
||||
.mx_PinnedEventTile_footer {
|
||||
grid-area: footer;
|
||||
font-size: $font-10px;
|
||||
line-height: 12px;
|
||||
span {
|
||||
display: flex;
|
||||
color: var(--cpd-color-text-secondary);
|
||||
}
|
||||
|
||||
.mx_PinnedEventTile_timestamp {
|
||||
color: $secondary-content;
|
||||
display: unset;
|
||||
width: unset; /* Cancel the default width value */
|
||||
}
|
||||
|
||||
.mx_AccessibleButton_kind_link {
|
||||
margin-left: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.mx_PinnedEventTile_unpinButton {
|
||||
visibility: visible;
|
||||
button {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -44,6 +44,10 @@ limitations under the License.
|
||||
|
||||
transition: all 500ms;
|
||||
|
||||
.mx_UserOnboardingTask_title {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
}
|
||||
|
||||
.mx_UserOnboardingTask_description {
|
||||
font-size: $font-12px;
|
||||
}
|
||||
|
@@ -271,9 +271,7 @@ export default class AddThreepid {
|
||||
* with a "message" property which contains a human-readable message detailing why
|
||||
* the request failed.
|
||||
*/
|
||||
public async haveMsisdnToken(
|
||||
msisdnToken: string,
|
||||
): Promise<[success?: boolean, result?: IAuthData | Error | null] | undefined> {
|
||||
public async haveMsisdnToken(msisdnToken: string): Promise<[success?: boolean, result?: IAuthData | Error | null]> {
|
||||
const authClient = new IdentityAuthClient();
|
||||
|
||||
if (this.submitUrl) {
|
||||
@@ -301,13 +299,14 @@ export default class AddThreepid {
|
||||
id_server: getIdServerDomain(this.matrixClient),
|
||||
id_access_token: await authClient.getAccessToken(),
|
||||
});
|
||||
return [true];
|
||||
} else {
|
||||
try {
|
||||
await this.makeAddThreepidOnlyRequest();
|
||||
|
||||
// The spec has always required this to use UI auth but synapse briefly
|
||||
// implemented it without, so this may just succeed and that's OK.
|
||||
return;
|
||||
return [true];
|
||||
} catch (err) {
|
||||
if (!(err instanceof MatrixError) || err.httpStatus !== 401 || !err.data || !err.data.flows) {
|
||||
// doesn't look like an interactive-auth failure
|
||||
|
@@ -18,6 +18,7 @@ import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import { EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/SendMessageComposer";
|
||||
import { WYSIWYG_EDITOR_STATE_STORAGE_PREFIX } from "./components/views/rooms/MessageComposer";
|
||||
|
||||
// The key used to persist the the timestamp we last cleaned up drafts
|
||||
export const DRAFT_LAST_CLEANUP_KEY = "mx_draft_cleanup";
|
||||
@@ -61,14 +62,21 @@ function shouldCleanupDrafts(): boolean {
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all drafts for the CIDER editor if the room does not exist in the known rooms.
|
||||
* Clear all drafts for the CIDER and WYSIWYG editors if the room does not exist in the known rooms.
|
||||
*/
|
||||
function cleaupDrafts(): void {
|
||||
for (let i = 0; i < localStorage.length; i++) {
|
||||
const keyName = localStorage.key(i);
|
||||
if (!keyName?.startsWith(EDITOR_STATE_STORAGE_PREFIX)) continue;
|
||||
if (!keyName) continue;
|
||||
let roomId: string | undefined = undefined;
|
||||
if (keyName.startsWith(EDITOR_STATE_STORAGE_PREFIX)) {
|
||||
roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
|
||||
}
|
||||
if (keyName.startsWith(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX)) {
|
||||
roomId = keyName.slice(WYSIWYG_EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
|
||||
}
|
||||
if (!roomId) continue;
|
||||
// Remove the prefix and the optional event id suffix to leave the room id
|
||||
const roomId = keyName.slice(EDITOR_STATE_STORAGE_PREFIX.length).split("_$")[0];
|
||||
const room = MatrixClientPeg.safeGet().getRoom(roomId);
|
||||
if (!room) {
|
||||
logger.debug(`Removing draft for unknown room with key ${keyName}`);
|
||||
|
@@ -41,7 +41,6 @@ import MatrixClientBackedSettingsHandler from "./settings/handlers/MatrixClientB
|
||||
import * as StorageManager from "./utils/StorageManager";
|
||||
import IdentityAuthClient from "./IdentityAuthClient";
|
||||
import { crossSigningCallbacks } from "./SecurityManager";
|
||||
import { ModuleRunner } from "./modules/ModuleRunner";
|
||||
import { SlidingSyncManager } from "./SlidingSyncManager";
|
||||
import { _t, UserFriendlyError } from "./languageHandler";
|
||||
import { SettingLevel } from "./settings/SettingLevel";
|
||||
@@ -452,11 +451,6 @@ class MatrixClientPegClass implements IMatrixClientPeg {
|
||||
},
|
||||
};
|
||||
|
||||
const dehydrationKeyCallback = ModuleRunner.instance.extensions.cryptoSetup.getDehydrationKeyCallback();
|
||||
if (dehydrationKeyCallback) {
|
||||
opts.cryptoCallbacks!.getDehydrationKey = dehydrationKeyCallback;
|
||||
}
|
||||
|
||||
this.matrixClient = createMatrixClient(opts);
|
||||
this.matrixClient.setGuest(Boolean(creds.guest));
|
||||
|
||||
|
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { Crypto, ICryptoCallbacks, encodeBase64, SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||
import { ICryptoCallbacks, SecretStorage } from "matrix-js-sdk/src/matrix";
|
||||
import { deriveKey } from "matrix-js-sdk/src/crypto/key_passphrase";
|
||||
import { decodeRecoveryKey } from "matrix-js-sdk/src/crypto/recoverykey";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
@@ -39,11 +39,6 @@ let secretStorageKeys: Record<string, Uint8Array> = {};
|
||||
let secretStorageKeyInfo: Record<string, SecretStorage.SecretStorageKeyDescription> = {};
|
||||
let secretStorageBeingAccessed = false;
|
||||
|
||||
let dehydrationCache: {
|
||||
key?: Uint8Array;
|
||||
keyInfo?: SecretStorage.SecretStorageKeyDescription;
|
||||
} = {};
|
||||
|
||||
/**
|
||||
* This can be used by other components to check if secret storage access is in
|
||||
* progress, so that we can e.g. avoid intermittently showing toasts during
|
||||
@@ -119,14 +114,6 @@ async function getSecretStorageKey({
|
||||
return [keyId, secretStorageKeys[keyId]];
|
||||
}
|
||||
|
||||
if (dehydrationCache.key) {
|
||||
if (await MatrixClientPeg.safeGet().checkSecretStorageKey(dehydrationCache.key, keyInfo)) {
|
||||
logger.debug("getSecretStorageKey: returning key from dehydration cache");
|
||||
cacheSecretStorageKey(keyId, keyInfo, dehydrationCache.key);
|
||||
return [keyId, dehydrationCache.key];
|
||||
}
|
||||
}
|
||||
|
||||
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
|
||||
if (keyFromCustomisations) {
|
||||
logger.log("getSecretStorageKey: Using secret storage key from CryptoSetupExtension");
|
||||
@@ -171,56 +158,6 @@ async function getSecretStorageKey({
|
||||
return [keyId, key];
|
||||
}
|
||||
|
||||
export async function getDehydrationKey(
|
||||
keyInfo: SecretStorage.SecretStorageKeyDescription,
|
||||
checkFunc: (data: Uint8Array) => void,
|
||||
): Promise<Uint8Array> {
|
||||
const keyFromCustomisations = ModuleRunner.instance.extensions.cryptoSetup.getSecretStorageKey();
|
||||
if (keyFromCustomisations) {
|
||||
logger.log("CryptoSetupExtension: Using key from extension (dehydration)");
|
||||
return keyFromCustomisations;
|
||||
}
|
||||
|
||||
const inputToKey = makeInputToKey(keyInfo);
|
||||
const { finished } = Modal.createDialog(
|
||||
AccessSecretStorageDialog,
|
||||
/* props= */
|
||||
{
|
||||
keyInfo,
|
||||
checkPrivateKey: async (input: KeyParams): Promise<boolean> => {
|
||||
const key = await inputToKey(input);
|
||||
try {
|
||||
checkFunc(key);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
},
|
||||
/* className= */ undefined,
|
||||
/* isPriorityModal= */ false,
|
||||
/* isStaticModal= */ false,
|
||||
/* options= */ {
|
||||
onBeforeClose: async (reason): Promise<boolean> => {
|
||||
if (reason === "backgroundClick") {
|
||||
return confirmToDismiss();
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
);
|
||||
const [input] = await finished;
|
||||
if (!input) {
|
||||
throw new AccessCancelledError();
|
||||
}
|
||||
const key = await inputToKey(input);
|
||||
|
||||
// need to copy the key because rehydration (unpickling) will clobber it
|
||||
dehydrationCache = { key: new Uint8Array(key), keyInfo };
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
function cacheSecretStorageKey(
|
||||
keyId: string,
|
||||
keyInfo: SecretStorage.SecretStorageKeyDescription,
|
||||
@@ -232,50 +169,9 @@ function cacheSecretStorageKey(
|
||||
}
|
||||
}
|
||||
|
||||
async function onSecretRequested(
|
||||
userId: string,
|
||||
deviceId: string,
|
||||
requestId: string,
|
||||
name: string,
|
||||
deviceTrust: Crypto.DeviceVerificationStatus,
|
||||
): Promise<string | undefined> {
|
||||
logger.log("onSecretRequested", userId, deviceId, requestId, name, deviceTrust);
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
if (userId !== client.getUserId()) {
|
||||
return;
|
||||
}
|
||||
if (!deviceTrust?.isVerified()) {
|
||||
logger.log(`Ignoring secret request from untrusted device ${deviceId}`);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
name === "m.cross_signing.master" ||
|
||||
name === "m.cross_signing.self_signing" ||
|
||||
name === "m.cross_signing.user_signing"
|
||||
) {
|
||||
const callbacks = client.getCrossSigningCacheCallbacks();
|
||||
if (!callbacks?.getCrossSigningKeyCache) return;
|
||||
const keyId = name.replace("m.cross_signing.", "");
|
||||
const key = await callbacks.getCrossSigningKeyCache(keyId);
|
||||
if (!key) {
|
||||
logger.log(`${keyId} requested by ${deviceId}, but not found in cache`);
|
||||
}
|
||||
return key ? encodeBase64(key) : undefined;
|
||||
} else if (name === "m.megolm_backup.v1") {
|
||||
const key = await client.crypto?.getSessionBackupPrivateKey();
|
||||
if (!key) {
|
||||
logger.log(`session backup key requested by ${deviceId}, but not found in cache`);
|
||||
}
|
||||
return key ? encodeBase64(key) : undefined;
|
||||
}
|
||||
logger.warn("onSecretRequested didn't recognise the secret named ", name);
|
||||
}
|
||||
|
||||
export const crossSigningCallbacks: ICryptoCallbacks = {
|
||||
getSecretStorageKey,
|
||||
cacheSecretStorageKey,
|
||||
onSecretRequested,
|
||||
getDehydrationKey,
|
||||
};
|
||||
|
||||
/**
|
||||
|
@@ -34,7 +34,7 @@ import ThreadView from "./ThreadView";
|
||||
import ThreadPanel from "./ThreadPanel";
|
||||
import NotificationPanel from "./NotificationPanel";
|
||||
import ResizeNotifier from "../../utils/ResizeNotifier";
|
||||
import PinnedMessagesCard from "../views/right_panel/PinnedMessagesCard";
|
||||
import { PinnedMessagesCard } from "../views/right_panel/PinnedMessagesCard";
|
||||
import { RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import { E2EStatus } from "../../utils/ShieldUtils";
|
||||
import TimelineCard from "../views/right_panel/TimelineCard";
|
||||
|
@@ -161,7 +161,7 @@ interface IRoomProps {
|
||||
|
||||
// This defines the content of the mainSplit.
|
||||
// If the mainSplit does not contain the Timeline, the chat is shown in the right panel.
|
||||
enum MainSplitContentType {
|
||||
export enum MainSplitContentType {
|
||||
Timeline,
|
||||
MaximisedWidget,
|
||||
Call,
|
||||
|
@@ -485,6 +485,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||
fragmentAfterLogin={this.props.fragmentAfterLogin}
|
||||
primary={!this.state.flows?.find((flow) => flow.type === "m.login.password")}
|
||||
action={SSOAction.LOGIN}
|
||||
disabled={this.isBusy()}
|
||||
/>
|
||||
);
|
||||
};
|
||||
@@ -558,6 +559,7 @@ export default class LoginComponent extends React.PureComponent<IProps, IState>
|
||||
<ServerPicker
|
||||
serverConfig={this.props.serverConfig}
|
||||
onServerConfigChange={this.props.onServerConfigChange}
|
||||
disabled={this.isBusy()}
|
||||
/>
|
||||
{this.renderLoginComponentForFlows()}
|
||||
{footer}
|
||||
|
@@ -26,6 +26,7 @@ import DateSeparator from "../../views/messages/DateSeparator";
|
||||
import HistoryTile from "../../views/rooms/HistoryTile";
|
||||
import EventListSummary from "../../views/elements/EventListSummary";
|
||||
import { SeparatorKind } from "../../views/messages/TimelineSeparator";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
|
||||
const groupedStateEvents = [
|
||||
EventType.RoomMember,
|
||||
@@ -97,6 +98,12 @@ export class MainGrouper extends BaseGrouper {
|
||||
// absorb hidden events to not split the summary
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.getType() === EventType.RoomPinnedEvents && !SettingsStore.getValue("feature_pinning")) {
|
||||
// If pinned messages are disabled, don't show the summary
|
||||
return;
|
||||
}
|
||||
|
||||
this.events.push(wrappedEvent);
|
||||
}
|
||||
|
||||
|
@@ -833,7 +833,7 @@ export class SSOAuthEntry extends React.Component<ISSOAuthEntryProps, ISSOAuthEn
|
||||
};
|
||||
|
||||
private onReceiveMessage = (event: MessageEvent): void => {
|
||||
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
|
||||
if (event.data === "authDone" && event.source === this.popupWindow) {
|
||||
if (this.popupWindow) {
|
||||
this.popupWindow.close();
|
||||
this.popupWindow = null;
|
||||
@@ -950,7 +950,7 @@ export class FallbackAuthEntry extends React.Component<IAuthEntryProps> {
|
||||
};
|
||||
|
||||
private onReceiveMessage = (event: MessageEvent): void => {
|
||||
if (event.data === "authDone" && event.origin === this.props.matrixClient.getHomeserverUrl()) {
|
||||
if (event.data === "authDone" && event.source === this.popupWindow) {
|
||||
this.props.submitAuthDict({});
|
||||
}
|
||||
};
|
||||
|
@@ -36,9 +36,8 @@ import Modal from "../../../Modal";
|
||||
import Resend from "../../../Resend";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { isUrlPermitted } from "../../../HtmlUtils";
|
||||
import { canEditContent, canPinEvent, editEvent, isContentActionable } from "../../../utils/EventUtils";
|
||||
import { canEditContent, editEvent, isContentActionable } from "../../../utils/EventUtils";
|
||||
import IconizedContextMenu, { IconizedContextMenuOption, IconizedContextMenuOptionList } from "./IconizedContextMenu";
|
||||
import { ReadPinsEventId } from "../right_panel/types";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
@@ -60,6 +59,7 @@ import { getForwardableEvent } from "../../../events/forward/getForwardableEvent
|
||||
import { getShareableLocationEvent } from "../../../events/location/getShareableLocationEvent";
|
||||
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||
import { CardContext } from "../right_panel/context";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
|
||||
interface IReplyInThreadButton {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -177,24 +177,11 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
this.props.mxEvent.getType() !== EventType.RoomServerAcl &&
|
||||
this.props.mxEvent.getType() !== EventType.RoomEncryption;
|
||||
|
||||
let canPin =
|
||||
!!room?.currentState.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli) &&
|
||||
canPinEvent(this.props.mxEvent);
|
||||
|
||||
// HACK: Intentionally say we can't pin if the user doesn't want to use the functionality
|
||||
if (!SettingsStore.getValue("feature_pinning")) canPin = false;
|
||||
const canPin = PinningUtils.canPinOrUnpin(cli, this.props.mxEvent);
|
||||
|
||||
this.setState({ canRedact, canPin });
|
||||
};
|
||||
|
||||
private isPinned(): boolean {
|
||||
const room = MatrixClientPeg.safeGet().getRoom(this.props.mxEvent.getRoomId());
|
||||
const pinnedEvent = room?.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||
if (!pinnedEvent) return false;
|
||||
const content = pinnedEvent.getContent();
|
||||
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(this.props.mxEvent.getId());
|
||||
}
|
||||
|
||||
private canEndPoll(mxEvent: MatrixEvent): boolean {
|
||||
return (
|
||||
M_POLL_START.matches(mxEvent.getType()) &&
|
||||
@@ -257,22 +244,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
};
|
||||
|
||||
private onPinClick = (): void => {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const room = cli.getRoom(this.props.mxEvent.getRoomId());
|
||||
if (!room) return;
|
||||
const eventId = this.props.mxEvent.getId();
|
||||
|
||||
const pinnedIds = room.currentState?.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent().pinned || [];
|
||||
|
||||
if (pinnedIds.includes(eventId)) {
|
||||
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
|
||||
} else {
|
||||
pinnedIds.push(eventId);
|
||||
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
||||
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
|
||||
});
|
||||
}
|
||||
cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
|
||||
// Pin or unpin in background
|
||||
PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
this.closeMenu();
|
||||
};
|
||||
|
||||
@@ -452,17 +425,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
let pinButton: JSX.Element | undefined;
|
||||
if (contentActionable && this.state.canPin) {
|
||||
pinButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName="mx_MessageContextMenu_iconPin"
|
||||
label={this.isPinned() ? _t("action|unpin") : _t("action|pin")}
|
||||
onClick={this.onPinClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// This is specifically not behind the developerMode flag to give people insight into the Matrix
|
||||
const viewSourceButton = (
|
||||
<IconizedContextMenuOption
|
||||
@@ -649,6 +611,18 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
);
|
||||
}
|
||||
|
||||
let pinButton: JSX.Element | undefined;
|
||||
if (rightClick && this.state.canPin) {
|
||||
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
pinButton = (
|
||||
<IconizedContextMenuOption
|
||||
iconClassName={isPinned ? "mx_MessageContextMenu_iconUnpin" : "mx_MessageContextMenu_iconPin"}
|
||||
label={isPinned ? _t("action|unpin") : _t("action|pin")}
|
||||
onClick={this.onPinClick}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
let viewInRoomButton: JSX.Element | undefined;
|
||||
if (isThreadRootEvent) {
|
||||
viewInRoomButton = (
|
||||
@@ -671,13 +645,14 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
}
|
||||
|
||||
let quickItemsList: JSX.Element | undefined;
|
||||
if (editButton || replyButton || reactButton) {
|
||||
if (editButton || replyButton || reactButton || pinButton) {
|
||||
quickItemsList = (
|
||||
<IconizedContextMenuOptionList>
|
||||
{reactButton}
|
||||
{replyButton}
|
||||
{replyInThreadButton}
|
||||
{editButton}
|
||||
{pinButton}
|
||||
</IconizedContextMenuOptionList>
|
||||
);
|
||||
}
|
||||
@@ -688,7 +663,6 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
|
||||
{openInMapSiteButton}
|
||||
{endPollButton}
|
||||
{forwardButton}
|
||||
{pinButton}
|
||||
{permalinkButton}
|
||||
{reportEventButton}
|
||||
{externalURLButton}
|
||||
|
77
src/components/views/dialogs/UnpinAllDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
@@ -151,11 +151,20 @@ interface IProps {
|
||||
fragmentAfterLogin?: string;
|
||||
primary?: boolean;
|
||||
action?: SSOAction;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const MAX_PER_ROW = 6;
|
||||
|
||||
const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => {
|
||||
const SSOButtons: React.FC<IProps> = ({
|
||||
matrixClient,
|
||||
flow,
|
||||
loginType,
|
||||
fragmentAfterLogin,
|
||||
primary,
|
||||
action,
|
||||
disabled,
|
||||
}) => {
|
||||
const providers = flow.identity_providers || [];
|
||||
if (providers.length < 2) {
|
||||
return (
|
||||
@@ -168,6 +177,7 @@ const SSOButtons: React.FC<IProps> = ({ matrixClient, flow, loginType, fragmentA
|
||||
primary={primary}
|
||||
action={action}
|
||||
flow={flow}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
@@ -29,6 +29,7 @@ interface IProps {
|
||||
title?: string;
|
||||
dialogTitle?: string;
|
||||
serverConfig: ValidatedServerConfig;
|
||||
disabled?: boolean;
|
||||
onServerConfigChange?(config: ValidatedServerConfig): void;
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ const onHelpClick = (): void => {
|
||||
);
|
||||
};
|
||||
|
||||
const ServerPicker: React.FC<IProps> = ({ title, dialogTitle, serverConfig, onServerConfigChange }) => {
|
||||
const ServerPicker: React.FC<IProps> = ({ title, dialogTitle, serverConfig, onServerConfigChange, disabled }) => {
|
||||
const disableCustomUrls = SdkConfig.get("disable_custom_urls");
|
||||
|
||||
let editBtn;
|
||||
@@ -68,7 +69,7 @@ const ServerPicker: React.FC<IProps> = ({ title, dialogTitle, serverConfig, onSe
|
||||
});
|
||||
};
|
||||
editBtn = (
|
||||
<AccessibleButton className="mx_ServerPicker_change" kind="link" onClick={onClick}>
|
||||
<AccessibleButton className="mx_ServerPicker_change" kind="link" onClick={onClick} disabled={disabled}>
|
||||
{_t("action|edit")}
|
||||
</AccessibleButton>
|
||||
);
|
||||
|
@@ -26,6 +26,8 @@ import {
|
||||
M_BEACON_INFO,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import classNames from "classnames";
|
||||
import { Icon as PinIcon } from "@vector-im/compound-design-tokens/icons/pin.svg";
|
||||
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";
|
||||
|
||||
import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
|
||||
import { Icon as EditIcon } from "../../../../res/img/element-icons/room/message-bar/edit.svg";
|
||||
@@ -61,6 +63,7 @@ import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayloa
|
||||
import { GetRelationsForEvent, IEventTileType } from "../rooms/EventTile";
|
||||
import { VoiceBroadcastInfoEventType } from "../../../voice-broadcast/types";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
|
||||
interface IOptionsButtonProps {
|
||||
mxEvent: MatrixEvent;
|
||||
@@ -384,6 +387,17 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Pin or unpin the event.
|
||||
*/
|
||||
private onPinClick = async (event: ButtonEvent): Promise<void> => {
|
||||
// Don't open the regular browser or our context menu on right-click
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
await PinningUtils.pinOrUnpinEvent(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const toolbarOpts: JSX.Element[] = [];
|
||||
if (canEditContent(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
|
||||
@@ -401,6 +415,22 @@ export default class MessageActionBar extends React.PureComponent<IMessageAction
|
||||
);
|
||||
}
|
||||
|
||||
if (PinningUtils.canPinOrUnpin(MatrixClientPeg.safeGet(), this.props.mxEvent)) {
|
||||
const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent);
|
||||
toolbarOpts.push(
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
title={isPinned ? _t("action|unpin") : _t("action|pin")}
|
||||
onClick={this.onPinClick}
|
||||
onContextMenu={this.onPinClick}
|
||||
key="pin"
|
||||
placement="left"
|
||||
>
|
||||
{isPinned ? <UnpinIcon /> : <PinIcon />}
|
||||
</RovingAccessibleButton>,
|
||||
);
|
||||
}
|
||||
|
||||
const cancelSendingButton = (
|
||||
<RovingAccessibleButton
|
||||
className="mx_MessageActionBar_iconButton"
|
||||
|
@@ -14,41 +14,62 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useContext, useEffect, useState } from "react";
|
||||
import { Room, RoomEvent, RoomStateEvent, MatrixEvent, EventType, RelationType } from "matrix-js-sdk/src/matrix";
|
||||
import React, { useCallback, useEffect, useState, JSX } from "react";
|
||||
import {
|
||||
Room,
|
||||
RoomEvent,
|
||||
RoomStateEvent,
|
||||
MatrixEvent,
|
||||
EventType,
|
||||
RelationType,
|
||||
EventTimeline,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Button, Separator } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
import PinIcon from "@vector-im/compound-design-tokens/assets/web/icons/pin";
|
||||
|
||||
import { Icon as ContextMenuIcon } from "../../../../res/img/element-icons/context-menu.svg";
|
||||
import { Icon as EmojiIcon } from "../../../../res/img/element-icons/room/message-bar/emoji.svg";
|
||||
import { Icon as ReplyIcon } from "../../../../res/img/element-icons/room/message-bar/reply.svg";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import BaseCard from "./BaseCard";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import PinningUtils from "../../../utils/PinningUtils";
|
||||
import { useAsyncMemo } from "../../../hooks/useAsyncMemo";
|
||||
import PinnedEventTile from "../rooms/PinnedEventTile";
|
||||
import { PinnedEventTile } from "../rooms/PinnedEventTile";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import RoomContext, { TimelineRenderingType, useRoomContext } from "../../../contexts/RoomContext";
|
||||
import { ReadPinsEventId } from "./types";
|
||||
import Heading from "../typography/Heading";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { filterBoolean } from "../../../utils/arrays";
|
||||
import Modal from "../../../Modal";
|
||||
import { UnpinAllDialog } from "../dialogs/UnpinAllDialog";
|
||||
import EmptyState from "./EmptyState";
|
||||
|
||||
interface IProps {
|
||||
room: Room;
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pinned event IDs from a room.
|
||||
* @param room
|
||||
*/
|
||||
function getPinnedEventIds(room?: Room): string[] {
|
||||
return room?.currentState.getStateEvents(EventType.RoomPinnedEvents, "")?.getContent()?.pinned ?? [];
|
||||
return (
|
||||
room
|
||||
?.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(EventType.RoomPinnedEvents, "")
|
||||
?.getContent()?.pinned ?? []
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the pinned event IDs from a room.
|
||||
* @param room
|
||||
*/
|
||||
export const usePinnedEvents = (room?: Room): string[] => {
|
||||
const [pinnedEvents, setPinnedEvents] = useState<string[]>(getPinnedEventIds(room));
|
||||
|
||||
// Update the pinned events when the room state changes
|
||||
// Filter out events that are not pinned events
|
||||
const update = useCallback(
|
||||
(ev?: MatrixEvent) => {
|
||||
if (ev && ev.getType() !== EventType.RoomPinnedEvents) return;
|
||||
@@ -57,7 +78,7 @@ export const usePinnedEvents = (room?: Room): string[] => {
|
||||
[room],
|
||||
);
|
||||
|
||||
useTypedEventEmitter(room?.currentState, RoomStateEvent.Events, update);
|
||||
useTypedEventEmitter(room?.getLiveTimeline().getState(EventTimeline.FORWARDS), RoomStateEvent.Events, update);
|
||||
useEffect(() => {
|
||||
setPinnedEvents(getPinnedEventIds(room));
|
||||
return () => {
|
||||
@@ -67,13 +88,23 @@ export const usePinnedEvents = (room?: Room): string[] => {
|
||||
return pinnedEvents;
|
||||
};
|
||||
|
||||
/**
|
||||
* Get the read pinned event IDs from a room.
|
||||
* @param room
|
||||
*/
|
||||
function getReadPinnedEventIds(room?: Room): Set<string> {
|
||||
return new Set(room?.getAccountData(ReadPinsEventId)?.getContent()?.event_ids ?? []);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the read pinned event IDs from a room.
|
||||
* @param room
|
||||
*/
|
||||
export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
||||
const [readPinnedEvents, setReadPinnedEvents] = useState<Set<string>>(new Set());
|
||||
|
||||
// Update the read pinned events when the room state changes
|
||||
// Filter out events that are not read pinned events
|
||||
const update = useCallback(
|
||||
(ev?: MatrixEvent) => {
|
||||
if (ev && ev.getType() !== ReadPinsEventId) return;
|
||||
@@ -92,36 +123,36 @@ export const useReadPinnedEvents = (room?: Room): Set<string> => {
|
||||
return readPinnedEvents;
|
||||
};
|
||||
|
||||
const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator }) => {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const roomContext = useContext(RoomContext);
|
||||
const canUnpin = useRoomState(room, (state) => state.mayClientSendStateEvent(EventType.RoomPinnedEvents, cli));
|
||||
const pinnedEventIds = usePinnedEvents(room);
|
||||
const readPinnedEvents = useReadPinnedEvents(room);
|
||||
/**
|
||||
* Fetch the pinned events
|
||||
* @param room
|
||||
* @param pinnedEventIds
|
||||
*/
|
||||
function useFetchedPinnedEvents(room: Room, pinnedEventIds: string[]): Array<MatrixEvent | null> | null {
|
||||
const cli = useMatrixClientContext();
|
||||
|
||||
useEffect(() => {
|
||||
if (!cli || cli.isGuest()) return; // nothing to do
|
||||
const newlyRead = pinnedEventIds.filter((id) => !readPinnedEvents.has(id));
|
||||
if (newlyRead.length > 0) {
|
||||
// clear out any read pinned events which no longer are pinned
|
||||
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
||||
event_ids: pinnedEventIds,
|
||||
});
|
||||
}
|
||||
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
|
||||
|
||||
const pinnedEvents = useAsyncMemo(
|
||||
return useAsyncMemo(
|
||||
() => {
|
||||
const promises = pinnedEventIds.map(async (eventId): Promise<MatrixEvent | null> => {
|
||||
const timelineSet = room.getUnfilteredTimelineSet();
|
||||
// Get the event from the local timeline
|
||||
const localEvent = timelineSet
|
||||
?.getTimelineForEvent(eventId)
|
||||
?.getEvents()
|
||||
.find((e) => e.getId() === eventId);
|
||||
|
||||
// Decrypt the event if it's encrypted
|
||||
// Can happen when the tab is refreshed and the pinned events card is opened directly
|
||||
if (localEvent?.isEncrypted()) {
|
||||
await cli.decryptEventIfNeeded(localEvent);
|
||||
}
|
||||
|
||||
// If the event is available locally, return it if it's pinnable
|
||||
// Otherwise, return null
|
||||
if (localEvent) return PinningUtils.isPinnable(localEvent) ? localEvent : null;
|
||||
|
||||
try {
|
||||
// Fetch the event and latest edit in parallel
|
||||
// The event is not available locally, so we fetch the event and latest edit in parallel
|
||||
const [
|
||||
evJson,
|
||||
{
|
||||
@@ -131,10 +162,15 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||
cli.fetchRoomEvent(room.roomId, eventId),
|
||||
cli.relations(room.roomId, eventId, RelationType.Replace, null, { limit: 1 }),
|
||||
]);
|
||||
|
||||
const event = new MatrixEvent(evJson);
|
||||
|
||||
// Decrypt the event if it's encrypted
|
||||
if (event.isEncrypted()) {
|
||||
await cli.decryptEventIfNeeded(event); // TODO await?
|
||||
await cli.decryptEventIfNeeded(event);
|
||||
}
|
||||
|
||||
// Handle poll events
|
||||
await room.processPollEvents([event]);
|
||||
|
||||
const senderUserId = event.getSender();
|
||||
@@ -158,62 +194,59 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||
[cli, room, pinnedEventIds],
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
let content: JSX.Element[] | JSX.Element | undefined;
|
||||
/**
|
||||
* List the pinned messages in a room inside a Card.
|
||||
*/
|
||||
interface PinnedMessagesCardProps {
|
||||
/**
|
||||
* The room to list the pinned messages for.
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* Permalink of the room.
|
||||
*/
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
/**
|
||||
* Callback for when the card is closed.
|
||||
*/
|
||||
onClose(): void;
|
||||
}
|
||||
|
||||
export function PinnedMessagesCard({ room, onClose, permalinkCreator }: PinnedMessagesCardProps): JSX.Element {
|
||||
const cli = useMatrixClientContext();
|
||||
const roomContext = useRoomContext();
|
||||
const pinnedEventIds = usePinnedEvents(room);
|
||||
const readPinnedEvents = useReadPinnedEvents(room);
|
||||
const pinnedEvents = useFetchedPinnedEvents(room, pinnedEventIds);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cli || cli.isGuest()) return; // nothing to do
|
||||
const newlyRead = pinnedEventIds.filter((id) => !readPinnedEvents.has(id));
|
||||
if (newlyRead.length > 0) {
|
||||
// clear out any read pinned events which no longer are pinned
|
||||
cli.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
||||
event_ids: pinnedEventIds,
|
||||
});
|
||||
}
|
||||
}, [cli, room.roomId, pinnedEventIds, readPinnedEvents]);
|
||||
|
||||
let content: JSX.Element;
|
||||
if (!pinnedEventIds.length) {
|
||||
content = (
|
||||
<div className="mx_PinnedMessagesCard_empty_wrapper">
|
||||
<div className="mx_PinnedMessagesCard_empty">
|
||||
{/* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */}
|
||||
<div className="mx_MessageActionBar mx_PinnedMessagesCard_MessageActionBar">
|
||||
<div className="mx_MessageActionBar_iconButton">
|
||||
<EmojiIcon />
|
||||
</div>
|
||||
<div className="mx_MessageActionBar_iconButton">
|
||||
<ReplyIcon />
|
||||
</div>
|
||||
<div className="mx_MessageActionBar_iconButton mx_MessageActionBar_optionsButton">
|
||||
<ContextMenuIcon />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Heading size="4" className="mx_PinnedMessagesCard_empty_header">
|
||||
{_t("right_panel|pinned_messages|empty")}
|
||||
</Heading>
|
||||
{_t(
|
||||
"right_panel|pinned_messages|explainer",
|
||||
{},
|
||||
{
|
||||
b: (sub) => <b>{sub}</b>,
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<EmptyState
|
||||
Icon={PinIcon}
|
||||
title={_t("right_panel|pinned_messages|empty_title")}
|
||||
description={_t("right_panel|pinned_messages|empty_description", {
|
||||
pinAction: _t("action|pin"),
|
||||
})}
|
||||
/>
|
||||
);
|
||||
} else if (pinnedEvents?.length) {
|
||||
const onUnpinClicked = async (event: MatrixEvent): Promise<void> => {
|
||||
const pinnedEvents = room.currentState.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||
if (pinnedEvents?.getContent()?.pinned) {
|
||||
const pinned = pinnedEvents.getContent().pinned;
|
||||
const index = pinned.indexOf(event.getId());
|
||||
if (index !== -1) {
|
||||
pinned.splice(index, 1);
|
||||
await cli.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// show them in reverse, with latest pinned at the top
|
||||
content = filterBoolean(pinnedEvents)
|
||||
.reverse()
|
||||
.map((ev) => (
|
||||
<PinnedEventTile
|
||||
key={ev.getId()}
|
||||
event={ev}
|
||||
onUnpinClicked={canUnpin ? () => onUnpinClicked(ev) : undefined}
|
||||
permalinkCreator={permalinkCreator}
|
||||
/>
|
||||
));
|
||||
content = (
|
||||
<PinnedMessages events={filterBoolean(pinnedEvents)} room={room} permalinkCreator={permalinkCreator} />
|
||||
);
|
||||
} else {
|
||||
content = <Spinner />;
|
||||
}
|
||||
@@ -223,7 +256,7 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||
header={
|
||||
<div className="mx_BaseCard_header_title">
|
||||
<Heading size="4" className="mx_BaseCard_header_title_heading">
|
||||
{_t("right_panel|pinned_messages|title")}
|
||||
{_t("right_panel|pinned_messages|header", { count: pinnedEventIds.length })}
|
||||
</Heading>
|
||||
</div>
|
||||
}
|
||||
@@ -240,6 +273,79 @@ const PinnedMessagesCard: React.FC<IProps> = ({ room, onClose, permalinkCreator
|
||||
</RoomContext.Provider>
|
||||
</BaseCard>
|
||||
);
|
||||
};
|
||||
}
|
||||
|
||||
export default PinnedMessagesCard;
|
||||
/**
|
||||
* The pinned messages in a room.
|
||||
*/
|
||||
interface PinnedMessagesProps {
|
||||
/**
|
||||
* The pinned events.
|
||||
*/
|
||||
events: MatrixEvent[];
|
||||
/**
|
||||
* The room the events are in.
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* The permalink creator to use.
|
||||
*/
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
}
|
||||
|
||||
/**
|
||||
* The pinned messages in a room.
|
||||
*/
|
||||
function PinnedMessages({ events, room, permalinkCreator }: PinnedMessagesProps): JSX.Element {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
/**
|
||||
* Whether the client can unpin events from the room.
|
||||
*/
|
||||
const canUnpin = useRoomState(room, (state) =>
|
||||
state.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
|
||||
);
|
||||
|
||||
/**
|
||||
* Opens the unpin all dialog.
|
||||
*/
|
||||
const onUnpinAll = useCallback(async (): Promise<void> => {
|
||||
Modal.createDialog(UnpinAllDialog, {
|
||||
roomId: room.roomId,
|
||||
matrixClient,
|
||||
});
|
||||
}, [room, matrixClient]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={classNames("mx_PinnedMessagesCard_wrapper", {
|
||||
mx_PinnedMessagesCard_wrapper_unpin_all: canUnpin,
|
||||
})}
|
||||
role="list"
|
||||
>
|
||||
{events.reverse().map((event, i) => (
|
||||
<>
|
||||
<PinnedEventTile
|
||||
key={event.getId()}
|
||||
event={event}
|
||||
permalinkCreator={permalinkCreator}
|
||||
room={room}
|
||||
/>
|
||||
{/* Add a separator if this isn't the last pinned message */}
|
||||
{events.length - 1 !== i && (
|
||||
<Separator key={`separator-${event.getId()}`} className="mx_PinnedMessagesCard_Separator" />
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</div>
|
||||
{canUnpin && (
|
||||
<div className="mx_PinnedMessagesCard_unpin">
|
||||
<Button kind="tertiary" onClick={onUnpinAll}>
|
||||
{_t("right_panel|pinned_messages|unpin_all|button")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
@@ -26,6 +26,7 @@ import {
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { Optional } from "matrix-events-sdk";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
@@ -65,6 +66,9 @@ import { createCantStartVoiceMessageBroadcastDialog } from "../dialogs/CantStart
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { formatTimeLeft } from "../../../DateUtils";
|
||||
|
||||
// The prefix used when persisting editor drafts to localstorage.
|
||||
export const WYSIWYG_EDITOR_STATE_STORAGE_PREFIX = "mx_wysiwyg_state_";
|
||||
|
||||
let instanceCount = 0;
|
||||
|
||||
interface ISendButtonProps {
|
||||
@@ -109,6 +113,12 @@ interface IState {
|
||||
initialComposerContent: string;
|
||||
}
|
||||
|
||||
type WysiwygComposerState = {
|
||||
content: string;
|
||||
isRichText: boolean;
|
||||
replyEventId?: string;
|
||||
};
|
||||
|
||||
export class MessageComposer extends React.Component<IProps, IState> {
|
||||
private dispatcherRef?: string;
|
||||
private messageComposerInput = createRef<SendMessageComposerClass>();
|
||||
@@ -129,11 +139,32 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
|
||||
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
this.context = context; // otherwise React will only set it prior to render due to type def above
|
||||
|
||||
VoiceRecordingStore.instance.on(UPDATE_EVENT, this.onVoiceStoreUpdate);
|
||||
|
||||
window.addEventListener("beforeunload", this.saveWysiwygEditorState);
|
||||
const isWysiwygLabEnabled = SettingsStore.getValue<boolean>("feature_wysiwyg_composer");
|
||||
let isRichTextEnabled = true;
|
||||
let initialComposerContent = "";
|
||||
if (isWysiwygLabEnabled) {
|
||||
const wysiwygState = this.restoreWysiwygEditorState();
|
||||
if (wysiwygState) {
|
||||
isRichTextEnabled = wysiwygState.isRichText;
|
||||
initialComposerContent = wysiwygState.content;
|
||||
if (wysiwygState.replyEventId) {
|
||||
dis.dispatch({
|
||||
action: "reply_to_event",
|
||||
event: this.props.room.findEventById(wysiwygState.replyEventId),
|
||||
context: this.context.timelineRenderingType,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this.state = {
|
||||
isComposerEmpty: true,
|
||||
composerContent: "",
|
||||
isComposerEmpty: initialComposerContent?.length === 0,
|
||||
composerContent: initialComposerContent,
|
||||
haveRecording: false,
|
||||
recordingTimeLeftSeconds: undefined, // when set to a number, shows a toast
|
||||
isMenuOpen: false,
|
||||
@@ -141,9 +172,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"),
|
||||
showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"),
|
||||
showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast),
|
||||
isWysiwygLabEnabled: SettingsStore.getValue<boolean>("feature_wysiwyg_composer"),
|
||||
isRichTextEnabled: true,
|
||||
initialComposerContent: "",
|
||||
isWysiwygLabEnabled: isWysiwygLabEnabled,
|
||||
isRichTextEnabled: isRichTextEnabled,
|
||||
initialComposerContent: initialComposerContent,
|
||||
};
|
||||
|
||||
this.instanceId = instanceCount++;
|
||||
@@ -154,6 +185,52 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
SettingsStore.monitorSetting("feature_wysiwyg_composer", null);
|
||||
}
|
||||
|
||||
private get editorStateKey(): string {
|
||||
let key = WYSIWYG_EDITOR_STATE_STORAGE_PREFIX + this.props.room.roomId;
|
||||
if (this.props.relation?.rel_type === THREAD_RELATION_TYPE.name) {
|
||||
key += `_${this.props.relation.event_id}`;
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
private restoreWysiwygEditorState(): WysiwygComposerState | undefined {
|
||||
const json = localStorage.getItem(this.editorStateKey);
|
||||
if (json) {
|
||||
try {
|
||||
const state: WysiwygComposerState = JSON.parse(json);
|
||||
return state;
|
||||
} catch (e) {
|
||||
logger.error(e);
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
private saveWysiwygEditorState = (): void => {
|
||||
if (this.shouldSaveWysiwygEditorState()) {
|
||||
const { isRichTextEnabled, composerContent } = this.state;
|
||||
const replyEventId = this.props.replyToEvent ? this.props.replyToEvent.getId() : undefined;
|
||||
const item: WysiwygComposerState = {
|
||||
content: composerContent,
|
||||
isRichText: isRichTextEnabled,
|
||||
replyEventId: replyEventId,
|
||||
};
|
||||
localStorage.setItem(this.editorStateKey, JSON.stringify(item));
|
||||
} else {
|
||||
this.clearStoredEditorState();
|
||||
}
|
||||
};
|
||||
|
||||
// should save state when wysiwyg is enabled and has contents or reply is open
|
||||
private shouldSaveWysiwygEditorState = (): boolean => {
|
||||
const { isWysiwygLabEnabled, isComposerEmpty } = this.state;
|
||||
return isWysiwygLabEnabled && (!isComposerEmpty || !!this.props.replyToEvent);
|
||||
};
|
||||
|
||||
private clearStoredEditorState(): void {
|
||||
localStorage.removeItem(this.editorStateKey);
|
||||
}
|
||||
|
||||
private get voiceRecording(): Optional<VoiceMessageRecording> {
|
||||
return this._voiceRecording;
|
||||
}
|
||||
@@ -265,6 +342,8 @@ export class MessageComposer extends React.Component<IProps, IState> {
|
||||
UIStore.instance.stopTrackingElementDimensions(`MessageComposer${this.instanceId}`);
|
||||
UIStore.instance.removeListener(`MessageComposer${this.instanceId}`, this.onResize);
|
||||
|
||||
window.removeEventListener("beforeunload", this.saveWysiwygEditorState);
|
||||
this.saveWysiwygEditorState();
|
||||
// clean up our listeners by setting our cached recording to falsy (see internal setter)
|
||||
this.voiceRecording = null;
|
||||
}
|
||||
|
@@ -15,112 +15,241 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { MatrixEvent, EventType, RelationType, Relations } from "matrix-js-sdk/src/matrix";
|
||||
import React, { JSX, useCallback, useState } from "react";
|
||||
import { EventTimeline, EventType, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { IconButton, Menu, MenuItem, Separator, Text } from "@vector-im/compound-web";
|
||||
import { Icon as ViewIcon } from "@vector-im/compound-design-tokens/icons/visibility-on.svg";
|
||||
import { Icon as UnpinIcon } from "@vector-im/compound-design-tokens/icons/unpin.svg";
|
||||
import { Icon as ForwardIcon } from "@vector-im/compound-design-tokens/icons/forward.svg";
|
||||
import { Icon as TriggerIcon } from "@vector-im/compound-design-tokens/icons/overflow-horizontal.svg";
|
||||
import { Icon as DeleteIcon } from "@vector-im/compound-design-tokens/icons/delete.svg";
|
||||
import { Icon as ThreadIcon } from "@vector-im/compound-design-tokens/icons/threads.svg";
|
||||
import classNames from "classnames";
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import AccessibleButton from "../elements/AccessibleButton";
|
||||
import MessageEvent from "../messages/MessageEvent";
|
||||
import MemberAvatar from "../avatars/MemberAvatar";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { formatDate } from "../../../DateUtils";
|
||||
import MatrixClientContext from "../../../contexts/MatrixClientContext";
|
||||
import { getUserNameColorClass } from "../../../utils/FormattingUtils";
|
||||
import { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { RoomPermalinkCreator } from "../../../utils/permalinks/Permalinks";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { isContentActionable } from "../../../utils/EventUtils";
|
||||
import { getForwardableEvent } from "../../../events";
|
||||
import { OpenForwardDialogPayload } from "../../../dispatcher/payloads/OpenForwardDialogPayload";
|
||||
import { createRedactEventDialog } from "../dialogs/ConfirmRedactDialog";
|
||||
import { ShowThreadPayload } from "../../../dispatcher/payloads/ShowThreadPayload";
|
||||
|
||||
interface IProps {
|
||||
const AVATAR_SIZE = "32px";
|
||||
|
||||
/**
|
||||
* Properties for {@link PinnedEventTile}.
|
||||
*/
|
||||
interface PinnedEventTileProps {
|
||||
/**
|
||||
* The event to display.
|
||||
*/
|
||||
event: MatrixEvent;
|
||||
/**
|
||||
* The permalink creator to use.
|
||||
*/
|
||||
permalinkCreator: RoomPermalinkCreator;
|
||||
onUnpinClicked?(): void;
|
||||
/**
|
||||
* The room the event is in.
|
||||
*/
|
||||
room: Room;
|
||||
}
|
||||
|
||||
const AVATAR_SIZE = "24px";
|
||||
/**
|
||||
* A pinned event tile.
|
||||
*/
|
||||
export function PinnedEventTile({ event, room, permalinkCreator }: PinnedEventTileProps): JSX.Element {
|
||||
const sender = event.getSender();
|
||||
if (!sender) {
|
||||
throw new Error("Pinned event unexpectedly has no sender");
|
||||
}
|
||||
|
||||
export default class PinnedEventTile extends React.Component<IProps> {
|
||||
public static contextType = MatrixClientContext;
|
||||
public declare context: React.ContextType<typeof MatrixClientContext>;
|
||||
const isInThread = Boolean(event.threadRootId);
|
||||
const displayThreadInfo = !event.isThreadRoot && isInThread;
|
||||
|
||||
private onTileClicked = (): void => {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
event_id: this.props.event.getId(),
|
||||
highlighted: true,
|
||||
room_id: this.props.event.getRoomId(),
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
};
|
||||
|
||||
// For event types like polls that use relations, we fetch those manually on
|
||||
// mount and store them here, exposing them through getRelationsForEvent
|
||||
private relations = new Map<string, Map<string, Relations>>();
|
||||
private getRelationsForEvent = (
|
||||
eventId: string,
|
||||
relationType: RelationType | string,
|
||||
eventType: EventType | string,
|
||||
): Relations | undefined => {
|
||||
if (eventId === this.props.event.getId()) {
|
||||
return this.relations.get(relationType)?.get(eventType);
|
||||
}
|
||||
};
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const sender = this.props.event.getSender();
|
||||
|
||||
if (!sender) {
|
||||
throw new Error("Pinned event unexpectedly has no sender");
|
||||
}
|
||||
|
||||
let unpinButton: JSX.Element | undefined;
|
||||
if (this.props.onUnpinClicked) {
|
||||
unpinButton = (
|
||||
<AccessibleButton
|
||||
onClick={this.props.onUnpinClicked}
|
||||
className="mx_PinnedEventTile_unpinButton"
|
||||
title={_t("action|unpin")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx_PinnedEventTile">
|
||||
return (
|
||||
<div className="mx_PinnedEventTile" role="listitem">
|
||||
<div>
|
||||
<MemberAvatar
|
||||
className="mx_PinnedEventTile_senderAvatar"
|
||||
member={this.props.event.sender}
|
||||
member={event.sender}
|
||||
size={AVATAR_SIZE}
|
||||
fallbackUserId={sender}
|
||||
/>
|
||||
|
||||
<span className={"mx_PinnedEventTile_sender " + getUserNameColorClass(sender)}>
|
||||
{this.props.event.sender?.name || sender}
|
||||
</span>
|
||||
|
||||
{unpinButton}
|
||||
|
||||
<div className="mx_PinnedEventTile_message">
|
||||
<MessageEvent
|
||||
mxEvent={this.props.event}
|
||||
getRelationsForEvent={this.getRelationsForEvent}
|
||||
// @ts-ignore - complaining that className is invalid when it's not
|
||||
className="mx_PinnedEventTile_body"
|
||||
maxImageHeight={150}
|
||||
onHeightChanged={() => {}} // we need to give this, apparently
|
||||
permalinkCreator={this.props.permalinkCreator}
|
||||
replacingEventId={this.props.event.replacingEventId()}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="mx_PinnedEventTile_footer">
|
||||
<span className="mx_MessageTimestamp mx_PinnedEventTile_timestamp">
|
||||
{formatDate(new Date(this.props.event.getTs()))}
|
||||
</span>
|
||||
|
||||
<AccessibleButton onClick={this.onTileClicked} kind="link">
|
||||
{_t("common|view_message")}
|
||||
</AccessibleButton>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
<div className="mx_PinnedEventTile_wrapper">
|
||||
<div className="mx_PinnedEventTile_top">
|
||||
<Text
|
||||
weight="semibold"
|
||||
className={classNames("mx_PinnedEventTile_sender", getUserNameColorClass(sender))}
|
||||
as="span"
|
||||
>
|
||||
{event.sender?.name || sender}
|
||||
</Text>
|
||||
<PinMenu event={event} room={room} permalinkCreator={permalinkCreator} />
|
||||
</div>
|
||||
<MessageEvent
|
||||
mxEvent={event}
|
||||
maxImageHeight={150}
|
||||
onHeightChanged={() => {}} // we need to give this, apparently
|
||||
permalinkCreator={permalinkCreator}
|
||||
replacingEventId={event.replacingEventId()}
|
||||
/>
|
||||
{displayThreadInfo && (
|
||||
<div className="mx_PinnedEventTile_thread">
|
||||
<ThreadIcon />
|
||||
{_t(
|
||||
"right_panel|pinned_messages|reply_thread",
|
||||
{},
|
||||
{
|
||||
link: (sub) => (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!event.threadRootId) return;
|
||||
|
||||
const rootEvent = room.findEventById(event.threadRootId);
|
||||
if (!rootEvent) return;
|
||||
|
||||
dis.dispatch<ShowThreadPayload>({
|
||||
action: Action.ShowThread,
|
||||
rootEvent: rootEvent,
|
||||
push: true,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{sub}
|
||||
</button>
|
||||
),
|
||||
},
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Properties for {@link PinMenu}.
|
||||
*/
|
||||
interface PinMenuProps extends PinnedEventTileProps {}
|
||||
|
||||
/**
|
||||
* A popover menu with actions on the pinned event
|
||||
*/
|
||||
function PinMenu({ event, room, permalinkCreator }: PinMenuProps): JSX.Element {
|
||||
const [open, setOpen] = useState(false);
|
||||
const matrixClient = useMatrixClientContext();
|
||||
|
||||
/**
|
||||
* View the event in the timeline.
|
||||
*/
|
||||
const onViewInTimeline = useCallback(() => {
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
event_id: event.getId(),
|
||||
highlighted: true,
|
||||
room_id: event.getRoomId(),
|
||||
metricsTrigger: undefined, // room doesn't change
|
||||
});
|
||||
}, [event]);
|
||||
|
||||
/**
|
||||
* Whether the client can unpin the event.
|
||||
* Pin and unpin are using the same permission.
|
||||
*/
|
||||
const canUnpin = useRoomState(room, (state) =>
|
||||
state.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient),
|
||||
);
|
||||
|
||||
/**
|
||||
* Unpin the event.
|
||||
* @param event
|
||||
*/
|
||||
const onUnpin = useCallback(async (): Promise<void> => {
|
||||
const pinnedEvents = room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||
if (pinnedEvents?.getContent()?.pinned) {
|
||||
const pinned = pinnedEvents.getContent().pinned;
|
||||
const index = pinned.indexOf(event.getId());
|
||||
if (index !== -1) {
|
||||
pinned.splice(index, 1);
|
||||
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned }, "");
|
||||
}
|
||||
}
|
||||
}, [event, room, matrixClient]);
|
||||
|
||||
const contentActionable = isContentActionable(event);
|
||||
// Get the forwardable event for the given event
|
||||
const forwardableEvent = contentActionable && getForwardableEvent(event, matrixClient);
|
||||
/**
|
||||
* Open the forward dialog.
|
||||
*/
|
||||
const onForward = useCallback(() => {
|
||||
if (forwardableEvent) {
|
||||
dis.dispatch<OpenForwardDialogPayload>({
|
||||
action: Action.OpenForwardDialog,
|
||||
event: forwardableEvent,
|
||||
permalinkCreator: permalinkCreator,
|
||||
});
|
||||
}
|
||||
}, [forwardableEvent, permalinkCreator]);
|
||||
|
||||
/**
|
||||
* Whether the client can redact the event.
|
||||
*/
|
||||
const canRedact =
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.maySendRedactionForEvent(event, matrixClient.getSafeUserId()) &&
|
||||
event.getType() !== EventType.RoomServerAcl &&
|
||||
event.getType() !== EventType.RoomEncryption;
|
||||
|
||||
/**
|
||||
* Redact the event.
|
||||
*/
|
||||
const onRedact = useCallback(
|
||||
(): void =>
|
||||
createRedactEventDialog({
|
||||
mxEvent: event,
|
||||
}),
|
||||
[event],
|
||||
);
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
showTitle={false}
|
||||
title={_t("right_panel|pinned_messages|menu")}
|
||||
side="right"
|
||||
align="start"
|
||||
trigger={
|
||||
<IconButton size="24px" aria-label={_t("right_panel|pinned_messages|menu")}>
|
||||
<TriggerIcon />
|
||||
</IconButton>
|
||||
}
|
||||
>
|
||||
<MenuItem Icon={ViewIcon} label={_t("right_panel|pinned_messages|view")} onSelect={onViewInTimeline} />
|
||||
{canUnpin && <MenuItem Icon={UnpinIcon} label={_t("action|unpin")} onSelect={onUnpin} />}
|
||||
{forwardableEvent && <MenuItem Icon={ForwardIcon} label={_t("action|forward")} onSelect={onForward} />}
|
||||
{canRedact && (
|
||||
<>
|
||||
<Separator />
|
||||
<MenuItem kind="critical" Icon={DeleteIcon} label={_t("action|delete")} onSelect={onRedact} />
|
||||
</>
|
||||
)}
|
||||
</Menu>
|
||||
);
|
||||
}
|
||||
|
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { useCallback, useMemo, useState } from "react";
|
||||
import React, { useCallback, useContext, useMemo, useState } from "react";
|
||||
import { Body as BodyText, Button, IconButton, Menu, MenuItem, Tooltip } from "@vector-im/compound-web";
|
||||
import { Icon as VideoCallIcon } from "@vector-im/compound-design-tokens/icons/video-call-solid.svg";
|
||||
import { Icon as VoiceCallIcon } from "@vector-im/compound-design-tokens/icons/voice-call.svg";
|
||||
@@ -50,7 +50,7 @@ import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
|
||||
import PosthogTrackers from "../../../PosthogTrackers";
|
||||
import { VideoRoomChatButton } from "./RoomHeader/VideoRoomChatButton";
|
||||
import { RoomKnocksBar } from "./RoomKnocksBar";
|
||||
import { isVideoRoom } from "../../../utils/video-rooms";
|
||||
import { useIsVideoRoom } from "../../../utils/video-rooms";
|
||||
import { notificationLevelToIndicator } from "../../../utils/notifications";
|
||||
import { CallGuestLinkButton } from "./RoomHeader/CallGuestLinkButton";
|
||||
import { ButtonEvent } from "../elements/AccessibleButton";
|
||||
@@ -59,6 +59,8 @@ import { useIsReleaseAnnouncementOpen } from "../../../hooks/useIsReleaseAnnounc
|
||||
import { ReleaseAnnouncementStore } from "../../../stores/ReleaseAnnouncementStore";
|
||||
import WithPresenceIndicator, { useDmMember } from "../avatars/WithPresenceIndicator";
|
||||
import { IOOBData } from "../../../stores/ThreepidInviteStore";
|
||||
import RoomContext from "../../../contexts/RoomContext";
|
||||
import { MainSplitContentType } from "../../structures/RoomView";
|
||||
|
||||
export default function RoomHeader({
|
||||
room,
|
||||
@@ -233,6 +235,13 @@ export default function RoomHeader({
|
||||
|
||||
const isReleaseAnnouncementOpen = useIsReleaseAnnouncementOpen("newRoomHeader");
|
||||
|
||||
const roomContext = useContext(RoomContext);
|
||||
const isVideoRoom = useIsVideoRoom(room);
|
||||
const showChatButton =
|
||||
isVideoRoom ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.MaximisedWidget ||
|
||||
roomContext.mainSplitContentType === MainSplitContentType.Call;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Flex as="header" align="center" gap="var(--cpd-space-3x)" className="mx_RoomHeader light-panel">
|
||||
@@ -325,14 +334,13 @@ export default function RoomHeader({
|
||||
})}
|
||||
|
||||
{isViewingCall && <CallGuestLinkButton room={room} />}
|
||||
{((isConnectedToCall && isViewingCall) || isVideoRoom(room)) && <VideoRoomChatButton room={room} />}
|
||||
|
||||
{hasActiveCallSession && !isConnectedToCall && !isViewingCall ? (
|
||||
joinCallButton
|
||||
) : (
|
||||
<>
|
||||
{!isVideoRoom(room) && videoCallButton}
|
||||
{!useElementCallExclusively && !isVideoRoom(room) && voiceCallButton}
|
||||
{!isVideoRoom && videoCallButton}
|
||||
{!useElementCallExclusively && !isVideoRoom && voiceCallButton}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -347,6 +355,9 @@ export default function RoomHeader({
|
||||
<RoomInfoIcon />
|
||||
</IconButton>
|
||||
</Tooltip>
|
||||
|
||||
{showChatButton && <VideoRoomChatButton room={room} />}
|
||||
|
||||
<Tooltip label={_t("common|threads")}>
|
||||
<IconButton
|
||||
indicator={notificationLevelToIndicator(threadNotifications)}
|
||||
|
@@ -23,6 +23,7 @@ import { _t } from "../../../languageHandler";
|
||||
import { PosthogScreenTracker } from "../../../PosthogTrackers";
|
||||
import SearchWarning, { WarningKind } from "../elements/SearchWarning";
|
||||
import { SearchInfo, SearchScope } from "../../../Searching";
|
||||
import InlineSpinner from "../elements/InlineSpinner";
|
||||
|
||||
interface Props {
|
||||
searchInfo?: SearchInfo;
|
||||
@@ -41,13 +42,15 @@ const RoomSearchAuxPanel: React.FC<Props> = ({ searchInfo, isRoomEncrypted, onSe
|
||||
<div className="mx_RoomSearchAuxPanel_summary">
|
||||
<SearchIcon width="24px" height="24px" />
|
||||
<div className="mx_RoomSearchAuxPanel_summary_text">
|
||||
{searchInfo
|
||||
? _t(
|
||||
"room|search|summary",
|
||||
{ count: searchInfo.count ?? 0 },
|
||||
{ query: () => <b>{searchInfo.term}</b> },
|
||||
)
|
||||
: undefined}
|
||||
{searchInfo?.count !== undefined ? (
|
||||
_t(
|
||||
"room|search|summary",
|
||||
{ count: searchInfo.count },
|
||||
{ query: () => <b>{searchInfo.term}</b> },
|
||||
)
|
||||
) : (
|
||||
<InlineSpinner />
|
||||
)}
|
||||
<SearchWarning kind={WarningKind.Search} isRoomEncrypted={isRoomEncrypted} showLogo={false} />
|
||||
</div>
|
||||
</div>
|
||||
|
534
src/components/views/settings/AddRemoveThreepids.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
@@ -18,8 +18,6 @@ import React, { useCallback, useEffect, useState } from "react";
|
||||
import { ThreepidMedium } from "matrix-js-sdk/src/matrix";
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
|
||||
import AccountEmailAddresses from "./account/EmailAddresses";
|
||||
import AccountPhoneNumbers from "./account/PhoneNumbers";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import InlineSpinner from "../elements/InlineSpinner";
|
||||
import SettingsSubsection from "./shared/SettingsSubsection";
|
||||
@@ -27,6 +25,7 @@ import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { ThirdPartyIdentifier } from "../../../AddThreepid";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { UIFeature } from "../../../settings/UIFeature";
|
||||
import { AddRemoveThreepids } from "./AddRemoveThreepids";
|
||||
|
||||
type LoadingState = "loading" | "loaded" | "error";
|
||||
|
||||
@@ -64,26 +63,28 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
|
||||
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const threepids = await client.getThreePids();
|
||||
setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email));
|
||||
setPhoneNumbers(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Phone));
|
||||
setLoadingState("loaded");
|
||||
} catch (e) {
|
||||
setLoadingState("error");
|
||||
}
|
||||
})();
|
||||
const updateThreepids = useCallback(async () => {
|
||||
try {
|
||||
const threepids = await client.getThreePids();
|
||||
setEmails(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Email));
|
||||
setPhoneNumbers(threepids.threepids.filter((a) => a.medium === ThreepidMedium.Phone));
|
||||
setLoadingState("loaded");
|
||||
} catch (e) {
|
||||
setLoadingState("error");
|
||||
}
|
||||
}, [client]);
|
||||
|
||||
const onEmailsChange = useCallback((emails: ThirdPartyIdentifier[]) => {
|
||||
setEmails(emails);
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
updateThreepids().then();
|
||||
}, [updateThreepids]);
|
||||
|
||||
const onMsisdnsChange = useCallback((msisdns: ThirdPartyIdentifier[]) => {
|
||||
setPhoneNumbers(msisdns);
|
||||
}, []);
|
||||
const onEmailsChange = useCallback(() => {
|
||||
updateThreepids().then();
|
||||
}, [updateThreepids]);
|
||||
|
||||
const onMsisdnsChange = useCallback(() => {
|
||||
updateThreepids().then();
|
||||
}, [updateThreepids]);
|
||||
|
||||
if (!SettingsStore.getValue(UIFeature.ThirdPartyID)) return null;
|
||||
|
||||
@@ -99,10 +100,13 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
|
||||
error={_t("settings|general|unable_to_load_emails")}
|
||||
loadingState={loadingState}
|
||||
>
|
||||
<AccountEmailAddresses
|
||||
emails={emails!}
|
||||
onEmailsChange={onEmailsChange}
|
||||
<AddRemoveThreepids
|
||||
mode="hs"
|
||||
medium={ThreepidMedium.Email}
|
||||
threepids={emails!}
|
||||
onChange={onEmailsChange}
|
||||
disabled={!canMake3pidChanges}
|
||||
isLoading={loadingState === "loading"}
|
||||
/>
|
||||
</ThreepidSectionWrapper>
|
||||
</SettingsSubsection>
|
||||
@@ -116,10 +120,13 @@ export const UserPersonalInfoSettings: React.FC<UserPersonalInfoSettingsProps> =
|
||||
error={_t("settings|general|unable_to_load_msisdns")}
|
||||
loadingState={loadingState}
|
||||
>
|
||||
<AccountPhoneNumbers
|
||||
msisdns={phoneNumbers!}
|
||||
onMsisdnsChange={onMsisdnsChange}
|
||||
<AddRemoveThreepids
|
||||
mode="hs"
|
||||
medium={ThreepidMedium.Phone}
|
||||
threepids={phoneNumbers!}
|
||||
onChange={onMsisdnsChange}
|
||||
disabled={!canMake3pidChanges}
|
||||
isLoading={loadingState === "loading"}
|
||||
/>
|
||||
</ThreepidSectionWrapper>
|
||||
</SettingsSubsection>
|
||||
|
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@@ -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}
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
@@ -19,8 +19,6 @@ import { SERVICE_TYPES, ThreepidMedium } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { Alert } from "@vector-im/compound-web";
|
||||
|
||||
import DiscoveryEmailAddresses from "../discovery/EmailAddresses";
|
||||
import DiscoveryPhoneNumbers from "../discovery/PhoneNumbers";
|
||||
import { getThreepidsWithBindStatus } from "../../../../boundThreepids";
|
||||
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext";
|
||||
import { ThirdPartyIdentifier } from "../../../../AddThreepid";
|
||||
@@ -36,6 +34,7 @@ import { abbreviateUrl } from "../../../../utils/UrlUtils";
|
||||
import { useDispatcher } from "../../../../hooks/useDispatcher";
|
||||
import defaultDispatcher from "../../../../dispatcher/dispatcher";
|
||||
import { ActionPayload } from "../../../../dispatcher/payloads";
|
||||
import { AddRemoveThreepids } from "../AddRemoveThreepids";
|
||||
|
||||
type RequiredPolicyInfo =
|
||||
| {
|
||||
@@ -56,9 +55,9 @@ type RequiredPolicyInfo =
|
||||
export const DiscoverySettings: React.FC = () => {
|
||||
const client = useMatrixClientContext();
|
||||
|
||||
const [isLoadingThreepids, setIsLoadingThreepids] = useState<boolean>(true);
|
||||
const [emails, setEmails] = useState<ThirdPartyIdentifier[]>([]);
|
||||
const [phoneNumbers, setPhoneNumbers] = useState<ThirdPartyIdentifier[]>([]);
|
||||
const [loadingState, setLoadingState] = useState<"loading" | "loaded" | "error">("loading");
|
||||
const [idServerName, setIdServerName] = useState<string | undefined>(abbreviateUrl(client.getIdentityServerUrl()));
|
||||
const [canMake3pidChanges, setCanMake3pidChanges] = useState<boolean>(false);
|
||||
|
||||
@@ -71,9 +70,11 @@ export const DiscoverySettings: React.FC = () => {
|
||||
const [hasTerms, setHasTerms] = useState<boolean>(false);
|
||||
|
||||
const getThreepidState = useCallback(async () => {
|
||||
setIsLoadingThreepids(true);
|
||||
const threepids = await getThreepidsWithBindStatus(client);
|
||||
setEmails(threepids.filter((a) => a.medium === ThreepidMedium.Email));
|
||||
setPhoneNumbers(threepids.filter((a) => a.medium === ThreepidMedium.Phone));
|
||||
setIsLoadingThreepids(false);
|
||||
}, [client]);
|
||||
|
||||
useDispatcher(
|
||||
@@ -133,11 +134,7 @@ export const DiscoverySettings: React.FC = () => {
|
||||
);
|
||||
logger.warn(e);
|
||||
}
|
||||
|
||||
setLoadingState("loaded");
|
||||
} catch (e) {
|
||||
setLoadingState("error");
|
||||
}
|
||||
} catch (e) {}
|
||||
})();
|
||||
}, [client, getThreepidState]);
|
||||
|
||||
@@ -163,23 +160,44 @@ export const DiscoverySettings: React.FC = () => {
|
||||
);
|
||||
}
|
||||
|
||||
const threepidSection = idServerName ? (
|
||||
<>
|
||||
<DiscoveryEmailAddresses
|
||||
emails={emails}
|
||||
isLoading={loadingState === "loading"}
|
||||
disabled={!canMake3pidChanges}
|
||||
/>
|
||||
<DiscoveryPhoneNumbers
|
||||
msisdns={phoneNumbers}
|
||||
isLoading={loadingState === "loading"}
|
||||
disabled={!canMake3pidChanges}
|
||||
/>
|
||||
</>
|
||||
) : null;
|
||||
let threepidSection;
|
||||
if (idServerName) {
|
||||
threepidSection = (
|
||||
<>
|
||||
<SettingsSubsection
|
||||
heading={_t("settings|general|emails_heading")}
|
||||
description={emails.length === 0 ? _t("settings|general|discovery_email_empty") : undefined}
|
||||
stretchContent
|
||||
>
|
||||
<AddRemoveThreepids
|
||||
mode="is"
|
||||
medium={ThreepidMedium.Email}
|
||||
threepids={emails}
|
||||
onChange={getThreepidState}
|
||||
disabled={!canMake3pidChanges}
|
||||
isLoading={isLoadingThreepids}
|
||||
/>
|
||||
</SettingsSubsection>
|
||||
<SettingsSubsection
|
||||
heading={_t("settings|general|msisdns_heading")}
|
||||
description={phoneNumbers.length === 0 ? _t("settings|general|discovery_msisdn_empty") : undefined}
|
||||
stretchContent
|
||||
>
|
||||
<AddRemoveThreepids
|
||||
mode="is"
|
||||
medium={ThreepidMedium.Phone}
|
||||
threepids={phoneNumbers}
|
||||
onChange={getThreepidState}
|
||||
disabled={!canMake3pidChanges}
|
||||
isLoading={isLoadingThreepids}
|
||||
/>
|
||||
</SettingsSubsection>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection">
|
||||
<SettingsSubsection heading={_t("settings|discovery|title")} data-testid="discoverySection" stretchContent>
|
||||
{threepidSection}
|
||||
{/* has its own heading as it includes the current identity server */}
|
||||
<SetIdServer missingTerms={false} />
|
||||
|
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -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>
|
||||
);
|
||||
}
|
||||
}
|
@@ -1792,8 +1792,6 @@
|
||||
"export_chat_button": "Exportovat chat",
|
||||
"files_button": "Soubory",
|
||||
"pinned_messages": {
|
||||
"empty": "Zatím není nic připnuto",
|
||||
"explainer": "Pokud máte oprávnění, otevřete nabídku na libovolné zprávě a výběrem možnosti <b>Připnout</b> je sem vložte.",
|
||||
"limits": {
|
||||
"other": "Můžete připnout až %(count)s widgetů"
|
||||
},
|
||||
@@ -2454,7 +2452,6 @@
|
||||
"error_share_msisdn_discovery": "Nepovedlo se nasdílet telefonní číslo",
|
||||
"identity_server_no_token": "Nebyl nalezen žádný přístupový token identity",
|
||||
"identity_server_not_set": "Server identit není nastaven",
|
||||
"incorrect_msisdn_verification": "Nesprávný ověřovací kód",
|
||||
"language_section": "Jazyk a region",
|
||||
"msisdn_in_use": "Toto telefonní číslo je již používáno",
|
||||
"msisdn_label": "Telefonní číslo",
|
||||
|
@@ -1778,8 +1778,6 @@
|
||||
"export_chat_button": "Unterhaltung exportieren",
|
||||
"files_button": "Dateien",
|
||||
"pinned_messages": {
|
||||
"empty": "Es ist nichts angepinnt. Noch nicht.",
|
||||
"explainer": "Sofern du die Berechtigung hast, öffne das Menü einer Nachricht und wähle <b>Anheften</b>, um sie hier aufzubewahren.",
|
||||
"limits": {
|
||||
"other": "Du kannst nur %(count)s Widgets anheften"
|
||||
},
|
||||
@@ -2433,7 +2431,6 @@
|
||||
"error_share_msisdn_discovery": "Teilen der Telefonnummer nicht möglich",
|
||||
"identity_server_no_token": "Kein Identitäts-Zugangs-Token gefunden",
|
||||
"identity_server_not_set": "Kein Identitäts-Server festgelegt",
|
||||
"incorrect_msisdn_verification": "Falscher Verifizierungscode",
|
||||
"language_section": "Sprache und Region",
|
||||
"msisdn_in_use": "Diese Telefonnummer wird bereits verwendet",
|
||||
"msisdn_label": "Telefonnummer",
|
||||
|
@@ -1427,8 +1427,6 @@
|
||||
"export_chat_button": "Εξαγωγή συνομιλίας",
|
||||
"files_button": "Αρχεία",
|
||||
"pinned_messages": {
|
||||
"empty": "Δεν έχει καρφιτσωθεί κάτι ακόμα",
|
||||
"explainer": "Εάν έχετε δικαιώματα, ανοίξτε το μενού σε οποιοδήποτε μήνυμα και επιλέξτε <b>Καρφίτσωμα</b> για να τα κολλήσετε εδώ.",
|
||||
"limits": {
|
||||
"other": "Μπορείτε να καρφιτσώσετε μόνο έως %(count)s μικρεοεφαρμογές"
|
||||
},
|
||||
@@ -1965,7 +1963,6 @@
|
||||
"error_revoke_msisdn_discovery": "Αδυναμία ανάκληση της κοινής χρήσης για τον αριθμό τηλεφώνου",
|
||||
"error_share_email_discovery": "Δεν είναι δυνατή η κοινή χρήση της διεύθυνσης email",
|
||||
"error_share_msisdn_discovery": "Αδυναμία κοινής χρήσης του αριθμού τηλεφώνου",
|
||||
"incorrect_msisdn_verification": "Λανθασμένος κωδικός επαλήθευσης",
|
||||
"language_section": "Γλώσσα και περιοχή",
|
||||
"msisdn_in_use": "Αυτός ο αριθμός τηλεφώνου είναι ήδη σε χρήση",
|
||||
"msisdn_label": "Αριθμός Τηλεφώνου",
|
||||
|
@@ -1839,12 +1839,25 @@
|
||||
"files_button": "Files",
|
||||
"info": "Info",
|
||||
"pinned_messages": {
|
||||
"empty": "Nothing pinned, yet",
|
||||
"explainer": "If you have permissions, open the menu on any message and select <b>Pin</b> to stick them here.",
|
||||
"empty_description": "Select a message and choose “%(pinAction)s” to it include here.",
|
||||
"empty_title": "Pin important messages so that they can be easily discovered",
|
||||
"header": {
|
||||
"one": "1 Pinned message",
|
||||
"other": "%(count)s Pinned messages",
|
||||
"zero": "Pinned message"
|
||||
},
|
||||
"limits": {
|
||||
"other": "You can only pin up to %(count)s widgets"
|
||||
},
|
||||
"title": "Pinned messages"
|
||||
"menu": "Open menu",
|
||||
"reply_thread": "Reply to a <link>thread message</link>",
|
||||
"title": "Pinned messages",
|
||||
"unpin_all": {
|
||||
"button": "Unpin all messages",
|
||||
"content": "Make sure that you really want to remove all pinned messages. This action can’t be undone.",
|
||||
"title": "Unpin all messages?"
|
||||
},
|
||||
"view": "View in timeline"
|
||||
},
|
||||
"pinned_messages_button": "Pinned messages",
|
||||
"poll": {
|
||||
@@ -2532,7 +2545,6 @@
|
||||
"error_share_msisdn_discovery": "Unable to share phone number",
|
||||
"identity_server_no_token": "No identity access token found",
|
||||
"identity_server_not_set": "Identity server not set",
|
||||
"incorrect_msisdn_verification": "Incorrect verification code",
|
||||
"language_section": "Language",
|
||||
"msisdn_in_use": "This phone number is already in use",
|
||||
"msisdn_label": "Phone Number",
|
||||
|
@@ -1290,8 +1290,6 @@
|
||||
"export_chat_button": "Eksporti babilejon",
|
||||
"files_button": "Dosieroj",
|
||||
"pinned_messages": {
|
||||
"empty": "Ankoraŭ nenio fiksita",
|
||||
"explainer": "Se vi havas la bezonajn permesojn, malfermu la menuon sur ajna mesaĝo, kaj klaku al <b>Fiksi</b> por meti ĝin ĉi tien.",
|
||||
"limits": {
|
||||
"other": "Vi povas fiksi maksimume %(count)s fenestraĵojn"
|
||||
},
|
||||
@@ -1754,7 +1752,6 @@
|
||||
"error_revoke_msisdn_discovery": "Ne povas senvalidigi havigadon je telefonnumero",
|
||||
"error_share_email_discovery": "Ne povas havigi vian retpoŝtadreson",
|
||||
"error_share_msisdn_discovery": "Ne povas havigi telefonnumeron",
|
||||
"incorrect_msisdn_verification": "Malĝusta kontrola kodo",
|
||||
"language_section": "Lingvo kaj regiono",
|
||||
"msisdn_in_use": "Tiu ĉi telefonnumero jam estas uzata",
|
||||
"msisdn_label": "Telefonnumero",
|
||||
|
@@ -1651,8 +1651,6 @@
|
||||
"export_chat_button": "Exportar conversación",
|
||||
"files_button": "Archivos",
|
||||
"pinned_messages": {
|
||||
"empty": "Ningún mensaje fijado… todavía",
|
||||
"explainer": "Si tienes permisos, abre el menú de cualquier mensaje y selecciona <b>Fijar</b> para colocarlo aquí.",
|
||||
"limits": {
|
||||
"other": "Solo puedes anclar hasta %(count)s accesorios"
|
||||
},
|
||||
@@ -2248,7 +2246,6 @@
|
||||
"error_share_email_discovery": "No se logró compartir la dirección de correo electrónico",
|
||||
"error_share_msisdn_discovery": "No se logró compartir el número de teléfono",
|
||||
"identity_server_not_set": "Servidor de identidad no configurado",
|
||||
"incorrect_msisdn_verification": "Verificación de código incorrecta",
|
||||
"language_section": "Idioma y región",
|
||||
"msisdn_in_use": "Este número de teléfono ya está en uso",
|
||||
"msisdn_label": "Número de teléfono",
|
||||
|
@@ -1775,8 +1775,6 @@
|
||||
"export_chat_button": "Ekspordi vestlus",
|
||||
"files_button": "Failid",
|
||||
"pinned_messages": {
|
||||
"empty": "Klammerdatud sõnumeid veel pole",
|
||||
"explainer": "Kui sul on vastavad õigused olemas, siis ava sõnumi juuresolev menüü ning püsisõnumi tekitamiseks vali <b>Klammerda</b>.",
|
||||
"limits": {
|
||||
"other": "Sa saad kinnitada kuni %(count)s vidinat"
|
||||
},
|
||||
@@ -2416,7 +2414,6 @@
|
||||
"error_share_msisdn_discovery": "Telefoninumbri jagamine ei õnnestunud",
|
||||
"identity_server_no_token": "Ei leidu tunnusluba isikutuvastusserveri jaoks",
|
||||
"identity_server_not_set": "Isikutuvastusserver on määramata",
|
||||
"incorrect_msisdn_verification": "Vigane verifikatsioonikood",
|
||||
"language_section": "Keel ja piirkond",
|
||||
"msisdn_in_use": "See telefoninumber on juba kasutusel",
|
||||
"msisdn_label": "Telefoninumber",
|
||||
|
@@ -1545,7 +1545,6 @@
|
||||
"error_share_email_discovery": "به اشتراکگذاری آدرس ایمیل ممکن نیست",
|
||||
"error_share_msisdn_discovery": "امکان به اشتراکگذاری شماره تلفن وجود ندارد",
|
||||
"identity_server_not_set": "سرور هویت تنظیم نشده است",
|
||||
"incorrect_msisdn_verification": "کد فعالسازی اشتباه است",
|
||||
"language_section": "زبان و جغرافیا",
|
||||
"msisdn_in_use": "این شماره تلفن در حال استفاده است",
|
||||
"msisdn_label": "شماره تلفن",
|
||||
|
@@ -1547,7 +1547,6 @@
|
||||
"export_chat_button": "Vie keskustelu",
|
||||
"files_button": "Tiedostot",
|
||||
"pinned_messages": {
|
||||
"empty": "Ei mitään kiinnitetty, ei vielä",
|
||||
"limits": {
|
||||
"other": "Voit kiinnittää enintään %(count)s sovelmaa"
|
||||
},
|
||||
@@ -2134,7 +2133,6 @@
|
||||
"error_share_email_discovery": "Sähköpostiosoitetta ei voi jakaa",
|
||||
"error_share_msisdn_discovery": "Puhelinnumeroa ei voi jakaa",
|
||||
"identity_server_not_set": "Identiteettipalvelinta ei ole asetettu",
|
||||
"incorrect_msisdn_verification": "Virheellinen varmennuskoodi",
|
||||
"language_section": "Kieli ja alue",
|
||||
"msisdn_in_use": "Puhelinnumero on jo käytössä",
|
||||
"msisdn_label": "Puhelinnumero",
|
||||
|
@@ -1829,8 +1829,6 @@
|
||||
"export_chat_button": "Exporter la conversation",
|
||||
"files_button": "Fichiers",
|
||||
"pinned_messages": {
|
||||
"empty": "Rien d’épinglé, pour l’instant",
|
||||
"explainer": "Si vous avez les permissions, ouvrez le menu de n’importe quel message et sélectionnez <b>Épingler</b> pour les afficher ici.",
|
||||
"limits": {
|
||||
"other": "Vous ne pouvez épingler que jusqu’à %(count)s widgets"
|
||||
},
|
||||
@@ -2484,7 +2482,6 @@
|
||||
"error_share_msisdn_discovery": "Impossible de partager le numéro de téléphone",
|
||||
"identity_server_no_token": "Aucun jeton d’accès d’identité trouvé",
|
||||
"identity_server_not_set": "Serveur d'identité non défini",
|
||||
"incorrect_msisdn_verification": "Code de vérification incorrect",
|
||||
"language_section": "Langue et région",
|
||||
"msisdn_in_use": "Ce numéro de téléphone est déjà utilisé",
|
||||
"msisdn_label": "Numéro de téléphone",
|
||||
|
@@ -1536,8 +1536,6 @@
|
||||
"export_chat_button": "Exportar chat",
|
||||
"files_button": "Ficheiros",
|
||||
"pinned_messages": {
|
||||
"empty": "Nada fixado, por agora",
|
||||
"explainer": "Se tes permisos, abre o menú en calquera mensaxe e elixe <b>Fixar</b> para pegalos aquí.",
|
||||
"limits": {
|
||||
"other": "Só podes fixar ata %(count)s widgets"
|
||||
},
|
||||
@@ -2081,7 +2079,6 @@
|
||||
"error_revoke_msisdn_discovery": "Non se puido revogar a compartición do número de teléfono",
|
||||
"error_share_email_discovery": "Non se puido compartir co enderezo de email",
|
||||
"error_share_msisdn_discovery": "Non se puido compartir o número de teléfono",
|
||||
"incorrect_msisdn_verification": "Código de verificación incorrecto",
|
||||
"language_section": "Idioma e rexión",
|
||||
"msisdn_in_use": "Xa se está a usar este teléfono",
|
||||
"msisdn_label": "Número de teléfono",
|
||||
|
@@ -1233,7 +1233,6 @@
|
||||
"export_chat_button": "ייצוא צ'אט",
|
||||
"files_button": "קבצים",
|
||||
"pinned_messages": {
|
||||
"empty": "אין הודעות נעוצות, לבינתיים",
|
||||
"limits": {
|
||||
"other": "אתה יכול להצמיד עד%(count)s ווידג'טים בלבד"
|
||||
},
|
||||
@@ -1672,7 +1671,6 @@
|
||||
"error_revoke_msisdn_discovery": "לא ניתן לבטל את השיתוף למספר טלפון",
|
||||
"error_share_email_discovery": "לא ניתן לשתף את כתובת הדוא\"ל",
|
||||
"error_share_msisdn_discovery": "לא ניתן לשתף מספר טלפון",
|
||||
"incorrect_msisdn_verification": "קוד אימות שגוי",
|
||||
"language_section": "שפה ואיזור",
|
||||
"msisdn_in_use": "מספר הטלפון הזה כבר בשימוש",
|
||||
"msisdn_label": "מספר טלפון",
|
||||
|
@@ -1745,8 +1745,6 @@
|
||||
"export_chat_button": "Beszélgetés exportálása",
|
||||
"files_button": "Fájlok",
|
||||
"pinned_messages": {
|
||||
"empty": "Még semmi sincs kitűzve",
|
||||
"explainer": "Ha van hozzá jogosultsága, nyissa meg a menüt bármelyik üzenetben és válassza a <b>Kitűzés</b> menüpontot a kitűzéshez.",
|
||||
"limits": {
|
||||
"other": "Csak %(count)s kisalkalmazást tud kitűzni"
|
||||
},
|
||||
@@ -2375,7 +2373,6 @@
|
||||
"error_share_msisdn_discovery": "A telefonszámot nem sikerült megosztani",
|
||||
"identity_server_no_token": "Nem található személyazonosság-hozzáférési kulcs",
|
||||
"identity_server_not_set": "Az azonosítási kiszolgáló nincs megadva",
|
||||
"incorrect_msisdn_verification": "Hibás azonosítási kód",
|
||||
"language_section": "Nyelv és régió",
|
||||
"msisdn_in_use": "Ez a telefonszám már használatban van",
|
||||
"msisdn_label": "Telefonszám",
|
||||
|
@@ -1757,8 +1757,6 @@
|
||||
"export_chat_button": "Ekspor obrolan",
|
||||
"files_button": "File",
|
||||
"pinned_messages": {
|
||||
"empty": "Belum ada yang dipasangi pin",
|
||||
"explainer": "Jika Anda memiliki izin, buka menunya di pesan apa saja dan pilih <b>Pin</b> untuk menempelkannya di sini.",
|
||||
"limits": {
|
||||
"other": "Anda hanya dapat memasang pin sampai %(count)s widget"
|
||||
},
|
||||
@@ -2408,7 +2406,6 @@
|
||||
"error_share_msisdn_discovery": "Tidak dapat membagikan nomor telepon",
|
||||
"identity_server_no_token": "Tidak ada token akses identitas yang ditemukan",
|
||||
"identity_server_not_set": "Server identitas tidak diatur",
|
||||
"incorrect_msisdn_verification": "Kode verifikasi tidak benar",
|
||||
"language_section": "Bahasa dan wilayah",
|
||||
"msisdn_in_use": "Nomor telepon ini telah dipakai",
|
||||
"msisdn_label": "Nomor Telepon",
|
||||
|
@@ -1470,7 +1470,6 @@
|
||||
"export_chat_button": "Flytja út spjall",
|
||||
"files_button": "Skrár",
|
||||
"pinned_messages": {
|
||||
"empty": "Ekkert fest, ennþá",
|
||||
"limits": {
|
||||
"other": "Þú getur bara fest allt að %(count)s viðmótshluta"
|
||||
},
|
||||
@@ -1968,7 +1967,6 @@
|
||||
"error_revoke_msisdn_discovery": "Ekki er hægt að afturkalla að deila símanúmeri",
|
||||
"error_share_email_discovery": "Get ekki deilt tölvupóstfangi",
|
||||
"error_share_msisdn_discovery": "Ekki er hægt að deila símanúmeri",
|
||||
"incorrect_msisdn_verification": "Rangur sannvottunarkóði",
|
||||
"language_section": "Tungumál og landsvæði",
|
||||
"msisdn_in_use": "Þetta símanúmer er nú þegar í notkun",
|
||||
"msisdn_label": "Símanúmer",
|
||||
|
@@ -1791,8 +1791,6 @@
|
||||
"export_chat_button": "Esporta conversazione",
|
||||
"files_button": "File",
|
||||
"pinned_messages": {
|
||||
"empty": "Non c'è ancora nulla di ancorato",
|
||||
"explainer": "Se ne hai il permesso, apri il menu di qualsiasi messaggio e seleziona <b>Fissa</b> per ancorarlo qui.",
|
||||
"limits": {
|
||||
"other": "Puoi ancorare al massimo %(count)s widget"
|
||||
},
|
||||
@@ -2450,7 +2448,6 @@
|
||||
"error_share_msisdn_discovery": "Impossibile condividere il numero di telefono",
|
||||
"identity_server_no_token": "Nessun token di accesso d'identità trovato",
|
||||
"identity_server_not_set": "Server d'identità non impostato",
|
||||
"incorrect_msisdn_verification": "Codice di verifica sbagliato",
|
||||
"language_section": "Lingua e regione",
|
||||
"msisdn_in_use": "Questo numero di telefono è già in uso",
|
||||
"msisdn_label": "Numero di telefono",
|
||||
|
@@ -1664,8 +1664,6 @@
|
||||
"export_chat_button": "チャットをエクスポート",
|
||||
"files_button": "ファイル",
|
||||
"pinned_messages": {
|
||||
"empty": "固定メッセージはありません",
|
||||
"explainer": "権限がある場合は、メッセージのメニューを開いて<b>固定</b>を選択すると、ここにメッセージが表示されます。",
|
||||
"limits": {
|
||||
"other": "ウィジェットのピン留めは%(count)s件までです"
|
||||
},
|
||||
@@ -2239,7 +2237,6 @@
|
||||
"error_revoke_msisdn_discovery": "電話番号の共有を取り消せません",
|
||||
"error_share_email_discovery": "メールアドレスを共有できません",
|
||||
"error_share_msisdn_discovery": "電話番号を共有できません",
|
||||
"incorrect_msisdn_verification": "認証コードが誤っています",
|
||||
"language_section": "言語と地域",
|
||||
"msisdn_in_use": "この電話番号は既に使用されています",
|
||||
"msisdn_label": "電話番号",
|
||||
|
@@ -1453,8 +1453,6 @@
|
||||
"export_chat_button": "ສົ່ງການສົນທະນາອອກ",
|
||||
"files_button": "ໄຟລ໌",
|
||||
"pinned_messages": {
|
||||
"empty": "ບໍ່ມີຫຍັງຖືກປັກໝຸດ,",
|
||||
"explainer": "ຖ້າຫາກທ່ານມີການອະນຸຍາດ, ເປີດເມນູໃນຂໍ້ຄວາມໃດຫນຶ່ງ ແລະ ເລືອກ <b>Pin</b> ເພື່ອຕິດໃຫ້ເຂົາເຈົ້າຢູ່ທີ່ນີ້.",
|
||||
"limits": {
|
||||
"other": "ທ່ານສາມາດປັກໝຸດໄດ້ເຖິງ %(count)s widget ເທົ່ານັ້ນ"
|
||||
},
|
||||
@@ -1994,7 +1992,6 @@
|
||||
"error_revoke_msisdn_discovery": "ບໍ່ສາມາດຖອນການແບ່ງປັນສຳລັບເບີໂທລະສັບໄດ້",
|
||||
"error_share_email_discovery": "ບໍ່ສາມາດແບ່ງປັນທີ່ຢູ່ອີເມວໄດ້",
|
||||
"error_share_msisdn_discovery": "ບໍ່ສາມາດແບ່ງປັນເບີໂທລະສັບໄດ້",
|
||||
"incorrect_msisdn_verification": "ລະຫັດຢືນຢັນບໍ່ຖືກຕ້ອງ",
|
||||
"language_section": "ພາສາ ແລະ ພາກພື້ນ",
|
||||
"msisdn_in_use": "ເບີໂທນີ້ຖືກໃຊ້ແລ້ວ",
|
||||
"msisdn_label": "ເບີໂທລະສັບ",
|
||||
|
@@ -1084,8 +1084,6 @@
|
||||
"export_chat_button": "Eksportuoti pokalbį",
|
||||
"files_button": "Failai",
|
||||
"pinned_messages": {
|
||||
"empty": "Kol kas nieko neprisegta",
|
||||
"explainer": "Jei turite leidimus, atidarykite bet kurios žinutės meniu ir pasirinkite <b>Prisegti</b>, kad juos čia priklijuotumėte.",
|
||||
"limits": {
|
||||
"other": "Galite prisegti tik iki %(count)s valdiklių"
|
||||
},
|
||||
@@ -1568,7 +1566,6 @@
|
||||
"error_revoke_msisdn_discovery": "Neina atšaukti telefono numerio bendrinimo",
|
||||
"error_share_email_discovery": "Nepavyko pasidalinti el. pašto adresu",
|
||||
"error_share_msisdn_discovery": "Neina bendrinti telefono numerio",
|
||||
"incorrect_msisdn_verification": "Neteisingas patvirtinimo kodas",
|
||||
"language_section": "Kalba ir regionas",
|
||||
"msisdn_in_use": "Šis telefono numeris jau naudojamas",
|
||||
"msisdn_label": "Telefono Numeris",
|
||||
|
@@ -1516,8 +1516,6 @@
|
||||
"export_chat_button": "Chat exporteren",
|
||||
"files_button": "Bestanden",
|
||||
"pinned_messages": {
|
||||
"empty": "Nog niks vastgeprikt",
|
||||
"explainer": "Als je de rechten hebt, open dan het menu op elk bericht en selecteer <b>Vastprikken</b> om ze hier te zetten.",
|
||||
"limits": {
|
||||
"other": "Je kunt maar %(count)s widgets vastzetten"
|
||||
},
|
||||
@@ -2076,7 +2074,6 @@
|
||||
"error_revoke_msisdn_discovery": "Kan delen voor dit telefoonnummer niet intrekken",
|
||||
"error_share_email_discovery": "Kan e-mailadres niet delen",
|
||||
"error_share_msisdn_discovery": "Kan telefoonnummer niet delen",
|
||||
"incorrect_msisdn_verification": "Onjuiste verificatiecode",
|
||||
"language_section": "Taal en regio",
|
||||
"msisdn_in_use": "Dit telefoonnummer is al in gebruik",
|
||||
"msisdn_label": "Telefoonnummer",
|
||||
|
@@ -1629,7 +1629,7 @@
|
||||
"level_none": "Brak",
|
||||
"level_notification": "Powiadomienie",
|
||||
"level_unsent": "Niewysłane",
|
||||
"mark_all_read": "Oznacz wszystko jako przeczytane",
|
||||
"mark_all_read": "Oznacz wszystkie jako przeczytane",
|
||||
"mentions_and_keywords": "@wzmianki & słowa kluczowe",
|
||||
"mentions_and_keywords_description": "Otrzymuj powiadomienia tylko z wzmiankami i słowami kluczowymi zgodnie z Twoimi <a>ustawieniami</a>",
|
||||
"mentions_keywords": "Wzmianki i słowa kluczowe",
|
||||
@@ -1841,8 +1841,6 @@
|
||||
"files_button": "Pliki",
|
||||
"info": "Info",
|
||||
"pinned_messages": {
|
||||
"empty": "Nie przypięto tu jeszcze niczego",
|
||||
"explainer": "Jeżeli masz uprawnienia, przejdź do menu dowolnej wiadomości i wybierz <b>Przypnij</b>, aby przyczepić ją tutaj.",
|
||||
"limits": {
|
||||
"other": "Możesz przypiąć do %(count)s widżetów"
|
||||
},
|
||||
@@ -2008,7 +2006,7 @@
|
||||
"joining": "Dołączanie…",
|
||||
"joining_room": "Dołączanie do pokoju…",
|
||||
"joining_space": "Dołączanie do przestrzeni…",
|
||||
"jump_read_marker": "Przeskocz do pierwszej nieprzeczytanej wiadomości.",
|
||||
"jump_read_marker": "Skocz do pierwszej nieprzeczytanej wiadomości.",
|
||||
"jump_to_bottom_button": "Przewiń do najnowszych wiadomości",
|
||||
"jump_to_date": "Przeskocz do daty",
|
||||
"jump_to_date_beginning": "Początek pokoju",
|
||||
@@ -2426,6 +2424,10 @@
|
||||
}
|
||||
},
|
||||
"settings": {
|
||||
"account": {
|
||||
"dialog_title": "<strong>Ustawienia:</strong> Konto",
|
||||
"title": "Konto"
|
||||
},
|
||||
"all_rooms_home": "Pokaż wszystkie pokoje na ekranie głównym",
|
||||
"all_rooms_home_description": "Wszystkie pokoje w których jesteś zostaną pokazane na ekranie głównym.",
|
||||
"always_show_message_timestamps": "Zawsze pokazuj znaczniki czasu wiadomości",
|
||||
@@ -2532,7 +2534,6 @@
|
||||
"error_share_msisdn_discovery": "Nie udało się udostępnić numeru telefonu",
|
||||
"identity_server_no_token": "Nie znaleziono tokena dostępu tożsamości",
|
||||
"identity_server_not_set": "Serwer tożsamości nie jest ustawiony",
|
||||
"incorrect_msisdn_verification": "Nieprawidłowy kod weryfikujący",
|
||||
"language_section": "Język",
|
||||
"msisdn_in_use": "Ten numer telefonu jest już zajęty",
|
||||
"msisdn_label": "Numer telefonu",
|
||||
@@ -3896,8 +3897,8 @@
|
||||
"disable_camera": "Wyłącz kamerę",
|
||||
"disable_microphone": "Wycisz mikrofon",
|
||||
"disabled_no_one_here": "Nie ma tu nikogo, do kogo można zadzwonić",
|
||||
"disabled_no_perms_start_video_call": "Nie posiadasz uprawnień do rozpoczęcia rozmowy wideo",
|
||||
"disabled_no_perms_start_voice_call": "Nie posiadasz uprawnień do rozpoczęcia rozmowy głosowej",
|
||||
"disabled_no_perms_start_video_call": "Nie masz uprawnień do rozpoczęcia rozmowy wideo",
|
||||
"disabled_no_perms_start_voice_call": "Nie masz uprawnień do rozpoczęcia rozmowy głosowej",
|
||||
"disabled_ongoing_call": "Rozmowa w toku",
|
||||
"element_call": "Element Call",
|
||||
"enable_camera": "Włącz kamerę",
|
||||
|
@@ -1196,8 +1196,6 @@
|
||||
"export_chat_button": "Exportar conversa",
|
||||
"files_button": "Arquivos",
|
||||
"pinned_messages": {
|
||||
"empty": "Nada fixado ainda",
|
||||
"explainer": "Caso você tenha a permissão para isso, abra o menu em qualquer mensagem e selecione <b>Fixar</b> para fixá-la aqui.",
|
||||
"limits": {
|
||||
"other": "Você pode fixar até %(count)s widgets"
|
||||
},
|
||||
@@ -1664,7 +1662,6 @@
|
||||
"error_revoke_msisdn_discovery": "Não foi possível revogar o compartilhamento do número de celular",
|
||||
"error_share_email_discovery": "Não foi possível compartilhar o endereço de e-mail",
|
||||
"error_share_msisdn_discovery": "Não foi possível compartilhar o número de celular",
|
||||
"incorrect_msisdn_verification": "Código de confirmação incorreto",
|
||||
"language_section": "Idioma e região",
|
||||
"msisdn_in_use": "Este número de telefone já está em uso",
|
||||
"msisdn_label": "Número de telefone",
|
||||
|
@@ -1773,8 +1773,6 @@
|
||||
"export_chat_button": "Экспорт чата",
|
||||
"files_button": "Файлы",
|
||||
"pinned_messages": {
|
||||
"empty": "Пока ничего не закреплено",
|
||||
"explainer": "Если у вас есть разрешения, откройте меню на любом сообщении и выберите <b>Закрепить</b>, чтобы поместить их сюда.",
|
||||
"limits": {
|
||||
"other": "Вы можете закрепить не более %(count)s виджетов"
|
||||
},
|
||||
@@ -2433,7 +2431,6 @@
|
||||
"error_share_msisdn_discovery": "Не удается предоставить общий доступ к номеру телефона",
|
||||
"identity_server_no_token": "Не найден токен доступа для идентификации",
|
||||
"identity_server_not_set": "Сервер идентификации не установлен",
|
||||
"incorrect_msisdn_verification": "Неверный код подтверждения",
|
||||
"language_section": "Язык и регион",
|
||||
"msisdn_in_use": "Этот номер телефона уже используется",
|
||||
"msisdn_label": "Номер телефона",
|
||||
|
@@ -1779,8 +1779,6 @@
|
||||
"export_chat_button": "Exportovať konverzáciu",
|
||||
"files_button": "Súbory",
|
||||
"pinned_messages": {
|
||||
"empty": "Zatiaľ nie je nič pripnuté",
|
||||
"explainer": "Ak máte oprávnenia, otvorte ponuku pri ľubovoľnej správe a výberom položky <b>Pripnúť</b> ich sem prilepíte.",
|
||||
"limits": {
|
||||
"other": "Môžete pripnúť iba %(count)s widgetov"
|
||||
},
|
||||
@@ -2437,7 +2435,6 @@
|
||||
"error_share_msisdn_discovery": "Nepodarilo sa zdieľanie telefónneho čísla",
|
||||
"identity_server_no_token": "Nenašiel sa prístupový token totožnosti",
|
||||
"identity_server_not_set": "Server totožnosti nie je nastavený",
|
||||
"incorrect_msisdn_verification": "Nesprávny overovací kód",
|
||||
"language_section": "Jazyk a región",
|
||||
"msisdn_in_use": "Toto telefónne číslo sa už používa",
|
||||
"msisdn_label": "Telefónne číslo",
|
||||
|
@@ -1680,8 +1680,6 @@
|
||||
"export_chat_button": "Eksportoni fjalosje",
|
||||
"files_button": "Kartela",
|
||||
"pinned_messages": {
|
||||
"empty": "Ende pa fiksuar gjë",
|
||||
"explainer": "Nëse keni leje, hapni menunë për çfarëdo mesazhi dhe përzgjidhni <b>Fiksoje</b>, për ta ngjitur këtu.",
|
||||
"limits": {
|
||||
"other": "Mundeni të fiksoni deri në %(count)s widget-e"
|
||||
},
|
||||
@@ -2300,7 +2298,6 @@
|
||||
"error_share_msisdn_discovery": "S’arrihet të ndahet numër telefoni",
|
||||
"identity_server_no_token": "S’u gjet token hyrjeje identiteti",
|
||||
"identity_server_not_set": "Shërbyes identitetesh i paujdisur",
|
||||
"incorrect_msisdn_verification": "Kod verifikimi i pasaktë",
|
||||
"language_section": "Gjuhë dhe rajon",
|
||||
"msisdn_in_use": "Ky numër telefoni është tashmë në përdorim",
|
||||
"msisdn_label": "Numër Telefoni",
|
||||
|
@@ -1791,8 +1791,6 @@
|
||||
"export_chat_button": "Exportera chatt",
|
||||
"files_button": "Filer",
|
||||
"pinned_messages": {
|
||||
"empty": "Inget fäst än",
|
||||
"explainer": "Om du har behörighet, öppna menyn på ett meddelande och välj <b>Fäst</b> för att fösta dem här.",
|
||||
"limits": {
|
||||
"other": "Du kan bara fästa upp till %(count)s widgets"
|
||||
},
|
||||
@@ -2449,7 +2447,6 @@
|
||||
"error_share_msisdn_discovery": "Kunde inte dela telefonnummer",
|
||||
"identity_server_no_token": "Ingen identitetsåtkomsttoken hittades",
|
||||
"identity_server_not_set": "Identitetsserver inte inställd",
|
||||
"incorrect_msisdn_verification": "Fel verifieringskod",
|
||||
"language_section": "Språk och region",
|
||||
"msisdn_in_use": "Detta telefonnummer används redan",
|
||||
"msisdn_label": "Telefonnummer",
|
||||
|
@@ -1735,8 +1735,6 @@
|
||||
"export_chat_button": "Експортувати бесіду",
|
||||
"files_button": "Файли",
|
||||
"pinned_messages": {
|
||||
"empty": "Наразі нічого не закріплено",
|
||||
"explainer": "Якщо маєте дозвіл, відкрийте меню будь-якого повідомлення й натисніть <b>Закріпити</b>, щоб додати його сюди.",
|
||||
"limits": {
|
||||
"other": "Закріпити можна до %(count)s віджетів"
|
||||
},
|
||||
@@ -2374,7 +2372,6 @@
|
||||
"error_share_msisdn_discovery": "Не вдалося надіслати телефонний номер",
|
||||
"identity_server_no_token": "Токен доступу до ідентифікації не знайдено",
|
||||
"identity_server_not_set": "Сервер ідентифікації не налаштовано",
|
||||
"incorrect_msisdn_verification": "Неправильний код перевірки",
|
||||
"language_section": "Мова та регіон",
|
||||
"msisdn_in_use": "Цей телефонний номер вже використовується",
|
||||
"msisdn_label": "Телефонний номер",
|
||||
|
@@ -1583,8 +1583,6 @@
|
||||
"add_integrations": "Thêm các widget, bridge và bot",
|
||||
"export_chat_button": "Xuất trò chuyện",
|
||||
"pinned_messages": {
|
||||
"empty": "Chưa có gì được ghim",
|
||||
"explainer": "Nếu bạn có quyền, hãy mở menu trên bất kỳ tin nhắn nào và chọn Ghim <b>Pin</b> để dán chúng vào đây.",
|
||||
"limits": {
|
||||
"other": "Bạn chỉ có thể ghim tối đa %(count)s widget"
|
||||
},
|
||||
@@ -2174,7 +2172,6 @@
|
||||
"error_share_msisdn_discovery": "Không thể chia sẻ số điện thoại",
|
||||
"identity_server_no_token": "Không tìm thấy mã thông báo danh tính",
|
||||
"identity_server_not_set": "Máy chủ định danh chưa được đặt",
|
||||
"incorrect_msisdn_verification": "Mã xác minh không chính xác",
|
||||
"language_section": "Ngôn ngữ và khu vực",
|
||||
"msisdn_in_use": "Số điện thoại này đã được sử dụng",
|
||||
"msisdn_label": "Số điện thoại",
|
||||
|
@@ -1631,8 +1631,6 @@
|
||||
"export_chat_button": "导出聊天",
|
||||
"files_button": "文件",
|
||||
"pinned_messages": {
|
||||
"empty": "尚无固定任何东西",
|
||||
"explainer": "如果你拥有权限,请打开任何消息的菜单并选择<b>固定</b>将它们粘贴至此。",
|
||||
"limits": {
|
||||
"other": "你仅能固定 %(count)s 个挂件"
|
||||
},
|
||||
@@ -2202,7 +2200,6 @@
|
||||
"error_share_msisdn_discovery": "无法共享电话号码",
|
||||
"identity_server_no_token": "找不到身份访问令牌",
|
||||
"identity_server_not_set": "身份服务器未设置",
|
||||
"incorrect_msisdn_verification": "验证码错误",
|
||||
"language_section": "语言与地区",
|
||||
"msisdn_in_use": "此电话号码已被使用",
|
||||
"msisdn_label": "电话号码",
|
||||
|
@@ -1738,8 +1738,6 @@
|
||||
"export_chat_button": "匯出聊天",
|
||||
"files_button": "檔案",
|
||||
"pinned_messages": {
|
||||
"empty": "尚未釘選任何東西",
|
||||
"explainer": "如果您有權限,請開啟任何訊息的選單,並選取<b>釘選</b>以將它們固定到這裡。",
|
||||
"limits": {
|
||||
"other": "您最多只能釘選 %(count)s 個小工具"
|
||||
},
|
||||
@@ -2376,7 +2374,6 @@
|
||||
"error_share_msisdn_discovery": "無法分享電話號碼",
|
||||
"identity_server_no_token": "找不到身分存取權杖",
|
||||
"identity_server_not_set": "身分伺服器未設定",
|
||||
"incorrect_msisdn_verification": "驗證碼錯誤",
|
||||
"language_section": "語言與區域",
|
||||
"msisdn_in_use": "這個電話號碼已被使用",
|
||||
"msisdn_label": "電話號碼",
|
||||
|
@@ -14,13 +14,17 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { MatrixEvent, EventType, M_POLL_START } from "matrix-js-sdk/src/matrix";
|
||||
import { MatrixEvent, EventType, M_POLL_START, MatrixClient, EventTimeline } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { canPinEvent, isContentActionable } from "./EventUtils";
|
||||
import SettingsStore from "../settings/SettingsStore";
|
||||
import { ReadPinsEventId } from "../components/views/right_panel/types";
|
||||
|
||||
export default class PinningUtils {
|
||||
/**
|
||||
* Event types that may be pinned.
|
||||
*/
|
||||
public static pinnableEventTypes: (EventType | string)[] = [
|
||||
public static readonly PINNABLE_EVENT_TYPES: (EventType | string)[] = [
|
||||
EventType.RoomMessage,
|
||||
M_POLL_START.name,
|
||||
M_POLL_START.altName,
|
||||
@@ -33,9 +37,80 @@ export default class PinningUtils {
|
||||
*/
|
||||
public static isPinnable(event: MatrixEvent): boolean {
|
||||
if (!event) return false;
|
||||
if (!this.pinnableEventTypes.includes(event.getType())) return false;
|
||||
if (!this.PINNABLE_EVENT_TYPES.includes(event.getType())) return false;
|
||||
if (event.isRedacted()) return false;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given event is pinned.
|
||||
* @param matrixClient
|
||||
* @param mxEvent
|
||||
*/
|
||||
public static isPinned(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
|
||||
const room = matrixClient.getRoom(mxEvent.getRoomId());
|
||||
if (!room) return false;
|
||||
|
||||
const pinnedEvent = room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(EventType.RoomPinnedEvents, "");
|
||||
if (!pinnedEvent) return false;
|
||||
const content = pinnedEvent.getContent();
|
||||
return content.pinned && Array.isArray(content.pinned) && content.pinned.includes(mxEvent.getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if the given event may be pinned or unpinned.
|
||||
* @param matrixClient
|
||||
* @param mxEvent
|
||||
*/
|
||||
public static canPinOrUnpin(matrixClient: MatrixClient, mxEvent: MatrixEvent): boolean {
|
||||
if (!SettingsStore.getValue("feature_pinning")) return false;
|
||||
if (!isContentActionable(mxEvent)) return false;
|
||||
|
||||
const room = matrixClient.getRoom(mxEvent.getRoomId());
|
||||
if (!room) return false;
|
||||
|
||||
return Boolean(
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.mayClientSendStateEvent(EventType.RoomPinnedEvents, matrixClient) && canPinEvent(mxEvent),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Pin or unpin the given event.
|
||||
* @param matrixClient
|
||||
* @param mxEvent
|
||||
*/
|
||||
public static async pinOrUnpinEvent(matrixClient: MatrixClient, mxEvent: MatrixEvent): Promise<void> {
|
||||
const room = matrixClient.getRoom(mxEvent.getRoomId());
|
||||
if (!room) return;
|
||||
|
||||
const eventId = mxEvent.getId();
|
||||
if (!eventId) return;
|
||||
|
||||
// Get the current pinned events of the room
|
||||
const pinnedIds: Array<string> =
|
||||
room
|
||||
.getLiveTimeline()
|
||||
.getState(EventTimeline.FORWARDS)
|
||||
?.getStateEvents(EventType.RoomPinnedEvents, "")
|
||||
?.getContent().pinned || [];
|
||||
|
||||
// If the event is already pinned, unpin it
|
||||
if (pinnedIds.includes(eventId)) {
|
||||
pinnedIds.splice(pinnedIds.indexOf(eventId), 1);
|
||||
} else {
|
||||
// Otherwise, pin it
|
||||
pinnedIds.push(eventId);
|
||||
await matrixClient.setRoomAccountData(room.roomId, ReadPinsEventId, {
|
||||
event_ids: [...(room.getAccountData(ReadPinsEventId)?.getContent()?.event_ids || []), eventId],
|
||||
});
|
||||
}
|
||||
await matrixClient.sendStateEvent(room.roomId, EventType.RoomPinnedEvents, { pinned: pinnedIds }, "");
|
||||
}
|
||||
}
|
||||
|
@@ -16,16 +16,11 @@ limitations under the License.
|
||||
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import fetchMockJest from "fetch-mock-jest";
|
||||
import {
|
||||
ProvideCryptoSetupExtensions,
|
||||
SecretStorageKeyDescription,
|
||||
} from "@matrix-org/react-sdk-module-api/lib/lifecycles/CryptoSetupExtensions";
|
||||
|
||||
import { advanceDateAndTime, stubClient } from "./test-utils";
|
||||
import { IMatrixClientPeg, MatrixClientPeg as peg } from "../src/MatrixClientPeg";
|
||||
import SettingsStore from "../src/settings/SettingsStore";
|
||||
import { SettingLevel } from "../src/settings/SettingLevel";
|
||||
import { ModuleRunner } from "../src/modules/ModuleRunner";
|
||||
|
||||
jest.useFakeTimers();
|
||||
|
||||
@@ -78,78 +73,6 @@ describe("MatrixClientPeg", () => {
|
||||
expect(peg.userRegisteredWithinLastHours(24)).toBe(false);
|
||||
});
|
||||
|
||||
describe(".start extensions", () => {
|
||||
let testPeg: IMatrixClientPeg;
|
||||
|
||||
beforeEach(() => {
|
||||
// instantiate a MatrixClientPegClass instance, with a new MatrixClient
|
||||
testPeg = new PegClass();
|
||||
fetchMockJest.get("http://example.com/_matrix/client/versions", {});
|
||||
});
|
||||
|
||||
describe("cryptoSetup extension", () => {
|
||||
it("should call default cryptoSetup.getDehydrationKeyCallback", async () => {
|
||||
const mockCryptoSetup = {
|
||||
SHOW_ENCRYPTION_SETUP_UI: true,
|
||||
examineLoginResponse: jest.fn(),
|
||||
persistCredentials: jest.fn(),
|
||||
getSecretStorageKey: jest.fn(),
|
||||
createSecretStorageKey: jest.fn(),
|
||||
catchAccessSecretStorageError: jest.fn(),
|
||||
setupEncryptionNeeded: jest.fn(),
|
||||
getDehydrationKeyCallback: jest.fn().mockReturnValue(null),
|
||||
} as ProvideCryptoSetupExtensions;
|
||||
|
||||
// Ensure we have an instance before we set up spies
|
||||
const instance = ModuleRunner.instance;
|
||||
jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup);
|
||||
|
||||
testPeg.replaceUsingCreds({
|
||||
accessToken: "SEKRET",
|
||||
homeserverUrl: "http://example.com",
|
||||
userId: "@user:example.com",
|
||||
deviceId: "TEST_DEVICE_ID",
|
||||
});
|
||||
|
||||
expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("should call overridden cryptoSetup.getDehydrationKeyCallback", async () => {
|
||||
const mockDehydrationKeyCallback = () => Uint8Array.from([0x11, 0x22, 0x33]);
|
||||
|
||||
const mockCryptoSetup = {
|
||||
SHOW_ENCRYPTION_SETUP_UI: true,
|
||||
examineLoginResponse: jest.fn(),
|
||||
persistCredentials: jest.fn(),
|
||||
getSecretStorageKey: jest.fn(),
|
||||
createSecretStorageKey: jest.fn(),
|
||||
catchAccessSecretStorageError: jest.fn(),
|
||||
setupEncryptionNeeded: jest.fn(),
|
||||
getDehydrationKeyCallback: jest.fn().mockReturnValue(mockDehydrationKeyCallback),
|
||||
} as ProvideCryptoSetupExtensions;
|
||||
|
||||
// Ensure we have an instance before we set up spies
|
||||
const instance = ModuleRunner.instance;
|
||||
jest.spyOn(instance.extensions, "cryptoSetup", "get").mockReturnValue(mockCryptoSetup);
|
||||
|
||||
testPeg.replaceUsingCreds({
|
||||
accessToken: "SEKRET",
|
||||
homeserverUrl: "http://example.com",
|
||||
userId: "@user:example.com",
|
||||
deviceId: "TEST_DEVICE_ID",
|
||||
});
|
||||
expect(mockCryptoSetup.getDehydrationKeyCallback).toHaveBeenCalledTimes(1);
|
||||
|
||||
const client = testPeg.get();
|
||||
const dehydrationKey = await client?.cryptoCallbacks.getDehydrationKey!(
|
||||
{} as SecretStorageKeyDescription,
|
||||
(key: Uint8Array) => true,
|
||||
);
|
||||
expect(dehydrationKey).toEqual(Uint8Array.from([0x11, 0x22, 0x33]));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe(".start", () => {
|
||||
let testPeg: IMatrixClientPeg;
|
||||
|
||||
|