diff --git a/.prettierrc.js b/.prettierrc.cjs similarity index 100% rename from .prettierrc.js rename to .prettierrc.cjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 043e2c97ad..ec355249f9 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/package.json b/package.json index 52d9131786..a5479d9ec5 100644 --- a/package.json +++ b/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", diff --git a/playwright/e2e/composer/RTE.spec.ts b/playwright/e2e/composer/RTE.spec.ts index 53599d5320..47243e1c82 100644 --- a/playwright/e2e/composer/RTE.spec.ts +++ b/playwright/e2e/composer/RTE.spec.ts @@ -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(); + }); + }); }); }); diff --git a/playwright/e2e/pinned-messages/index.ts b/playwright/e2e/pinned-messages/index.ts new file mode 100644 index 0000000000..5e61b11e85 --- /dev/null +++ b/playwright/e2e/pinned-messages/index.ts @@ -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 }; diff --git a/playwright/e2e/pinned-messages/pinned-messages.spec.ts b/playwright/e2e/pinned-messages/pinned-messages.spec.ts new file mode 100644 index 0000000000..53f657ea7f --- /dev/null +++ b/playwright/e2e/pinned-messages/pinned-messages.spec.ts @@ -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); + }); +}); diff --git a/playwright/e2e/user-menu/user-menu.spec.ts b/playwright/e2e/user-menu/user-menu.spec.ts index d727ae7b12..84e849c156 100644 --- a/playwright/e2e/user-menu/user-menu.spec.ts +++ b/playwright/e2e/user-menu/user-menu.spec.ts @@ -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"); }); }); diff --git a/playwright/plugins/homeserver/synapse/index.ts b/playwright/plugins/homeserver/synapse/index.ts index b48d9311bb..4da7c8a326 100644 --- a/playwright/plugins/homeserver/synapse/index.ts +++ b/playwright/plugins/homeserver/synapse/index.ts @@ -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> { const templateDir = path.join(__dirname, "templates", opts.template); diff --git a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png index 5b52d00d77..dd8b24beea 100644 Binary files a/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png and b/playwright/snapshots/app-loading/feature-detection.spec.ts/unsupported-browser-CompatibilityView-linux.png differ diff --git a/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png index 9a877e0718..9d301e7919 100644 Binary files a/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png and b/playwright/snapshots/chat-export/html-export.spec.ts/html-export-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-empty-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-empty-linux.png new file mode 100644 index 0000000000..28099d338c Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-empty-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png new file mode 100644 index 0000000000..82666b0d95 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-2-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png new file mode 100644 index 0000000000..98e804d897 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-messages-list-messages-3-linux.png differ diff --git a/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/unpin-all-dialog-linux.png b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/unpin-all-dialog-linux.png new file mode 100644 index 0000000000..e6f1005395 Binary files /dev/null and b/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/unpin-all-dialog-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png index 9992923226..3936b29fdf 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-bubble-layout-linux.png differ diff --git a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png index 050a82a8af..c1007f06e7 100644 Binary files a/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png and b/playwright/snapshots/polls/polls.spec.ts/ThreadView-with-a-poll-on-group-layout-linux.png differ diff --git a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png index 62316fd312..f21145e02c 100644 Binary files a/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png and b/playwright/snapshots/release-announcement/releaseAnnouncement.spec.ts/release-announcement-Threads-Activity-Centre-linux.png differ diff --git a/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png index d18266534d..cc1ea27dfc 100644 Binary files a/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png and b/playwright/snapshots/right-panel/notification-panel.spec.ts/empty-linux.png differ diff --git a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png index 7271a50cf9..bb10e28aba 100644 Binary files a/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png and b/playwright/snapshots/room/room-header.spec.ts/room-header-video-room-linux.png differ diff --git a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png index 17cf808383..ec4a0f030c 100644 Binary files a/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png and b/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png index f891abaa16..76a9308b35 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/appearance-tab-linux.png differ diff --git a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png index edbc19aa22..bf47c91388 100644 Binary files a/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png and b/playwright/snapshots/settings/appearance-user-settings-tab/appearance-user-settings-tab.spec.ts/window-12px-linux.png differ diff --git a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png index 927af90d00..56745f9a19 100644 Binary files a/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png and b/playwright/snapshots/settings/preferences-user-settings-tab.spec.ts/Preferences-user-settings-tab-should-be-rendered-properly-1-linux.png differ diff --git a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png index 3528d129be..ea9e428244 100644 Binary files a/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png and b/playwright/snapshots/spaces/spaces.spec.ts/space-panel-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png index 4c403627fa..7f76175fcf 100644 Binary files a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-button-expanded-linux.png differ diff --git a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png index 3dfe328de2..7f76175fcf 100644 Binary files a/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png and b/playwright/snapshots/spaces/threads-activity-centre/threadsActivityCentre.spec.ts/tac-hovered-expanded-linux.png differ diff --git a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png index d30d8e98a8..ab4ec4ec3b 100644 Binary files a/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png and b/playwright/snapshots/threads/threads.spec.ts/Reply-to-the-location-on-ThreadView-linux.png differ diff --git a/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png new file mode 100644 index 0000000000..f713a7dc98 Binary files /dev/null and b/playwright/snapshots/user-menu/user-menu.spec.ts/user-menu-linux.png differ diff --git a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png index ea1fa63bde..3112b0fcf2 100644 Binary files a/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png and b/playwright/snapshots/user-onboarding/user-onboarding-new.spec.ts/User-Onboarding-new-user-page-is-shown-and-preference-exists-1-linux.png differ diff --git a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png index b6883c218e..2cedb4d1f5 100644 Binary files a/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png and b/playwright/snapshots/user-view/user-view.spec.ts/user-info-linux.png differ diff --git a/res/css/_common.pcss b/res/css/_common.pcss index de698e2324..605ad41b81 100644 --- a/res/css/_common.pcss +++ b/res/css/_common.pcss @@ -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 { diff --git a/res/css/_components.pcss b/res/css/_components.pcss index 85ac596d08..96c285bc0a 100644 --- a/res/css/_components.pcss +++ b/res/css/_components.pcss @@ -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"; diff --git a/res/css/components/views/settings/_EmailAddressesPhoneNumbers.pcss b/res/css/components/views/settings/_AddRemoveThreepids.pcss similarity index 72% rename from res/css/components/views/settings/_EmailAddressesPhoneNumbers.pcss rename to res/css/components/views/settings/_AddRemoveThreepids.pcss index 76dfc2d73b..0e9ef83ae7 100644 --- a/res/css/components/views/settings/_EmailAddressesPhoneNumbers.pcss +++ b/res/css/components/views/settings/_AddRemoveThreepids.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; +} diff --git a/res/css/structures/_ToastContainer.pcss b/res/css/structures/_ToastContainer.pcss index 65a171b496..f8f15bca40 100644 --- a/res/css/structures/_ToastContainer.pcss +++ b/res/css/structures/_ToastContainer.pcss @@ -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; } diff --git a/res/css/structures/_UserMenu.pcss b/res/css/structures/_UserMenu.pcss index 5f8a6a70a1..c60f6c5850 100644 --- a/res/css/structures/_UserMenu.pcss +++ b/res/css/structures/_UserMenu.pcss @@ -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; diff --git a/res/css/views/context_menus/_MessageContextMenu.pcss b/res/css/views/context_menus/_MessageContextMenu.pcss index be113c770f..28529eabf9 100644 --- a/res/css/views/context_menus/_MessageContextMenu.pcss +++ b/res/css/views/context_menus/_MessageContextMenu.pcss @@ -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 { diff --git a/res/css/views/dialogs/_UnpinAllDialog.pcss b/res/css/views/dialogs/_UnpinAllDialog.pcss new file mode 100644 index 0000000000..fb05809523 --- /dev/null +++ b/res/css/views/dialogs/_UnpinAllDialog.pcss @@ -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%; + } + } +} diff --git a/res/css/views/right_panel/_BaseCard.pcss b/res/css/views/right_panel/_BaseCard.pcss index 47092c124f..12a0abda1b 100644 --- a/res/css/views/right_panel/_BaseCard.pcss +++ b/res/css/views/right_panel/_BaseCard.pcss @@ -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 { diff --git a/res/css/views/right_panel/_PinnedMessagesCard.pcss b/res/css/views/right_panel/_PinnedMessagesCard.pcss index 5cdafcf7c5..23e23bae85 100644 --- a/res/css/views/right_panel/_PinnedMessagesCard.pcss +++ b/res/css/views/right_panel/_PinnedMessagesCard.pcss @@ -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; } diff --git a/res/css/views/right_panel/_UserInfo.pcss b/res/css/views/right_panel/_UserInfo.pcss index a2e156e0e5..186eb78a32 100644 --- a/res/css/views/right_panel/_UserInfo.pcss +++ b/res/css/views/right_panel/_UserInfo.pcss @@ -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; } diff --git a/res/css/views/rooms/_PinnedEventTile.pcss b/res/css/views/rooms/_PinnedEventTile.pcss index e755c3a71d..b37e3724fc 100644 --- a/res/css/views/rooms/_PinnedEventTile.pcss +++ b/res/css/views/rooms/_PinnedEventTile.pcss @@ -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; + } } } } diff --git a/res/css/views/user-onboarding/_UserOnboardingTask.pcss b/res/css/views/user-onboarding/_UserOnboardingTask.pcss index 05232da8c5..6bb54207b7 100644 --- a/res/css/views/user-onboarding/_UserOnboardingTask.pcss +++ b/res/css/views/user-onboarding/_UserOnboardingTask.pcss @@ -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; } diff --git a/src/AddThreepid.ts b/src/AddThreepid.ts index ee244f2c92..9549e6d08b 100644 --- a/src/AddThreepid.ts +++ b/src/AddThreepid.ts @@ -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 diff --git a/src/DraftCleaner.ts b/src/DraftCleaner.ts index 5e6c1cbae7..cede027f22 100644 --- a/src/DraftCleaner.ts +++ b/src/DraftCleaner.ts @@ -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}`); diff --git a/src/MatrixClientPeg.ts b/src/MatrixClientPeg.ts index 646f9eec62..775897bb1c 100644 --- a/src/MatrixClientPeg.ts +++ b/src/MatrixClientPeg.ts @@ -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)); diff --git a/src/SecurityManager.ts b/src/SecurityManager.ts index c1c4e7a72b..faf915e3c8 100644 --- a/src/SecurityManager.ts +++ b/src/SecurityManager.ts @@ -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 = {}; let secretStorageKeyInfo: Record = {}; 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 { - 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 => { - 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 => { - 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 { - 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, }; /** diff --git a/src/components/structures/RightPanel.tsx b/src/components/structures/RightPanel.tsx index 9e228de611..8f1ba7aecf 100644 --- a/src/components/structures/RightPanel.tsx +++ b/src/components/structures/RightPanel.tsx @@ -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"; diff --git a/src/components/structures/RoomView.tsx b/src/components/structures/RoomView.tsx index 9c6ffb857f..70ee16b542 100644 --- a/src/components/structures/RoomView.tsx +++ b/src/components/structures/RoomView.tsx @@ -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, diff --git a/src/components/structures/auth/Login.tsx b/src/components/structures/auth/Login.tsx index 5fadde7cbe..16f04f84d6 100644 --- a/src/components/structures/auth/Login.tsx +++ b/src/components/structures/auth/Login.tsx @@ -485,6 +485,7 @@ export default class LoginComponent extends React.PureComponent 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 {this.renderLoginComponentForFlows()} {footer} diff --git a/src/components/structures/grouper/MainGrouper.tsx b/src/components/structures/grouper/MainGrouper.tsx index 28a62d7ac9..b1137c1729 100644 --- a/src/components/structures/grouper/MainGrouper.tsx +++ b/src/components/structures/grouper/MainGrouper.tsx @@ -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); } diff --git a/src/components/views/auth/InteractiveAuthEntryComponents.tsx b/src/components/views/auth/InteractiveAuthEntryComponents.tsx index 7bed60d603..d54b52c1a0 100644 --- a/src/components/views/auth/InteractiveAuthEntryComponents.tsx +++ b/src/components/views/auth/InteractiveAuthEntryComponents.tsx @@ -833,7 +833,7 @@ export class SSOAuthEntry extends React.Component { - 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 { }; 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({}); } }; diff --git a/src/components/views/context_menus/MessageContextMenu.tsx b/src/components/views/context_menus/MessageContextMenu.tsx index 801ab0b023..2d5a81a89b 100644 --- a/src/components/views/context_menus/MessageContextMenu.tsx +++ b/src/components/views/context_menus/MessageContextMenu.tsx @@ -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 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 }; 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 ); } - let pinButton: JSX.Element | undefined; - if (contentActionable && this.state.canPin) { - pinButton = ( - - ); - } - // This is specifically not behind the developerMode flag to give people insight into the Matrix const viewSourceButton = ( ); } + let pinButton: JSX.Element | undefined; + if (rightClick && this.state.canPin) { + const isPinned = PinningUtils.isPinned(MatrixClientPeg.safeGet(), this.props.mxEvent); + pinButton = ( + + ); + } + let viewInRoomButton: JSX.Element | undefined; if (isThreadRootEvent) { viewInRoomButton = ( @@ -671,13 +645,14 @@ export default class MessageContextMenu extends React.Component } let quickItemsList: JSX.Element | undefined; - if (editButton || replyButton || reactButton) { + if (editButton || replyButton || reactButton || pinButton) { quickItemsList = ( {reactButton} {replyButton} {replyInThreadButton} {editButton} + {pinButton} ); } @@ -688,7 +663,6 @@ export default class MessageContextMenu extends React.Component {openInMapSiteButton} {endPollButton} {forwardButton} - {pinButton} {permalinkButton} {reportEventButton} {externalURLButton} diff --git a/src/components/views/dialogs/UnpinAllDialog.tsx b/src/components/views/dialogs/UnpinAllDialog.tsx new file mode 100644 index 0000000000..ef7439d858 --- /dev/null +++ b/src/components/views/dialogs/UnpinAllDialog.tsx @@ -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 ( + + {_t("right_panel|pinned_messages|unpin_all|content")} +
+ + +
+
+ ); +} diff --git a/src/components/views/elements/SSOButtons.tsx b/src/components/views/elements/SSOButtons.tsx index c0647e504f..be795ee50a 100644 --- a/src/components/views/elements/SSOButtons.tsx +++ b/src/components/views/elements/SSOButtons.tsx @@ -151,11 +151,20 @@ interface IProps { fragmentAfterLogin?: string; primary?: boolean; action?: SSOAction; + disabled?: boolean; } const MAX_PER_ROW = 6; -const SSOButtons: React.FC = ({ matrixClient, flow, loginType, fragmentAfterLogin, primary, action }) => { +const SSOButtons: React.FC = ({ + 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 = ({ matrixClient, flow, loginType, fragmentA primary={primary} action={action} flow={flow} + disabled={disabled} /> ); diff --git a/src/components/views/elements/ServerPicker.tsx b/src/components/views/elements/ServerPicker.tsx index c6f505e804..fd489bd67b 100644 --- a/src/components/views/elements/ServerPicker.tsx +++ b/src/components/views/elements/ServerPicker.tsx @@ -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 = ({ title, dialogTitle, serverConfig, onServerConfigChange }) => { +const ServerPicker: React.FC = ({ title, dialogTitle, serverConfig, onServerConfigChange, disabled }) => { const disableCustomUrls = SdkConfig.get("disable_custom_urls"); let editBtn; @@ -68,7 +69,7 @@ const ServerPicker: React.FC = ({ title, dialogTitle, serverConfig, onSe }); }; editBtn = ( - + {_t("action|edit")} ); diff --git a/src/components/views/messages/MessageActionBar.tsx b/src/components/views/messages/MessageActionBar.tsx index 25547c7836..eebcb96b1e 100644 --- a/src/components/views/messages/MessageActionBar.tsx +++ b/src/components/views/messages/MessageActionBar.tsx @@ -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 => { + // 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 + {isPinned ? : } + , + ); + } + const cancelSendingButton = ( { const [pinnedEvents, setPinnedEvents] = useState(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 { 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 => { const [readPinnedEvents, setReadPinnedEvents] = useState>(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 => { return readPinnedEvents; }; -const PinnedMessagesCard: React.FC = ({ 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 | 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 => { 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 = ({ 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 = ({ 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 = ( -
-
- {/* XXX: We reuse the classes for simplicity, but deliberately not the components for non-interactivity. */} -
-
- -
-
- -
-
- -
-
- - - {_t("right_panel|pinned_messages|empty")} - - {_t( - "right_panel|pinned_messages|explainer", - {}, - { - b: (sub) => {sub}, - }, - )} -
-
+ ); } else if (pinnedEvents?.length) { - const onUnpinClicked = async (event: MatrixEvent): Promise => { - 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) => ( - onUnpinClicked(ev) : undefined} - permalinkCreator={permalinkCreator} - /> - )); + content = ( + + ); } else { content = ; } @@ -223,7 +256,7 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator header={
- {_t("right_panel|pinned_messages|title")} + {_t("right_panel|pinned_messages|header", { count: pinnedEventIds.length })}
} @@ -240,6 +273,79 @@ const PinnedMessagesCard: React.FC = ({ room, onClose, permalinkCreator ); -}; +} -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 => { + Modal.createDialog(UnpinAllDialog, { + roomId: room.roomId, + matrixClient, + }); + }, [room, matrixClient]); + + return ( + <> +
+ {events.reverse().map((event, i) => ( + <> + + {/* Add a separator if this isn't the last pinned message */} + {events.length - 1 !== i && ( + + )} + + ))} +
+ {canUnpin && ( +
+ +
+ )} + + ); +} diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index a4a73b495f..b99c1c401e 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -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 { private dispatcherRef?: string; private messageComposerInput = createRef(); @@ -129,11 +139,32 @@ export class MessageComposer extends React.Component { public constructor(props: IProps, context: React.ContextType) { 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("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 { showStickersButton: SettingsStore.getValue("MessageComposerInput.showStickersButton"), showPollsButton: SettingsStore.getValue("MessageComposerInput.showPollsButton"), showVoiceBroadcastButton: SettingsStore.getValue(Features.VoiceBroadcast), - isWysiwygLabEnabled: SettingsStore.getValue("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 { 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 { return this._voiceRecording; } @@ -265,6 +342,8 @@ export class MessageComposer extends React.Component { 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; } diff --git a/src/components/views/rooms/PinnedEventTile.tsx b/src/components/views/rooms/PinnedEventTile.tsx index 6c63efc352..5fb9c07f45 100644 --- a/src/components/views/rooms/PinnedEventTile.tsx +++ b/src/components/views/rooms/PinnedEventTile.tsx @@ -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 { - public static contextType = MatrixClientContext; - public declare context: React.ContextType; + const isInThread = Boolean(event.threadRootId); + const displayThreadInfo = !event.isThreadRoot && isInThread; - private onTileClicked = (): void => { - dis.dispatch({ - 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>(); - 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 = ( - - ); - } - - return ( -
+ return ( +
+
- - - {this.props.event.sender?.name || sender} - - - {unpinButton} - -
- {}} // we need to give this, apparently - permalinkCreator={this.props.permalinkCreator} - replacingEventId={this.props.event.replacingEventId()} - /> -
- -
- - {formatDate(new Date(this.props.event.getTs()))} - - - - {_t("common|view_message")} - -
- ); - } +
+
+ + {event.sender?.name || sender} + + +
+ {}} // we need to give this, apparently + permalinkCreator={permalinkCreator} + replacingEventId={event.replacingEventId()} + /> + {displayThreadInfo && ( +
+ + {_t( + "right_panel|pinned_messages|reply_thread", + {}, + { + link: (sub) => ( + + ), + }, + )} +
+ )} +
+
+ ); +} + +/** + * 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({ + 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 => { + 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({ + 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 ( + + + + } + > + + {canUnpin && } + {forwardableEvent && } + {canRedact && ( + <> + + + + )} + + ); } diff --git a/src/components/views/rooms/RoomHeader.tsx b/src/components/views/rooms/RoomHeader.tsx index c3e5ad78ae..fcde852046 100644 --- a/src/components/views/rooms/RoomHeader.tsx +++ b/src/components/views/rooms/RoomHeader.tsx @@ -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 ( <> @@ -325,14 +334,13 @@ export default function RoomHeader({ })} {isViewingCall && } - {((isConnectedToCall && isViewingCall) || isVideoRoom(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({ + + {showChatButton && } + = ({ searchInfo, isRoomEncrypted, onSe
- {searchInfo - ? _t( - "room|search|summary", - { count: searchInfo.count ?? 0 }, - { query: () => {searchInfo.term} }, - ) - : undefined} + {searchInfo?.count !== undefined ? ( + _t( + "room|search|summary", + { count: searchInfo.count }, + { query: () => {searchInfo.term} }, + ) + ) : ( + + )}
diff --git a/src/components/views/settings/AddRemoveThreepids.tsx b/src/components/views/settings/AddRemoveThreepids.tsx new file mode 100644 index 0000000000..242afd272c --- /dev/null +++ b/src/components/views/settings/AddRemoveThreepids.tsx @@ -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 = ({ mode, threepid, onChange, disabled }) => { + const [isConfirming, setIsConfirming] = useState(false); + const client = useMatrixClientContext(); + const bindTask = useRef(); + + 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) => { + setVerificationCode(e.target.value); + }, []); + + if (isConfirming) { + return ( +
+ + {threepid.medium === ThreepidMedium.Email + ? _t("settings|general|remove_email_prompt", { email: threepid.address }) + : _t("settings|general|remove_msisdn_prompt", { phone: threepid.address })} + + + {_t("action|remove")} + + + {_t("action|cancel")} + +
+ ); + } + + if (isVerifyingBind) { + if (threepid.medium === ThreepidMedium.Email) { + return ( +
+ + {_t("settings|general|discovery_email_verification_instructions")} + + + {_t("action|complete")} + +
+ ); + } else { + return ( +
+ + {_t("settings|general|msisdn_verification_instructions")} + +
+ + +
+ ); + } + } + + return ( +
+ {threepid.address} + + {mode === "hs" ? _t("action|remove") : threepid.bound ? _t("action|revoke") : _t("action|share")} + +
+ ); +}; + +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(); + 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) => { + 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) => { + setVerificationCodeInput(e.target.value); + }, []); + + if (isVerifying && medium === "email") { + return ( +
+
{_t("settings|general|add_email_instructions")}
+ + {_t("action|continue")} + +
+ ); + } else if (isVerifying) { + return ( +
+
+ {_t("settings|general|add_msisdn_instructions", { msisdn: sentToMsisdn })} +
+
+
+ + + {_t("action|continue")} + + +
+ ); + } + + const phoneCountry = + medium === "msisdn" ? ( + + ) : undefined; + + return ( +
+ + + {_t("action|add")} + + + ); +}; + +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 = ({ + mode, + medium, + threepids, + disabled, + onChange, + isLoading, +}) => { + if (isLoading) { + return ; + } + + const existingEmailElements = threepids.map((e) => { + return ; + }); + + return ( + <> + {existingEmailElements} + {mode === "hs" && } + + ); +}; diff --git a/src/components/views/settings/UserPersonalInfoSettings.tsx b/src/components/views/settings/UserPersonalInfoSettings.tsx index 8e5880a517..63925424aa 100644 --- a/src/components/views/settings/UserPersonalInfoSettings.tsx +++ b/src/components/views/settings/UserPersonalInfoSettings.tsx @@ -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 = 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 = error={_t("settings|general|unable_to_load_emails")} loadingState={loadingState} > - @@ -116,10 +120,13 @@ export const UserPersonalInfoSettings: React.FC = error={_t("settings|general|unable_to_load_msisdns")} loadingState={loadingState} > - diff --git a/src/components/views/settings/account/EmailAddresses.tsx b/src/components/views/settings/account/EmailAddresses.tsx deleted file mode 100644 index b18f3de61d..0000000000 --- a/src/components/views/settings/account/EmailAddresses.tsx +++ /dev/null @@ -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 { - 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 ( -
- - {_t("settings|general|remove_email_prompt", { email: this.props.email.address })} - - - {_t("action|remove")} - - - {_t("action|cancel")} - -
- ); - } - - return ( -
- - {this.props.email.address} - - - {_t("action|remove")} - -
- ); - } -} - -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 { - 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): 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 ( - - ); - }); - - let addButton = ( - - {_t("action|add")} - - ); - if (this.state.verifying) { - addButton = ( -
-
{_t("settings|general|add_email_instructions")}
- - {_t("action|continue")} - -
- ); - } - - return ( - <> - {existingEmailElements} -
- - {addButton} - - - ); - } -} diff --git a/src/components/views/settings/account/PhoneNumbers.tsx b/src/components/views/settings/account/PhoneNumbers.tsx deleted file mode 100644 index 33c00c49b9..0000000000 --- a/src/components/views/settings/account/PhoneNumbers.tsx +++ /dev/null @@ -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 { - 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 ( -
- - {_t("settings|general|remove_msisdn_prompt", { phone: this.props.msisdn.address })} - - - {_t("action|remove")} - - - {_t("action|cancel")} - -
- ); - } - - return ( -
- - +{this.props.msisdn.address} - - - {_t("action|remove")} - -
- ); - } -} - -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 { - 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): void => { - this.setState({ - newPhoneNumber: e.target.value, - }); - }; - - private onChangeNewPhoneNumberCode = (e: React.ChangeEvent): 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 ( - - ); - }); - - let addVerifySection = ( - - {_t("action|add")} - - ); - if (this.state.verifying) { - const msisdn = this.state.verifyMsisdn; - addVerifySection = ( -
-
- {_t("settings|general|add_msisdn_instructions", { msisdn: msisdn })} -
- {this.state.verifyError} -
-
- - - {_t("action|continue")} - - -
- ); - } - - const phoneCountry = ( - - ); - - return ( - <> - {existingPhoneElements} -
-
- -
-
- {addVerifySection} - - ); - } -} diff --git a/src/components/views/settings/discovery/DiscoverySettings.tsx b/src/components/views/settings/discovery/DiscoverySettings.tsx index 8b1a20ac2e..4eec56e41f 100644 --- a/src/components/views/settings/discovery/DiscoverySettings.tsx +++ b/src/components/views/settings/discovery/DiscoverySettings.tsx @@ -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(true); const [emails, setEmails] = useState([]); const [phoneNumbers, setPhoneNumbers] = useState([]); - const [loadingState, setLoadingState] = useState<"loading" | "loaded" | "error">("loading"); const [idServerName, setIdServerName] = useState(abbreviateUrl(client.getIdentityServerUrl())); const [canMake3pidChanges, setCanMake3pidChanges] = useState(false); @@ -71,9 +70,11 @@ export const DiscoverySettings: React.FC = () => { const [hasTerms, setHasTerms] = useState(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 ? ( - <> - - - - ) : null; + let threepidSection; + if (idServerName) { + threepidSection = ( + <> + + + + + + + + ); + } return ( - + {threepidSection} {/* has its own heading as it includes the current identity server */} diff --git a/src/components/views/settings/discovery/EmailAddresses.tsx b/src/components/views/settings/discovery/EmailAddresses.tsx deleted file mode 100644 index 3f0568f544..0000000000 --- a/src/components/views/settings/discovery/EmailAddresses.tsx +++ /dev/null @@ -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 { - public constructor(props: IEmailAddressProps) { - super(props); - - const { bound } = props.email; - - this.state = { - verifying: false, - addTask: null, - continueDisabled: false, - bound, - }; - } - - public componentDidUpdate(prevProps: Readonly): void { - if (this.props.email !== prevProps.email) { - const { bound } = this.props.email; - this.setState({ bound }); - } - } - - private async changeBinding({ bind, label, errorTitle }: Binding): Promise { - 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 => { - 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 = ( - - {_t("settings|general|discovery_email_verification_instructions")} - - {_t("action|complete")} - - - ); - } else if (bound) { - status = ( - - {_t("action|revoke")} - - ); - } else { - status = ( - - {_t("action|share")} - - ); - } - - return ( -
- {address} - {status} -
- ); - } -} -interface IProps { - emails: ThirdPartyIdentifier[]; - isLoading?: boolean; - disabled?: boolean; -} - -export default class EmailAddresses extends React.Component { - public render(): React.ReactNode { - let content; - if (this.props.isLoading) { - content = ; - } else if (this.props.emails.length > 0) { - content = this.props.emails.map((e) => { - return ; - }); - } - - const hasEmails = !!this.props.emails.length; - - return ( - - {content} - - ); - } -} diff --git a/src/components/views/settings/discovery/PhoneNumbers.tsx b/src/components/views/settings/discovery/PhoneNumbers.tsx deleted file mode 100644 index e7a3326be2..0000000000 --- a/src/components/views/settings/discovery/PhoneNumbers.tsx +++ /dev/null @@ -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 { - 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): void { - if (this.props.msisdn !== prevProps.msisdn) { - const { bound } = this.props.msisdn; - this.setState({ bound }); - } - } - - private async changeBinding({ bind, label, errorTitle }: Binding): Promise { - 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): void => { - this.setState({ - verificationCode: e.target.value, - }); - }; - - private onContinueClick = async (e: ButtonEvent | React.FormEvent): Promise => { - 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 = ( - - - {_t("settings|general|msisdn_verification_instructions")} -
- {this.state.verifyError} -
-
- - -
- ); - } else if (bound) { - status = ( - - {_t("action|revoke")} - - ); - } else { - status = ( - - {_t("action|share")} - - ); - } - - return ( -
- +{address} - {status} -
- ); - } -} - -interface IProps { - msisdns: ThirdPartyIdentifier[]; - isLoading?: boolean; - disabled?: boolean; -} - -export default class PhoneNumbers extends React.Component { - public render(): React.ReactNode { - let content; - if (this.props.isLoading) { - content = ; - } else if (this.props.msisdns.length > 0) { - content = this.props.msisdns.map((e) => { - return ; - }); - } - - const description = (!content && _t("settings|general|discovery_msisdn_empty")) || undefined; - - return ( - - {content} - - ); - } -} diff --git a/src/i18n/strings/cs.json b/src/i18n/strings/cs.json index b4eec72313..879d381c4b 100644 --- a/src/i18n/strings/cs.json +++ b/src/i18n/strings/cs.json @@ -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 Připnout 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", diff --git a/src/i18n/strings/de_DE.json b/src/i18n/strings/de_DE.json index e62f1daab6..26abbe22f2 100644 --- a/src/i18n/strings/de_DE.json +++ b/src/i18n/strings/de_DE.json @@ -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 Anheften, ⁣ 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", diff --git a/src/i18n/strings/el.json b/src/i18n/strings/el.json index 8008c18c37..e41d10084b 100644 --- a/src/i18n/strings/el.json +++ b/src/i18n/strings/el.json @@ -1427,8 +1427,6 @@ "export_chat_button": "Εξαγωγή συνομιλίας", "files_button": "Αρχεία", "pinned_messages": { - "empty": "Δεν έχει καρφιτσωθεί κάτι ακόμα", - "explainer": "Εάν έχετε δικαιώματα, ανοίξτε το μενού σε οποιοδήποτε μήνυμα και επιλέξτε Καρφίτσωμα για να τα κολλήσετε εδώ.", "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": "Αριθμός Τηλεφώνου", diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index ff216691ec..889fc157e9 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -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 Pin 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 thread message", + "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", diff --git a/src/i18n/strings/eo.json b/src/i18n/strings/eo.json index 1da379da45..75afc3a4f3 100644 --- a/src/i18n/strings/eo.json +++ b/src/i18n/strings/eo.json @@ -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 Fiksi 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", diff --git a/src/i18n/strings/es.json b/src/i18n/strings/es.json index 657d331eff..0895426524 100644 --- a/src/i18n/strings/es.json +++ b/src/i18n/strings/es.json @@ -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 Fijar 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", diff --git a/src/i18n/strings/et.json b/src/i18n/strings/et.json index 7159371240..e1be11559b 100644 --- a/src/i18n/strings/et.json +++ b/src/i18n/strings/et.json @@ -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 Klammerda.", "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", diff --git a/src/i18n/strings/fa.json b/src/i18n/strings/fa.json index 4c86de65cb..99d60e0474 100644 --- a/src/i18n/strings/fa.json +++ b/src/i18n/strings/fa.json @@ -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": "شماره تلفن", diff --git a/src/i18n/strings/fi.json b/src/i18n/strings/fi.json index 7a94f7ae1d..0deae167ab 100644 --- a/src/i18n/strings/fi.json +++ b/src/i18n/strings/fi.json @@ -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", diff --git a/src/i18n/strings/fr.json b/src/i18n/strings/fr.json index c98f994595..715374f340 100644 --- a/src/i18n/strings/fr.json +++ b/src/i18n/strings/fr.json @@ -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 Épingler 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", diff --git a/src/i18n/strings/gl.json b/src/i18n/strings/gl.json index 136fec5094..6a508ef4b2 100644 --- a/src/i18n/strings/gl.json +++ b/src/i18n/strings/gl.json @@ -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 Fixar 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", diff --git a/src/i18n/strings/he.json b/src/i18n/strings/he.json index 4713278b93..9737f7d516 100644 --- a/src/i18n/strings/he.json +++ b/src/i18n/strings/he.json @@ -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": "מספר טלפון", diff --git a/src/i18n/strings/hu.json b/src/i18n/strings/hu.json index f259d46f52..06a09abd22 100644 --- a/src/i18n/strings/hu.json +++ b/src/i18n/strings/hu.json @@ -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 Kitűzés 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", diff --git a/src/i18n/strings/id.json b/src/i18n/strings/id.json index 3bc3c19209..360170f2b0 100644 --- a/src/i18n/strings/id.json +++ b/src/i18n/strings/id.json @@ -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 Pin 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", diff --git a/src/i18n/strings/is.json b/src/i18n/strings/is.json index 189348654a..71d292ca52 100644 --- a/src/i18n/strings/is.json +++ b/src/i18n/strings/is.json @@ -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", diff --git a/src/i18n/strings/it.json b/src/i18n/strings/it.json index b7a179e9b8..b978a3dfbf 100644 --- a/src/i18n/strings/it.json +++ b/src/i18n/strings/it.json @@ -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 Fissa 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", diff --git a/src/i18n/strings/ja.json b/src/i18n/strings/ja.json index d53f45ceb9..4af4a7cee0 100644 --- a/src/i18n/strings/ja.json +++ b/src/i18n/strings/ja.json @@ -1664,8 +1664,6 @@ "export_chat_button": "チャットをエクスポート", "files_button": "ファイル", "pinned_messages": { - "empty": "固定メッセージはありません", - "explainer": "権限がある場合は、メッセージのメニューを開いて固定を選択すると、ここにメッセージが表示されます。", "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": "電話番号", diff --git a/src/i18n/strings/lo.json b/src/i18n/strings/lo.json index 96450a708f..ab333ebf40 100644 --- a/src/i18n/strings/lo.json +++ b/src/i18n/strings/lo.json @@ -1453,8 +1453,6 @@ "export_chat_button": "ສົ່ງການສົນທະນາອອກ", "files_button": "ໄຟລ໌", "pinned_messages": { - "empty": "ບໍ່ມີຫຍັງຖືກປັກໝຸດ,", - "explainer": "ຖ້າຫາກທ່ານມີການອະນຸຍາດ, ເປີດເມນູໃນຂໍ້ຄວາມໃດຫນຶ່ງ ແລະ ເລືອກ Pin ເພື່ອຕິດໃຫ້ເຂົາເຈົ້າຢູ່ທີ່ນີ້.", "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": "ເບີໂທລະສັບ", diff --git a/src/i18n/strings/lt.json b/src/i18n/strings/lt.json index 5ef8e3ce78..9a300d5165 100644 --- a/src/i18n/strings/lt.json +++ b/src/i18n/strings/lt.json @@ -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 Prisegti, 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", diff --git a/src/i18n/strings/nl.json b/src/i18n/strings/nl.json index d2d5f7d3a6..10c9e419f4 100644 --- a/src/i18n/strings/nl.json +++ b/src/i18n/strings/nl.json @@ -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 Vastprikken 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", diff --git a/src/i18n/strings/pl.json b/src/i18n/strings/pl.json index 999543b3d8..ad9d34ab0c 100644 --- a/src/i18n/strings/pl.json +++ b/src/i18n/strings/pl.json @@ -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 ustawieniami", "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 Przypnij, 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": "Ustawienia: 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ę", diff --git a/src/i18n/strings/pt_BR.json b/src/i18n/strings/pt_BR.json index 2e49c0243e..54c74671aa 100644 --- a/src/i18n/strings/pt_BR.json +++ b/src/i18n/strings/pt_BR.json @@ -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 Fixar 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", diff --git a/src/i18n/strings/ru.json b/src/i18n/strings/ru.json index a054430e6c..da18850d71 100644 --- a/src/i18n/strings/ru.json +++ b/src/i18n/strings/ru.json @@ -1773,8 +1773,6 @@ "export_chat_button": "Экспорт чата", "files_button": "Файлы", "pinned_messages": { - "empty": "Пока ничего не закреплено", - "explainer": "Если у вас есть разрешения, откройте меню на любом сообщении и выберите Закрепить, чтобы поместить их сюда.", "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": "Номер телефона", diff --git a/src/i18n/strings/sk.json b/src/i18n/strings/sk.json index 8de4c714c4..43ce04b06d 100644 --- a/src/i18n/strings/sk.json +++ b/src/i18n/strings/sk.json @@ -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 Pripnúť 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", diff --git a/src/i18n/strings/sq.json b/src/i18n/strings/sq.json index be2c226290..1d544cd7fb 100644 --- a/src/i18n/strings/sq.json +++ b/src/i18n/strings/sq.json @@ -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 Fiksoje, 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", diff --git a/src/i18n/strings/sv.json b/src/i18n/strings/sv.json index 900430594f..1095c8dec1 100644 --- a/src/i18n/strings/sv.json +++ b/src/i18n/strings/sv.json @@ -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 Fäst 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", diff --git a/src/i18n/strings/uk.json b/src/i18n/strings/uk.json index bf31e7e36a..66642f5e62 100644 --- a/src/i18n/strings/uk.json +++ b/src/i18n/strings/uk.json @@ -1735,8 +1735,6 @@ "export_chat_button": "Експортувати бесіду", "files_button": "Файли", "pinned_messages": { - "empty": "Наразі нічого не закріплено", - "explainer": "Якщо маєте дозвіл, відкрийте меню будь-якого повідомлення й натисніть Закріпити, щоб додати його сюди.", "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": "Телефонний номер", diff --git a/src/i18n/strings/vi.json b/src/i18n/strings/vi.json index 612a50cf2d..aa240c20bf 100644 --- a/src/i18n/strings/vi.json +++ b/src/i18n/strings/vi.json @@ -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 Pin để 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", diff --git a/src/i18n/strings/zh_Hans.json b/src/i18n/strings/zh_Hans.json index 973c08be43..984d87f67d 100644 --- a/src/i18n/strings/zh_Hans.json +++ b/src/i18n/strings/zh_Hans.json @@ -1631,8 +1631,6 @@ "export_chat_button": "导出聊天", "files_button": "文件", "pinned_messages": { - "empty": "尚无固定任何东西", - "explainer": "如果你拥有权限,请打开任何消息的菜单并选择固定将它们粘贴至此。", "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": "电话号码", diff --git a/src/i18n/strings/zh_Hant.json b/src/i18n/strings/zh_Hant.json index 5c7c09e04a..d7d3a74784 100644 --- a/src/i18n/strings/zh_Hant.json +++ b/src/i18n/strings/zh_Hant.json @@ -1738,8 +1738,6 @@ "export_chat_button": "匯出聊天", "files_button": "檔案", "pinned_messages": { - "empty": "尚未釘選任何東西", - "explainer": "如果您有權限,請開啟任何訊息的選單,並選取釘選以將它們固定到這裡。", "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": "電話號碼", diff --git a/src/utils/PinningUtils.ts b/src/utils/PinningUtils.ts index f9750a3ed2..0d3323d50b 100644 --- a/src/utils/PinningUtils.ts +++ b/src/utils/PinningUtils.ts @@ -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 { + 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 = + 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 }, ""); + } } diff --git a/test/MatrixClientPeg-test.ts b/test/MatrixClientPeg-test.ts index 121da2a154..99e9195422 100644 --- a/test/MatrixClientPeg-test.ts +++ b/test/MatrixClientPeg-test.ts @@ -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; diff --git a/test/components/structures/MatrixChat-test.tsx b/test/components/structures/MatrixChat-test.tsx index 3b49ec2139..8d7f07e7df 100644 --- a/test/components/structures/MatrixChat-test.tsx +++ b/test/components/structures/MatrixChat-test.tsx @@ -624,6 +624,18 @@ describe("", () => { expect(localStorage.getItem(`mx_cider_state_${unknownRoomId}`)).toBeNull(); }); + it("should clean up wysiwyg drafts", async () => { + Date.now = jest.fn(() => timestamp); + localStorage.setItem(`mx_wysiwyg_state_${roomId}`, "fake_content"); + localStorage.setItem(`mx_wysiwyg_state_${unknownRoomId}`, "fake_content"); + await getComponentAndWaitForReady(); + mockClient.emit(ClientEvent.Sync, SyncState.Syncing, SyncState.Syncing); + // let things settle + await flushPromises(); + expect(localStorage.getItem(`mx_wysiwyg_state_${roomId}`)).not.toBeNull(); + expect(localStorage.getItem(`mx_wysiwyg_state_${unknownRoomId}`)).toBeNull(); + }); + it("should not clean up drafts before expiry", async () => { // Set the last cleanup to the recent past localStorage.setItem(`mx_cider_state_${unknownRoomId}`, "fake_content"); diff --git a/test/components/views/context_menus/MessageContextMenu-test.tsx b/test/components/views/context_menus/MessageContextMenu-test.tsx index 2be71e39cb..7be05452c8 100644 --- a/test/components/views/context_menus/MessageContextMenu-test.tsx +++ b/test/components/views/context_menus/MessageContextMenu-test.tsx @@ -15,7 +15,7 @@ limitations under the License. */ import React from "react"; -import { fireEvent, render, RenderResult } from "@testing-library/react"; +import { fireEvent, render, RenderResult, screen, waitFor } from "@testing-library/react"; import { EventStatus, MatrixEvent, @@ -28,9 +28,11 @@ import { FeatureSupport, Thread, M_POLL_KIND_DISCLOSED, + EventTimeline, } from "matrix-js-sdk/src/matrix"; import { PollStartEvent } from "matrix-js-sdk/src/extensible_events_v1/PollStartEvent"; import { mocked } from "jest-mock"; +import userEvent from "@testing-library/user-event"; import { MatrixClientPeg } from "../../../../src/MatrixClientPeg"; import RoomContext, { TimelineRenderingType } from "../../../../src/contexts/RoomContext"; @@ -83,8 +85,16 @@ describe("MessageContextMenu", () => { }); describe("message pinning", () => { + let room: Room; + beforeEach(() => { + room = makeDefaultRoom(); + jest.spyOn(SettingsStore, "getValue").mockReturnValue(true); + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockReturnValue(true); }); afterAll(() => { @@ -95,25 +105,23 @@ describe("MessageContextMenu", () => { const eventContent = createMessageEventContent("hello"); const event = new MatrixEvent({ type: EventType.RoomMessage, content: eventContent }); - const room = makeDefaultRoom(); // mock permission to disallow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(false); + jest.spyOn( + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, + "mayClientSendStateEvent", + ).mockReturnValue(false); - createMenu(event, {}, {}, undefined, room); + createMenu(event, { rightClick: true }, {}, undefined, room); - expect(document.querySelector('li[aria-label="Pin"]')).toBeFalsy(); + expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy(); }); it("does not show pin option for beacon_info event", () => { const deadBeaconEvent = makeBeaconInfoEvent("@alice:server.org", roomId, { isLive: false }); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + createMenu(deadBeaconEvent, { rightClick: true }, {}, undefined, room); - createMenu(deadBeaconEvent, {}, {}, undefined, room); - - expect(document.querySelector('li[aria-label="Pin"]')).toBeFalsy(); + expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy(); }); it("does not show pin option when pinning feature is disabled", () => { @@ -124,15 +132,12 @@ describe("MessageContextMenu", () => { room_id: roomId, }); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); // disable pinning feature jest.spyOn(SettingsStore, "getValue").mockReturnValue(false); - createMenu(pinnableEvent, {}, {}, undefined, room); + createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room); - expect(document.querySelector('li[aria-label="Pin"]')).toBeFalsy(); + expect(screen.queryByRole("menuitem", { name: "Pin" })).toBeFalsy(); }); it("shows pin option when pinning feature is enabled", () => { @@ -143,16 +148,12 @@ describe("MessageContextMenu", () => { room_id: roomId, }); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room); - createMenu(pinnableEvent, {}, {}, undefined, room); - - expect(document.querySelector('li[aria-label="Pin"]')).toBeTruthy(); + expect(screen.getByRole("menuitem", { name: "Pin" })).toBeTruthy(); }); - it("pins event on pin option click", () => { + it("pins event on pin option click", async () => { const onFinished = jest.fn(); const eventContent = createMessageEventContent("hello"); const pinnableEvent = new MatrixEvent({ @@ -162,43 +163,48 @@ describe("MessageContextMenu", () => { }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.safeGet(); - const room = makeDefaultRoom(); - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + jest.spyOn(room.getLiveTimeline().getState(EventTimeline.FORWARDS)!, "getStateEvents").mockReturnValue({ + // @ts-ignore + getContent: () => ({ pinned: ["!1", "!2"] }), + }); // mock read pins account data const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } }); jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData); - createMenu(pinnableEvent, { onFinished }, {}, undefined, room); + createMenu(pinnableEvent, { onFinished, rightClick: true }, {}, undefined, room); - fireEvent.click(document.querySelector('li[aria-label="Pin"]')!); + await userEvent.click(screen.getByRole("menuitem", { name: "Pin" })); // added to account data - expect(client.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, { - event_ids: [ - // from account data - "!1", - "!2", - pinnableEvent.getId(), - ], - }); + await waitFor(() => + expect(client.setRoomAccountData).toHaveBeenCalledWith(roomId, ReadPinsEventId, { + event_ids: [ + // from account data + "!1", + "!2", + pinnableEvent.getId(), + ], + }), + ); // add to room's pins - expect(client.sendStateEvent).toHaveBeenCalledWith( - roomId, - EventType.RoomPinnedEvents, - { - pinned: [pinnableEvent.getId()], - }, - "", + await waitFor(() => + expect(client.sendStateEvent).toHaveBeenCalledWith( + roomId, + EventType.RoomPinnedEvents, + { + pinned: ["!1", "!2", pinnableEvent.getId()], + }, + "", + ), ); expect(onFinished).toHaveBeenCalled(); }); - it("unpins event on pin option click when event is pinned", () => { + it("unpins event on pin option click when event is pinned", async () => { const eventContent = createMessageEventContent("hello"); const pinnableEvent = new MatrixEvent({ type: EventType.RoomMessage, @@ -207,7 +213,6 @@ describe("MessageContextMenu", () => { }); pinnableEvent.event.event_id = "!3"; const client = MatrixClientPeg.safeGet(); - const room = makeDefaultRoom(); // make the event already pinned in the room const pinEvent = new MatrixEvent({ @@ -216,18 +221,15 @@ describe("MessageContextMenu", () => { state_key: "", content: { pinned: [pinnableEvent.getId(), "!another-event"] }, }); - room.currentState.setStateEvents([pinEvent]); - - // mock permission to allow adding pinned messages to room - jest.spyOn(room.currentState, "mayClientSendStateEvent").mockReturnValue(true); + room.getLiveTimeline().getState(EventTimeline.FORWARDS)!.setStateEvents([pinEvent]); // mock read pins account data const pinsAccountData = new MatrixEvent({ content: { event_ids: ["!1", "!2"] } }); jest.spyOn(room, "getAccountData").mockReturnValue(pinsAccountData); - createMenu(pinnableEvent, {}, {}, undefined, room); + createMenu(pinnableEvent, { rightClick: true }, {}, undefined, room); - fireEvent.click(document.querySelector('li[aria-label="Unpin"]')!); + await userEvent.click(screen.getByRole("menuitem", { name: "Unpin" })); expect(client.setRoomAccountData).not.toHaveBeenCalled(); diff --git a/test/components/views/dialogs/UnpinAllDialog-test.tsx b/test/components/views/dialogs/UnpinAllDialog-test.tsx new file mode 100644 index 0000000000..95018cc72d --- /dev/null +++ b/test/components/views/dialogs/UnpinAllDialog-test.tsx @@ -0,0 +1,46 @@ +/* + * 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 from "react"; +import { render, screen } from "@testing-library/react"; +import userEvent from "@testing-library/user-event"; +import { EventType } from "matrix-js-sdk/src/matrix"; + +import { UnpinAllDialog } from "../../../../src/components/views/dialogs/UnpinAllDialog"; +import { createTestClient } from "../../../test-utils"; + +describe("", () => { + const client = createTestClient(); + const roomId = "!room:example.org"; + + function renderDialog(onFinished = jest.fn()) { + return render(); + } + + it("should render", () => { + const { asFragment } = renderDialog(); + expect(asFragment()).toMatchSnapshot(); + }); + + it("should remove all pinned events when clicked on Continue", async () => { + const onFinished = jest.fn(); + renderDialog(onFinished); + + await userEvent.click(screen.getByText("Continue")); + expect(client.sendStateEvent).toHaveBeenCalledWith(roomId, EventType.RoomPinnedEvents, { pinned: [] }, ""); + expect(onFinished).toHaveBeenCalled(); + }); +}); diff --git a/test/components/views/dialogs/__snapshots__/UnpinAllDialog-test.tsx.snap b/test/components/views/dialogs/__snapshots__/UnpinAllDialog-test.tsx.snap new file mode 100644 index 0000000000..8213a9b917 --- /dev/null +++ b/test/components/views/dialogs/__snapshots__/UnpinAllDialog-test.tsx.snap @@ -0,0 +1,66 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[` should render 1`] = ` + +
+