Compare commits

..

1 Commits

Author SHA1 Message Date
Michael Telatynski
18c90e2a7d Small step in tidying the IPCs
Signed-off-by: Michael Telatynski <7t3chguy@gmail.com>
2025-06-11 13:21:08 +01:00
126 changed files with 1696 additions and 3705 deletions

View File

@@ -1,32 +1,3 @@
Changes in [1.11.104](https://github.com/element-hq/element-web/releases/tag/v1.11.104) (2025-06-17)
====================================================================================================
## ✨ Features
* Update the mobile\_guide page to the new design. ([#30006](https://github.com/element-hq/element-web/pull/30006)). Contributed by @pixlwave.
* Provide a devtool for manually verifying other devices ([#30094](https://github.com/element-hq/element-web/pull/30094)). Contributed by @andybalaam.
* Implement MSC4155: Invite filtering ([#29603](https://github.com/element-hq/element-web/pull/29603)). Contributed by @Half-Shot.
* Add low priority avatar decoration to room tile ([#30065](https://github.com/element-hq/element-web/pull/30065)). Contributed by @MidhunSureshR.
* Add ability to prevent window content being captured by other apps (Desktop) ([#30098](https://github.com/element-hq/element-web/pull/30098)). Contributed by @t3chguy.
* New room list: move message preview in user settings ([#30023](https://github.com/element-hq/element-web/pull/30023)). Contributed by @florianduros.
* New room list: change room options icon ([#30029](https://github.com/element-hq/element-web/pull/30029)). Contributed by @florianduros.
* RoomListStore: Sort low priority rooms to the bottom of the list ([#30070](https://github.com/element-hq/element-web/pull/30070)). Contributed by @MidhunSureshR.
* Add low priority filter pill to the room list UI ([#30060](https://github.com/element-hq/element-web/pull/30060)). Contributed by @MidhunSureshR.
* New room list: remove color gradient in space panel ([#29721](https://github.com/element-hq/element-web/pull/29721)). Contributed by @florianduros.
* /share?msg=foo endpoint using forward message dialog ([#29874](https://github.com/element-hq/element-web/pull/29874)). Contributed by @ara4n.
## 🐛 Bug Fixes
* Do not send empty auth when setting up cross-signing keys ([#29914](https://github.com/element-hq/element-web/pull/29914)). Contributed by @gnieto.
* Settings: flip local video feed by default ([#29501](https://github.com/element-hq/element-web/pull/29501)). Contributed by @jbtrystram.
* AccessSecretStorageDialog: various fixes ([#30093](https://github.com/element-hq/element-web/pull/30093)). Contributed by @richvdh.
* AccessSecretStorageDialog: fix inability to enter recovery key ([#30090](https://github.com/element-hq/element-web/pull/30090)). Contributed by @richvdh.
* Fix failure to upload thumbnail causing image to send as file ([#30086](https://github.com/element-hq/element-web/pull/30086)). Contributed by @t3chguy.
* Low priority menu item should be a toggle ([#30071](https://github.com/element-hq/element-web/pull/30071)). Contributed by @MidhunSureshR.
* Add sanity checks to prevent users from ignoring themselves ([#30079](https://github.com/element-hq/element-web/pull/30079)). Contributed by @MidhunSureshR.
* Fix issue with duplicate images ([#30073](https://github.com/element-hq/element-web/pull/30073)). Contributed by @fatlewis.
* Handle errors returned from Seshat ([#30083](https://github.com/element-hq/element-web/pull/30083)). Contributed by @richvdh.
Changes in [1.11.103](https://github.com/element-hq/element-web/releases/tag/v1.11.103) (2025-06-10)
====================================================================================================
## 🐛 Bug Fixes

View File

@@ -1,6 +1,6 @@
{
"name": "element-web",
"version": "1.11.104",
"version": "1.11.103",
"description": "Element: the future of secure communication",
"author": "New Vector Ltd.",
"repository": {
@@ -81,7 +81,7 @@
},
"dependencies": {
"@babel/runtime": "^7.12.5",
"@element-hq/element-web-module-api": "1.2.0",
"@element-hq/element-web-module-api": "1.0.0",
"@fontsource/inconsolata": "^5",
"@fontsource/inter": "^5",
"@formatjs/intl-segmenter": "^11.5.7",
@@ -93,7 +93,7 @@
"@types/png-chunks-extract": "^1.0.2",
"@types/react-virtualized": "^9.21.30",
"@vector-im/compound-design-tokens": "^4.0.0",
"@vector-im/compound-web": "^8.0.0",
"@vector-im/compound-web": "^7.11.0",
"@vector-im/matrix-wysiwyg": "2.38.3",
"@zxcvbn-ts/core": "^3.0.4",
"@zxcvbn-ts/language-common": "^3.0.4",

View File

@@ -22,21 +22,13 @@ test.describe("Room list filters and sort", () => {
});
function getPrimaryFilters(page: Page): Locator {
return page.getByTestId("primary-filters");
return page.getByRole("listbox", { name: "Room list filters" });
}
function getRoomOptionsMenu(page: Page): Locator {
return page.getByRole("button", { name: "Room Options" });
}
function getFilterExpandButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Expand filter list" });
}
function getFilterCollapseButton(page: Page): Locator {
return getPrimaryFilters(page).getByRole("button", { name: "Collapse filter list" });
}
/**
* Get the room list
* @param page
@@ -144,7 +136,6 @@ test.describe("Room list filters and sort", () => {
await tile.click();
// Enable Favourite filter
await getFilterExpandButton(page).click();
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(tile).not.toBeVisible();
@@ -232,6 +223,10 @@ test.describe("Room list filters and sort", () => {
expect(await roomList.locator("role=gridcell").count()).toBe(4);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "People" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
@@ -245,12 +240,6 @@ test.describe("Room list filters and sort", () => {
await expect(roomList.getByRole("gridcell", { name: "Low prio room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(5);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: "Favourite" }).click();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Mentions" }).click();
await expect(roomList.getByRole("gridcell", { name: "room with mention" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
@@ -258,9 +247,6 @@ test.describe("Room list filters and sort", () => {
await primaryFilters.getByRole("option", { name: "Invites" }).click();
await expect(roomList.getByRole("gridcell", { name: "invited room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await getFilterCollapseButton(page).click();
await expect(primaryFilters.locator("role=option").first()).toHaveText("Invites");
});
test(
@@ -326,9 +312,7 @@ test.describe("Room list filters and sort", () => {
async ({ page, app, user }) => {
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("default-empty-room-list.png");
await expect(page.getByRole("navigation", { name: "Room list" })).toMatchScreenshot(
"room-panel-empty-room-list.png",
);
await expect(page.getByTestId("room-list-panel")).toMatchScreenshot("room-panel-empty-room-list.png");
},
);
@@ -342,8 +326,6 @@ test.describe("Room list filters and sort", () => {
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: filter }).click();
const emptyRoomList = getEmptyRoomList(page);
@@ -361,8 +343,6 @@ test.describe("Room list filters and sort", () => {
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await getFilterExpandButton(page).click();
await primaryFilters.getByRole("option", { name: filter }).click();
const emptyRoomList = getEmptyRoomList(page);

View File

@@ -19,7 +19,7 @@ test.describe("Room list panel", () => {
* @param page
*/
function getRoomListView(page: Page) {
return page.getByRole("navigation", { name: "Room list" });
return page.getByTestId("room-list-panel");
}
test.beforeEach(async ({ page, app, user }) => {
@@ -44,7 +44,7 @@ test.describe("Room list panel", () => {
test("should respond to small screen sizes", { tag: "@screenshot" }, async ({ page }) => {
await page.setViewportSize({ width: 575, height: 600 });
const roomListPanel = getRoomListView(page);
const roomListPanel = page.getByTestId("room-list-panel");
await expect(roomListPanel).toMatchScreenshot("room-list-panel-smallscreen.png");
});
});

View File

@@ -278,7 +278,7 @@ test.describe("Room list", () => {
});
test("should be a video room", { tag: "@screenshot" }, async ({ page, app, user }) => {
await page.getByRole("navigation", { name: "Room list" }).getByRole("button", { name: "Add" }).click();
await page.getByTestId("room-list-panel").getByRole("button", { name: "Add" }).click();
await page.getByRole("menuitem", { name: "New video room" }).click();
await page.getByRole("textbox", { name: "Name" }).fill("video room");
await page.getByRole("button", { name: "Create video room" }).click();

View File

@@ -1,43 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
test.describe("Brand API", () => {
test.use({
displayName: "Manny",
botCreateOpts: {
autoAcceptInvites: true,
},
config: {
modules: ["/modules/brand-module.js"],
},
page: async ({ page }, use) => {
await page.route("/modules/brand-module.js", async (route) => {
await route.fulfill({ path: "playwright/sample-files/brand-module.js" });
});
await use(page);
},
room: async ({ page, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name: "TestRoom", invite:[ bot.credentials.userId ] });
await use({ roomId });
},
});
test.describe("basic functionality", () => {
test(
"should replace the standard window title",
async ({ page, room, app , user, bot}) => {
await page.goto(`/#/home`);
// Default title
expect(await page.title()).toEqual("MyBrand | OK | notifs=undefined | notifsenabled=undefined | roomId=undefined | roomName=undefined");
await app.viewRoomById(room.roomId);
expect(await page.title()).toEqual(`MyBrand | OK | notifs=undefined | notifsenabled=undefined | roomId=${room.roomId} | roomName=TestRoom`);
},
);
});

View File

@@ -1,112 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test";
const screenshotOptions = (page: Page) => ({
mask: [page.locator(".mx_MessageTimestamp")],
// Hide the jump to bottom button in the timeline to avoid flakiness
// Exclude timestamp and read marker from snapshot
css: `
.mx_JumpToBottomButton {
display: none !important;
}
.mx_TopUnreadMessagesBar, .mx_MessagePanel_myReadMarker {
display: none !important;
}
`,
});
test.describe("Custom Component API", () => {
test.use({
displayName: "Manny",
config: {
modules: ["/modules/custom-component-module.js"],
},
page: async ({ page }, use) => {
await page.route("/modules/custom-component-module.js", async (route) => {
await route.fulfill({ path: "playwright/sample-files/custom-component-module.js" });
});
await use(page);
},
room: async ({ page, app, user, bot }, use) => {
const roomId = await app.client.createRoom({ name: "TestRoom" });
await use({ roomId });
},
});
test.describe("basic functionality", () => {
test(
"should replace the render method of a textual event",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Simple message");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-tile.png",
screenshotOptions(page),
);
},
);
test(
"should fall through if one module does not render a component",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Fall through here");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-tile-fall-through.png",
screenshotOptions(page),
);
},
);
test(
"should render the original content of a textual event conditionally",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Do not replace me");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-tile-original.png",
screenshotOptions(page),
);
},
);
test("should disallow editing when the allowEditingEvent hint is set to false", async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Do not show edits");
await page.getByText("Do not show edits").hover();
await expect(
await page.getByRole("toolbar", { name: "Message Actions" }).getByRole("button", { name: "Edit" }),
).not.toBeVisible();
});
test(
"should render the next registered component if the filter function throws",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Crash the filter!");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-crash-handle-filter.png",
screenshotOptions(page),
);
},
);
test(
"should render original component if the render function throws",
{ tag: "@screenshot" },
async ({ page, room, app }) => {
await app.viewRoomById(room.roomId);
await app.client.sendMessage(room.roomId, "Crash the renderer!");
await expect(await page.locator(".mx_EventTile_last")).toMatchScreenshot(
"custom-component-crash-handle-renderer.png",
screenshotOptions(page),
);
},
);
});
});

View File

@@ -11,9 +11,6 @@ import { type Page } from "@playwright/test";
import { expect } from "../../element-web-test";
/**
* Click through registering a new user in the MAS UI.
*/
export async function registerAccountMas(
page: Page,
mailpit: MailpitClient,
@@ -45,17 +42,3 @@ export async function registerAccountMas(
await expect(page.getByText("Allow access to your account?")).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
}
/**
* Click through entering username and password into the MAS login prompt.
*/
export async function logInAccountMas(page: Page, username: string, password: string): Promise<void> {
await expect(page.getByText("Please sign in to continue:")).toBeVisible();
await page.getByRole("textbox", { name: "Username" }).fill(username);
await page.getByRole("textbox", { name: "Password", exact: true }).fill(password);
await page.getByRole("button", { name: "Continue" }).click();
await expect(page.getByText("Allow access to your account?")).toBeVisible();
await page.getByRole("button", { name: "Continue" }).click();
}

View File

@@ -6,12 +6,8 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type Config, CONFIG_JSON } from "@element-hq/element-web-playwright-common";
import { type Browser, type Page } from "@playwright/test";
import { type StartedHomeserverContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers/HomeserverContainer";
import { test, expect } from "../../element-web-test.ts";
import { logInAccountMas, registerAccountMas } from ".";
import { registerAccountMas } from ".";
import { ElementAppPage } from "../../pages/ElementAppPage.ts";
import { masHomeserver } from "../../plugins/homeserver/synapse/masHomeserver.ts";
@@ -105,154 +101,4 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
expect(localStorageKeys).toHaveLength(0);
},
);
test("can log in to an existing MAS account", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
// Register an account with MAS
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
await expect(page.getByText("Welcome")).toBeVisible();
// Log out
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(userId, { exact: true })).toBeVisible();
// Allow the outstanding requests queue to settle before logging out
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/#\/login$/);
// Log in again
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be in (we see an error because we have no recovery key).
await expect(page.getByText("Unable to verify this device")).toBeVisible();
});
test.describe("with force_verification on", () => {
test.use({
config: {
force_verification: true,
},
});
test("verify dialog cannot be dismissed", { tag: "@screenshot" }, async ({ page, mailpitClient }, testInfo) => {
// Register an account with MAS
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, "Pa$sW0rD!");
await expect(page.getByText("Welcome")).toBeVisible();
// Log out
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(userId, { exact: true })).toBeVisible();
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/#\/login$/);
// Log in again
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be being warned that we need to verify (but we can't)
await expect(page.getByText("Unable to verify this device")).toBeVisible();
// And there should be no way to close this prompt
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
});
test(
"continues to show verification prompt after cancelling device verification",
{ tag: "@screenshot" },
async ({ browser, config, homeserver, page, mailpitClient }, testInfo) => {
// Register an account with MAS
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
const password = "Pa$sW0rD!";
await registerAccountMas(page, mailpitClient, userId, `${userId}@email.com`, password);
await expect(page.getByText("Welcome")).toBeVisible();
// Log in an additional account, and verify it.
//
// This means that when we log out and in again, we are offered
// to verify using another device.
const otherContext = await newContext(browser, config, homeserver);
const otherDevicePage = await otherContext.newPage();
await otherDevicePage.goto("/#/login");
await otherDevicePage.getByRole("button", { name: "Continue" }).click();
await logInAccountMas(otherDevicePage, userId, password);
await verifyUsingOtherDevice(otherDevicePage, page);
await otherDevicePage.close();
// Log out
await page.getByRole("button", { name: "User menu" }).click();
await expect(page.getByText(userId, { exact: true })).toBeVisible();
await page.waitForTimeout(2000);
await page.locator(".mx_UserMenu_contextMenu").getByRole("menuitem", { name: "Sign out" }).click();
await expect(page).toHaveURL(/\/#\/login$/);
// Log in again
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
await page.getByRole("button", { name: "Continue" }).click();
// We should be in, and not able to dismiss the verify dialog
await expect(page.getByText("Verify this device")).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
// When we start verifying with another device
await page.getByRole("button", { name: "Verify with another device" }).click();
// And then cancel it
await page.getByRole("button", { name: "Close dialog" }).click();
// Then we should still be at the unskippable verify prompt
await expect(page.getByText("Verify this device")).toBeVisible();
await expect(page.getByRole("button", { name: "Skip verification for now" })).not.toBeVisible();
},
);
});
});
/**
* Perform interactive emoji verification for a new device.
*/
async function verifyUsingOtherDevice(deviceToVerifyPage: Page, alreadyVerifiedDevicePage: Page) {
await deviceToVerifyPage.getByRole("button", { name: "Verify with another device" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Verify session" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Start" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "They match" }).click();
await deviceToVerifyPage.getByRole("button", { name: "They match" }).click();
await alreadyVerifiedDevicePage.getByRole("button", { name: "Got it" }).click();
await deviceToVerifyPage.getByRole("button", { name: "Got it" }).click();
}
/**
* Create a new browser context which serves up the default config plus what you supplied, and sets m.homeserver to the
* supplied homeserver's URL.
*/
async function newContext(browser: Browser, config: Partial<Partial<Config>>, homeserver: StartedHomeserverContainer) {
const otherContext = await browser.newContext();
await otherContext.route(`http://localhost:8080/config.json*`, async (route) => {
const json = {
...CONFIG_JSON,
...config,
default_server_config: {
"m.homeserver": {
base_url: homeserver.baseUrl,
},
},
};
await route.fulfill({ json });
});
return otherContext;
}

View File

@@ -1,24 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export default class CustomBrandModule {
static moduleApiVersion = "^1.2.0";
/**
*
* @param {import("@element-hq/element-web-module-api").Api} api
*/
constructor(api) {
this.api = api;
this.api.brand.registerTitleRenderer(({errorDidOccur, notificationCount, notificationsEnabled, roomId, roomName}) => {
return `MyBrand | ${errorDidOccur ? "ERROR" : "OK"} | notifs=${notificationCount} | notifsenabled=${notificationsEnabled} | roomId=${roomId} | roomName=${roomName}`
});
this.api.brand.registerFaviconRenderer(() => {
return `data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAAIGNIUk0AAHomAACAhAAA+gAAAIDoAAB1MAAA6mAAADqYAAAXcJy6UTwAAAGMUExURQAAAHZNgHhuiXZQgXqYlX3Kon3IonZOgJPQsXrHoF0AGHmglnVLf7POxIOGlpjStXpwi5TRsoXLqHt5jY7OrorNq4fMqZ//4HZTgnZPgXuqmXjUonXEnFK4hHZNgHZNgHZMgHZMgHpqiY7Cq5LRsZDPr4rNq2vBlXZNgHZNgHZNgHZPgJTRsozOrHZKf3ZNgHZNgICUl5PQsYHKpVgAFXZNgHZNgI3PrZTRsofNqWkAXHZNgHZNgI3OrZTRsoPMp28CanZNgHZNgIzOrYmen4/Rr0/BhXU4enZNgHZNgIzOrImhoHZQgXhXg3lriXZPgXZNgJrTtorIqnZNgHZMgHZOgHZNgHZNgIvNrIvRrXE0d3ZNgHZNgHZNgHZNgITLp4/Pr4/ProPKpnZNgHZNgHZNgHZNgGK+j1y8i3ZLf3dQgY2tpZfUtZbStH1tjH91j5LCrZbTtJTKsJTMsZrOtZbQs5XSs4yppH1sjIWNmZXOspfStIJ9k3VIfpvTt5vVt3ZMgHZNgJjStf///3HnC34AAABpdFJOUwAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAECQkJAwgFI6TBwL+oUAQEVTpr82QDKxpu0xcBIxNt7zADPSZt4CADOSRt/ogCASgsbfL78D0BGP51lawLASVlUBjK6DMgIKObGLJ6B0cEAwV2SrsAAAABYktHRIP8tM/SAAAAB3RJTUUH6QYXDRw74LVdRgAAAMhJREFUGNNjYMAAjEzMLKxsQMAIAiABdkkpaRlZDk4uRjl5sICCopKyiqqaOreGphZIgEc7Mys7J1dHl1dP3wAkwGeYl19QmJtrZGxiagYS4DcvKs4tKc21sLSytgEL2JYVlldUVtnZOzg6gQWcq6trarNcXN3cPTzBAl519d4+Db5+Av4GAVCBwKDgxoaQ0LBwsKGCEU3VkVHRjTFOjLFxIAGh+ITEJGHr5JTUsDSwGSKi6Rli4ozyYYxhEKeD/MUmwQgFmJ4HALFlKaeL1fmEAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDI1LTA2LTIzVDEzOjI4OjU5KzAwOjAwkTuBLgAAACV0RVh0ZGF0ZTptb2RpZnkAMjAyNS0wNi0yM1QxMzoyODo1OSswMDowMOBmOZIAAAAodEVYdGRhdGU6dGltZXN0YW1wADIwMjUtMDYtMjNUMTM6Mjg6NTkrMDA6MDC3cxhNAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAABJRU5ErkJggg==`;
})
}
async load() {}
}

View File

@@ -1,55 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
export default class CustomComponentModule {
static moduleApiVersion = "^1.2.0";
constructor(api) {
this.api = api;
this.api.customComponents.registerMessageRenderer(
(evt) => evt.content.body === "Do not show edits",
(_props, originalComponent) => {
return originalComponent();
},
{ allowEditingEvent: false },
);
this.api.customComponents.registerMessageRenderer(
(evt) => evt.content.body === "Fall through here",
(props) => {
const body = props.mxEvent.content.body;
return `Fallthrough text for ${body}`;
},
);
this.api.customComponents.registerMessageRenderer(
(evt) => {
if (evt.content.body === "Crash the filter!") {
throw new Error("Fail test!");
}
return false;
},
() => {
return `Should not render!`;
},
);
this.api.customComponents.registerMessageRenderer(
(evt) => evt.content.body === "Crash the renderer!",
() => {
throw new Error("Fail test!");
},
);
// Order is specific here to avoid this overriding the other renderers
this.api.customComponents.registerMessageRenderer("m.room.message", (props, originalComponent) => {
const body = props.mxEvent.content.body;
if (body === "Do not replace me") {
return originalComponent();
} else if (body === "Fall through here") {
return null;
}
return `Custom text for ${body}`;
});
}
async load() {}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.2 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.9 KiB

After

Width:  |  Height:  |  Size: 6.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.9 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 KiB

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 7.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.8 KiB

After

Width:  |  Height:  |  Size: 9.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 29 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.5 KiB

After

Width:  |  Height:  |  Size: 2.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 73 KiB

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 90 KiB

After

Width:  |  Height:  |  Size: 94 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

After

Width:  |  Height:  |  Size: 2.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 19 KiB

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 106 KiB

After

Width:  |  Height:  |  Size: 106 KiB

View File

@@ -49,12 +49,12 @@
&:hover,
&:hover .mx_ThreadsActivityCentreButton_Icon {
background-color: $quaternary-content;
fill: $primary-content;
color: $primary-content;
}
}
& .mx_ThreadsActivityCentreButton_Icon {
fill: $secondary-content;
color: $secondary-content;
}
}

View File

@@ -6,32 +6,7 @@
*/
.mx_RoomListPrimaryFilters {
padding: var(--cpd-space-2x) var(--cpd-space-4x) var(--cpd-space-2x) var(--cpd-space-3x);
.mx_RoomListPrimaryFilters_wrapping {
display: none;
}
ul {
margin: unset;
padding: unset;
list-style-type: none;
/**
* The InteractionObserver needs the height to be set to work properly.
*/
height: 100%;
flex: 1;
}
.mx_RoomListPrimaryFilters_IconButton {
svg {
transition: transform 0.1s linear;
}
}
.mx_RoomListPrimaryFilters_IconButton[aria-expanded="true"] {
svg {
transform: rotate(180deg);
}
}
margin: unset;
list-style-type: none;
padding: var(--cpd-space-2x) var(--cpd-space-3x);
}

View File

@@ -103,5 +103,5 @@ Please see LICENSE files in the repository root for full details.
}
.mx_RoomHeader .mx_RoomHeader_toggled {
fill: var(--cpd-color-icon-accent-primary);
color: var(--cpd-color-icon-accent-primary);
}

View File

@@ -12,6 +12,7 @@ import "@types/modernizr";
import type { ModuleLoader } from "@element-hq/element-web-module-api";
import type { logger } from "matrix-js-sdk/src/logger";
import type { CallState } from "matrix-js-sdk/src/webrtc/call";
import type ContentMessages from "../ContentMessages";
import { type IMatrixClientPeg } from "../MatrixClientPeg";
import type ToastStore from "../stores/ToastStore";
@@ -51,7 +52,6 @@ import type { RoomListStoreV3Class } from "../stores/room-list-v3/RoomListStoreV
/* eslint-disable @typescript-eslint/naming-convention */
type ElectronChannel =
| "app_onAction"
| "before-quit"
| "check_updates"
| "install_update"
@@ -133,14 +133,18 @@ declare global {
send(channel: ElectronChannel, ...args: any[]): void;
// Initialisation
initialise(): Promise<{
version: string;
protocol: string;
sessionId: string;
config: IConfigOptions;
supportedSettings: Record<string, boolean>;
canSelfUpdate: boolean;
}>;
// Settings
setSettingValue(settingName: string, value: any): Promise<void>;
getSettingValue(settingName: string): Promise<any>;
// Lifecycle
onCallState(callState: CallState): void;
}
interface DesktopCapturerSource {

View File

@@ -657,7 +657,7 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean }
freshLogin: freshLogin,
},
false,
freshLogin,
false,
);
return true;
} else {

View File

@@ -28,7 +28,6 @@ import { TooltipProvider } from "@vector-im/compound-web";
// what-input helps improve keyboard accessibility
import "what-input";
import sanitizeHtml from "sanitize-html";
import { type TitleRenderOptions } from "@element-hq/element-web-module-api";
import PosthogTrackers from "../../PosthogTrackers";
import { DecryptionFailureTracker } from "../../DecryptionFailureTracker";
@@ -142,7 +141,6 @@ import { type OpenForwardDialogPayload } from "../../dispatcher/payloads/OpenFor
import { ShareFormat, type SharePayload } from "../../dispatcher/payloads/SharePayload";
import Markdown from "../../Markdown";
import { sanitizeHtmlParams } from "../../Linkify";
import moduleApi from "../../modules/Api";
// legacy export
export { default as Views } from "../../Views";
@@ -229,7 +227,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private tokenLogin?: boolean;
// What to focus on next component update, if anything
private focusNext: FocusNextType;
private subTitleState: TitleRenderOptions;
private subTitleStatus: string;
private prevWindowWidth: number;
private readonly loggedInView = createRef<LoggedInViewType>();
@@ -285,7 +283,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// object field used for tracking the status info appended to the title tag.
// we don't do it as react state as i'm scared about triggering needless react refreshes.
this.subTitleState = {};
this.subTitleStatus = "";
}
/**
@@ -1507,7 +1505,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
collapseLhs: false,
currentRoomId: null,
});
this.subTitleState = {};
this.subTitleStatus = "";
this.setPageSubtitle();
this.stores.onLoggedOut();
}
@@ -1523,7 +1521,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
collapseLhs: false,
currentRoomId: null,
});
this.subTitleState = {};
this.subTitleStatus = "";
this.setPageSubtitle();
}
@@ -1994,44 +1992,18 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
});
}
private setPageSubtitle(): void {
let roomName: string | undefined;
private setPageSubtitle(subtitle = ""): void {
if (this.state.currentRoomId) {
const client = MatrixClientPeg.get();
roomName = client?.getRoom(this.state.currentRoomId)?.name;
const room = client?.getRoom(this.state.currentRoomId);
if (room) {
subtitle = `${this.subTitleStatus} | ${room.name} ${subtitle}`;
}
} else {
subtitle = `${this.subTitleStatus} ${subtitle}`;
}
let title = moduleApi.brand.renderTitle({
...this.subTitleState,
roomName,
roomId: this.state.currentRoomId ?? undefined,
});
if (title === undefined) {
// No module API implemented, fallback
let subTitleStatus = "";
if (this.subTitleState.errorDidOccur) {
subTitleStatus += `[${_t("common|offline")}] `;
}
if ((this.subTitleState.notificationCount ?? 0) > 0) {
subTitleStatus += `[${this.subTitleState.notificationCount}]`;
} else if (this.subTitleState.notificationsEnabled) {
subTitleStatus += `*`;
}
let subtitle;
if (this.state.currentRoomId) {
if (roomName) {
subtitle = `${subTitleStatus} | ${roomName}`;
}
} else {
subtitle = `${subTitleStatus}`;
}
title = `${SdkConfig.get().brand} ${subtitle}`;
}
const title = `${SdkConfig.get().brand} ${subtitle}`;
if (document.title !== title) {
document.title = title;
@@ -2039,20 +2011,22 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}
private onUpdateStatusIndicator = (notificationState: SummarizedNotificationState, state: SyncState): void => {
console.log("onUpdateStatusIndicator");
const notificationCount = notificationState.numUnreadStates; // we know that states === rooms here
const platform = PlatformPeg.get();
const numUnreadRooms = notificationState.numUnreadStates; // we know that states === rooms here
if (platform) {
platform.setErrorStatus(state === SyncState.Error);
platform.setNotificationCount(notificationCount);
if (PlatformPeg.get()) {
PlatformPeg.get()!.setErrorStatus(state === SyncState.Error);
PlatformPeg.get()!.setNotificationCount(numUnreadRooms);
}
this.subTitleState = {
errorDidOccur: state === SyncState.Error,
notificationCount,
notificationsEnabled: notificationState.level >= NotificationLevel.Activity,
};
this.subTitleStatus = "";
if (state === SyncState.Error) {
this.subTitleStatus += `[${_t("common|offline")}] `;
}
if (numUnreadRooms > 0) {
this.subTitleStatus += `[${numUnreadRooms}]`;
} else if (notificationState.level >= NotificationLevel.Activity) {
this.subTitleStatus += `*`;
}
this.setPageSubtitle();
};

View File

@@ -315,7 +315,7 @@ function LocalRoomView(props: LocalRoomViewProps): ReactElement {
<div className="mx_RoomView mx_RoomView--local">
<ErrorBoundary>
<RoomHeader room={room} />
<main className="mx_RoomView_body" ref={props.roomView} aria-label={_t("room|room_content")}>
<main className="mx_RoomView_body" ref={props.roomView}>
<FileDropTarget parent={props.roomView.current} onFileDrop={props.onFileDrop} />
<div className="mx_RoomView_timeline">
<ScrollPanel className="mx_RoomView_messagePanel" resizeNotifier={props.resizeNotifier}>

View File

@@ -1,82 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type Room, type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
/**
* Interface used by admin tools container subcomponents props
*/
export interface RoomAdminToolsProps {
room: Room;
member: RoomMember;
isUpdating: boolean;
startUpdating: () => void;
stopUpdating: () => void;
}
/**
* Interface used by admin tools container props
*/
export interface RoomAdminToolsContainerProps {
room: Room;
member: RoomMember;
powerLevels: IPowerLevelsContent;
}
interface UserInfoAdminToolsContainerState {
shouldShowKickButton: boolean;
shouldShowBanButton: boolean;
shouldShowMuteButton: boolean;
shouldShowRedactButton: boolean;
isCurrentUserInTheRoom: boolean;
}
/**
* The view model for the user info admin tools container
* @param {RoomAdminToolsContainerProps} props - the object containing the necceray props for the view model
* @param {Room} props.room - the room that display the admin tools
* @param {RoomMember} props.member - the selected member
* @param {IPowerLevelsContent} props.powerLevels - current room power levels
* @returns {UserInfoAdminToolsContainerState} the user info admin tools container state
*/
export const useUserInfoAdminToolsContainerViewModel = (
props: RoomAdminToolsContainerProps,
): UserInfoAdminToolsContainerState => {
const cli = useMatrixClientContext();
const { room, member, powerLevels } = props;
const editPowerLevel =
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default;
// if these do not exist in the event then they should default to 50 as per the spec
const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels;
const me = room.getMember(cli.getUserId() || "");
const isCurrentUserInTheRoom = me !== null;
if (!isCurrentUserInTheRoom) {
return {
shouldShowKickButton: false,
shouldShowBanButton: false,
shouldShowMuteButton: false,
shouldShowRedactButton: false,
isCurrentUserInTheRoom: false,
};
}
const isMe = me.userId === member.userId;
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
return {
shouldShowKickButton: !isMe && canAffectUser && me.powerLevel >= kickPowerLevel,
shouldShowRedactButton: me.powerLevel >= redactPowerLevel && !room.isSpaceRoom(),
shouldShowBanButton: !isMe && canAffectUser && me.powerLevel >= banPowerLevel,
shouldShowMuteButton: !isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom(),
isCurrentUserInTheRoom,
};
};

View File

@@ -1,153 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "@sentry/browser";
import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import { bulkSpaceBehaviour } from "../../../../../utils/space";
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
export interface BanButtonState {
/**
* The function to call when the button is clicked
*/
onBanOrUnbanClick: () => Promise<void>;
/**
* The label of the ban button can be ban or unban
*/
banLabel: string;
}
/**
* The view model for the room ban button used in the UserInfoAdminToolsContainer
* @param {RoomAdminToolsProps} props - the object containing the necceray props for banButton the view model
* @param {Room} props.room - the room to ban/unban the user in
* @param {RoomMember} props.member - the member to ban/unban
* @param {boolean} props.isUpdating - whether the operation is currently in progress
* @param {function} props.startUpdating - callback function to start the operation
* @param {function} props.stopUpdating - callback function to stop the operation
* @returns {BanButtonState} the room ban/unban button state
*/
export const useBanButtonViewModel = (props: RoomAdminToolsProps): BanButtonState => {
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
const cli = useMatrixClientContext();
const isBanned = member.membership === KnownMembership.Ban;
let banLabel = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room");
if (isBanned) {
banLabel = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
}
const onBanOrUnbanClick = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();
const commonProps = {
member,
action: room.isSpaceRoom()
? isBanned
? _t("user_info|unban_button_space")
: _t("user_info|ban_button_space")
: isBanned
? _t("user_info|unban_button_room")
: _t("user_info|ban_button_room"),
title: isBanned
? _t("user_info|unban_room_confirm_title", { roomName: room.name })
: _t("user_info|ban_room_confirm_title", { roomName: room.name }),
askReason: !isBanned,
danger: !isBanned,
};
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
if (room.isSpaceRoom()) {
({ finished } = Modal.createDialog(
ConfirmSpaceUserActionDialog,
{
...commonProps,
space: room,
spaceChildFilter: isBanned
? (child: Room) => {
// Return true if the target member is banned and we have sufficient PL to unban
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership === KnownMembership.Ban &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
);
}
: (child: Room) => {
// Return true if the target member isn't banned and we have sufficient PL to ban
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership !== KnownMembership.Ban &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
);
},
allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"),
specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"),
warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"),
},
"mx_ConfirmSpaceUserActionDialog_wrapper",
));
} else {
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
}
const [proceed, reason, rooms = []] = await finished;
if (!proceed) {
stopUpdating();
return;
}
const fn = (roomId: string): Promise<unknown> => {
if (isBanned) {
return cli.unban(roomId, member.userId);
} else {
return cli.ban(roomId, member.userId, reason || undefined);
}
};
bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId))
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.info("Ban success");
},
function (err) {
logger.error("Ban error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("user_info|error_ban_user"),
});
},
)
.finally(() => {
stopUpdating();
});
};
return {
onBanOrUnbanClick,
banLabel,
};
};

View File

@@ -1,142 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "@sentry/browser";
import { type Room } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import { bulkSpaceBehaviour } from "../../../../../utils/space";
import ConfirmSpaceUserActionDialog from "../../../../views/dialogs/ConfirmSpaceUserActionDialog";
import ConfirmUserActionDialog from "../../../../views/dialogs/ConfirmUserActionDialog";
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
interface RoomKickButtonState {
/**
* The function to call when the button is clicked
*/
onKickClick: () => Promise<void>;
/**
* Whether the user can be kicked based on membership value. If the user already join or was invited, it can be kicked
*/
canUserBeKicked: boolean;
/**
* The label of the kick button can be kick or disinvite
*/
kickLabel: string;
}
/**
* The view model for the room kick button used in the UserInfoAdminToolsContainer
* @param {RoomAdminToolsProps} props - the object containing the necceray props for kickButton the view model
* @param {Room} props.room - the room to kick/disinvite the user from
* @param {RoomMember} props.member - the member to kick/disinvite
* @param {boolean} props.isUpdating - whether the operation is currently in progress
* @param {function} props.startUpdating - callback function to start the operation
* @param {function} props.stopUpdating - callback function to stop the operation
* @returns {KickButtonState} the room kick/disinvite button state
*/
export function useRoomKickButtonViewModel(props: RoomAdminToolsProps): RoomKickButtonState {
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
const cli = useMatrixClientContext();
const onKickClick = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();
const commonProps = {
member,
action: room.isSpaceRoom()
? member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_space")
: _t("user_info|kick_button_space")
: member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room")
: _t("user_info|kick_button_room"),
title:
member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room_name", { roomName: room.name })
: _t("user_info|kick_button_room_name", { roomName: room.name }),
askReason: member.membership === KnownMembership.Join,
danger: true,
};
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
if (room.isSpaceRoom()) {
({ finished } = Modal.createDialog(
ConfirmSpaceUserActionDialog,
{
...commonProps,
space: room,
spaceChildFilter: (child: Room) => {
// Return true if the target member is not banned and we have sufficient PL to ban them
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership === member.membership &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel)
);
},
allLabel: _t("user_info|kick_button_space_everything"),
specificLabel: _t("user_info|kick_space_specific"),
warningMessage: _t("user_info|kick_space_warning"),
},
"mx_ConfirmSpaceUserActionDialog_wrapper",
));
} else {
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
}
const [proceed, reason, rooms = []] = await finished;
if (!proceed) {
stopUpdating();
return;
}
bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.info("Kick success");
},
function (err) {
logger.error("Kick error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("user_info|error_kicking_user"),
description: err?.message ?? "Operation failed",
});
},
)
.finally(() => {
stopUpdating();
});
};
const canUserBeKicked = member.membership === KnownMembership.Invite || member.membership === KnownMembership.Join;
const kickLabel = room.isSpaceRoom()
? member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_space")
: _t("user_info|kick_button_space")
: member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room")
: _t("user_info|kick_button_room");
return {
onKickClick,
canUserBeKicked,
kickLabel,
};
}

View File

@@ -1,120 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { logger } from "@sentry/browser";
import { type RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import { _t } from "../../../../../languageHandler";
import Modal from "../../../../../Modal";
import ErrorDialog from "../../../../views/dialogs/ErrorDialog";
import { type RoomAdminToolsProps } from "./UserInfoAdminToolsContainerViewModel";
interface MuteButtonState {
/**
* Whether the member is in the roomn based on the membership value
*/
isMemberInTheRoom: boolean;
/**
* The label of the mute button can be mute or unmute
*/
muteLabel: string;
/**
* The function to call when the mute button is clicked
*/
onMuteButtonClick: () => Promise<void>;
}
/**
* The view model for the room mute button used in the UserInfoAdminToolsContainer
* @param {RoomAdminToolsProps} props - the object containing the necceray props for muteButton the view model
* @param {Room} props.room - the room to mute/unmute the user in
* @param {RoomMember} props.member - the member to mute/unmute
* @param {boolean} props.isUpdating - whether the operation is currently in progress
* @param {function} props.startUpdating - callback function to start the operation
* @param {function} props.stopUpdating - callback function to stop the operation
* @returns {MuteButtonState} the room mute/unmute button state
*/
export const useMuteButtonViewModel = (props: RoomAdminToolsProps): MuteButtonState => {
const { isUpdating, startUpdating, stopUpdating, room, member } = props;
const cli = useMatrixClientContext();
const isMuted = (member: RoomMember, powerLevelContent: IPowerLevelsContent): boolean => {
if (!powerLevelContent || !member) return false;
const levelToSend =
(powerLevelContent.events ? powerLevelContent.events["m.room.message"] : null) ||
powerLevelContent.events_default;
// levelToSend could be undefined as .events_default is optional. Coercing in this case using
// Number() would always return false, so this preserves behaviour
// FIXME: per the spec, if `events_default` is unset, it defaults to zero. If
// the member has a negative powerlevel, this will give an incorrect result.
if (levelToSend === undefined) return false;
return member.powerLevel < levelToSend;
};
const muted = isMuted(member, room.currentState.getStateEvents("m.room.power_levels", "")?.getContent() || {});
const muteLabel = muted ? _t("common|unmute") : _t("common|mute");
const isMemberInTheRoom = member.membership == KnownMembership.Join;
const onMuteButtonClick = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();
const roomId = member.roomId;
const target = member.userId;
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const powerLevels = powerLevelEvent?.getContent();
const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
let level;
if (muted) {
// unmute
level = levelToSend;
} else {
// mute
level = levelToSend - 1;
}
level = parseInt(level);
console.log("level", level);
if (isNaN(level)) {
stopUpdating();
return;
}
cli.setPowerLevel(roomId, target, level)
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.info("Mute toggle success");
},
function (err) {
logger.error("Mute error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("user_info|error_mute_user"),
});
},
)
.finally(() => {
stopUpdating();
});
};
return {
isMemberInTheRoom,
onMuteButtonClick,
muteLabel,
};
};

View File

@@ -1,39 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
Please see LICENSE files in the repository root for full details.
*/
import { type RoomMember } from "matrix-js-sdk/src/matrix";
import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext";
import Modal from "../../../../../Modal";
import BulkRedactDialog from "../../../../views/dialogs/BulkRedactDialog";
export interface RedactMessagesButtonState {
onRedactAllMessagesClick: () => void;
}
/**
* The view model for the redact messages button used in the UserInfoAdminToolsContainer
* @param {RoomMember} member - the selected member to redact messages for
* @returns {RedactMessagesButtonState} the redact messages button state
*/
export const useRedactMessagesButtonViewModel = (member: RoomMember): RedactMessagesButtonState => {
const cli = useMatrixClientContext();
const onRedactAllMessagesClick = (): void => {
const room = cli.getRoom(member.roomId);
if (!room) return;
Modal.createDialog(BulkRedactDialog, {
matrixClient: cli,
room,
member,
});
};
return {
onRedactAllMessagesClick,
};
};

View File

@@ -20,7 +20,7 @@ import Modal from "../../../Modal";
import InfoDialog from "./InfoDialog";
import Field from "../elements/Field";
import ErrorDialog from "./ErrorDialog";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
interface Props {
onFinished(confirm?: boolean): void;
@@ -37,7 +37,7 @@ export function ManualDeviceKeyVerificationDialog({ onFinished }: Readonly<Props
const [deviceId, setDeviceId] = useState("");
const [fingerprint, setFingerprint] = useState("");
const client = MatrixClientPeg.safeGet();
const client = useMatrixClientContext();
const onDialogFinished = useCallback(
async (confirm: boolean) => {

View File

@@ -76,7 +76,7 @@ const BaseCard: React.FC<IProps> = ({
data-testid="base-card-back-button"
onClick={onBackClick}
tooltip={label}
kind="secondary"
subtleBackground
>
<ChevronLeftIcon />
</IconButton>
@@ -92,7 +92,7 @@ const BaseCard: React.FC<IProps> = ({
onClick={onClose ?? closeRightPanel}
ref={closeButtonRef}
tooltip={closeLabel ?? _t("action|close")}
kind="secondary"
subtleBackground
>
<CloseIcon />
</IconButton>

View File

@@ -27,8 +27,6 @@ import AccessibleButton from "../elements/AccessibleButton";
import WidgetAvatar from "../avatars/WidgetAvatar";
import { IntegrationManagers } from "../../../integrations/IntegrationManagers";
import EmptyState from "./EmptyState";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts";
import { UIComponent } from "../../../settings/UIFeature.ts";
interface Props {
room: Room;
@@ -193,11 +191,9 @@ const ExtensionsCard: React.FC<Props> = ({ room, onClose }) => {
return (
<BaseCard header={_t("right_panel|extensions_button")} className="mx_ExtensionsCard" onClose={onClose}>
{shouldShowComponent(UIComponent.AddIntegrations) && (
<Button size="sm" onClick={onManageIntegrations} kind="secondary" Icon={PlusIcon}>
{_t("right_panel|add_integrations")}
</Button>
)}
<Button size="sm" onClick={onManageIntegrations} kind="secondary" Icon={PlusIcon}>
{_t("right_panel|add_integrations")}
</Button>
{body}
</BaseCard>
);

View File

@@ -34,6 +34,10 @@ import MentionIcon from "@vector-im/compound-design-tokens/assets/web/icons/ment
import InviteIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import BlockIcon from "@vector-im/compound-design-tokens/assets/web/icons/block";
import DeleteIcon from "@vector-im/compound-design-tokens/assets/web/icons/delete";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ChatProblemIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat-problem";
import VisibilityOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/visibility-off";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import dis from "../../../dispatcher/dispatcher";
import Modal from "../../../Modal";
@@ -57,11 +61,15 @@ import Spinner from "../elements/Spinner";
import PowerSelector from "../elements/PowerSelector";
import MemberAvatar from "../avatars/MemberAvatar";
import PresenceLabel from "../rooms/PresenceLabel";
import BulkRedactDialog from "../dialogs/BulkRedactDialog";
import { ShareDialog } from "../dialogs/ShareDialog";
import ErrorDialog from "../dialogs/ErrorDialog";
import QuestionDialog from "../dialogs/QuestionDialog";
import ConfirmUserActionDialog from "../dialogs/ConfirmUserActionDialog";
import { mediaFromMxc } from "../../../customisations/Media";
import { type ComposerInsertPayload } from "../../../dispatcher/payloads/ComposerInsertPayload";
import ConfirmSpaceUserActionDialog from "../dialogs/ConfirmSpaceUserActionDialog";
import { bulkSpaceBehaviour } from "../../../utils/space";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { TimelineRenderingType } from "../../../contexts/RoomContext";
@@ -75,7 +83,6 @@ import { SdkContextClass } from "../../../contexts/SDKContext";
import { Flex } from "../../utils/Flex";
import CopyableText from "../elements/CopyableText";
import { useUserTimezone } from "../../../hooks/useUserTimezone";
import { UserInfoAdminToolsContainer } from "./user_info/UserInfoAdminToolsContainer";
export interface IDevice extends Device {
ambiguous?: boolean;
@@ -307,7 +314,7 @@ const Container: React.FC<{
return <div className={classes}>{children}</div>;
};
export interface IPowerLevelsContent {
interface IPowerLevelsContent {
events?: Record<string, number>;
// eslint-disable-next-line camelcase
users_default?: number;
@@ -361,6 +368,362 @@ export const useRoomPowerLevels = (cli: MatrixClient, room: Room): IPowerLevelsC
return powerLevels;
};
interface IBaseProps {
member: RoomMember;
isUpdating: boolean;
startUpdating(): void;
stopUpdating(): void;
}
export const RoomKickButton = ({
room,
member,
isUpdating,
startUpdating,
stopUpdating,
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element | null => {
const cli = useContext(MatrixClientContext);
// check if user can be kicked/disinvited
if (member.membership !== KnownMembership.Invite && member.membership !== KnownMembership.Join) return <></>;
const onKick = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();
const commonProps = {
member,
action: room.isSpaceRoom()
? member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_space")
: _t("user_info|kick_button_space")
: member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room")
: _t("user_info|kick_button_room"),
title:
member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room_name", { roomName: room.name })
: _t("user_info|kick_button_room_name", { roomName: room.name }),
askReason: member.membership === KnownMembership.Join,
danger: true,
};
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
if (room.isSpaceRoom()) {
({ finished } = Modal.createDialog(
ConfirmSpaceUserActionDialog,
{
...commonProps,
space: room,
spaceChildFilter: (child: Room) => {
// Return true if the target member is not banned and we have sufficient PL to ban them
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership === member.membership &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("kick", myMember.powerLevel)
);
},
allLabel: _t("user_info|kick_button_space_everything"),
specificLabel: _t("user_info|kick_space_specific"),
warningMessage: _t("user_info|kick_space_warning"),
},
"mx_ConfirmSpaceUserActionDialog_wrapper",
));
} else {
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
}
const [proceed, reason, rooms = []] = await finished;
if (!proceed) {
stopUpdating();
return;
}
bulkSpaceBehaviour(room, rooms, (room) => cli.kick(room.roomId, member.userId, reason || undefined))
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.log("Kick success");
},
function (err) {
logger.error("Kick error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("user_info|error_kicking_user"),
description: err?.message ?? "Operation failed",
});
},
)
.finally(() => {
stopUpdating();
});
};
const kickLabel = room.isSpaceRoom()
? member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_space")
: _t("user_info|kick_button_space")
: member.membership === KnownMembership.Invite
? _t("user_info|disinvite_button_room")
: _t("user_info|kick_button_room");
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onKick();
}}
disabled={isUpdating}
label={kickLabel}
kind="critical"
Icon={LeaveIcon}
/>
);
};
const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
const cli = useContext(MatrixClientContext);
const onRedactAllMessages = (): void => {
const room = cli.getRoom(member.roomId);
if (!room) return;
Modal.createDialog(BulkRedactDialog, {
matrixClient: cli,
room,
member,
});
};
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onRedactAllMessages();
}}
label={_t("user_info|redact_button")}
kind="critical"
Icon={CloseIcon}
/>
);
};
export const BanToggleButton = ({
room,
member,
isUpdating,
startUpdating,
stopUpdating,
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element => {
const cli = useContext(MatrixClientContext);
const isBanned = member.membership === KnownMembership.Ban;
const onBanOrUnban = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();
const commonProps = {
member,
action: room.isSpaceRoom()
? isBanned
? _t("user_info|unban_button_space")
: _t("user_info|ban_button_space")
: isBanned
? _t("user_info|unban_button_room")
: _t("user_info|ban_button_room"),
title: isBanned
? _t("user_info|unban_room_confirm_title", { roomName: room.name })
: _t("user_info|ban_room_confirm_title", { roomName: room.name }),
askReason: !isBanned,
danger: !isBanned,
};
let finished: Promise<[success?: boolean, reason?: string, rooms?: Room[]]>;
if (room.isSpaceRoom()) {
({ finished } = Modal.createDialog(
ConfirmSpaceUserActionDialog,
{
...commonProps,
space: room,
spaceChildFilter: isBanned
? (child: Room) => {
// Return true if the target member is banned and we have sufficient PL to unban
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership === KnownMembership.Ban &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
);
}
: (child: Room) => {
// Return true if the target member isn't banned and we have sufficient PL to ban
const myMember = child.getMember(cli.credentials.userId || "");
const theirMember = child.getMember(member.userId);
return (
!!myMember &&
!!theirMember &&
theirMember.membership !== KnownMembership.Ban &&
myMember.powerLevel > theirMember.powerLevel &&
child.currentState.hasSufficientPowerLevelFor("ban", myMember.powerLevel)
);
},
allLabel: isBanned ? _t("user_info|unban_space_everything") : _t("user_info|ban_space_everything"),
specificLabel: isBanned ? _t("user_info|unban_space_specific") : _t("user_info|ban_space_specific"),
warningMessage: isBanned ? _t("user_info|unban_space_warning") : _t("user_info|kick_space_warning"),
},
"mx_ConfirmSpaceUserActionDialog_wrapper",
));
} else {
({ finished } = Modal.createDialog(ConfirmUserActionDialog, commonProps));
}
const [proceed, reason, rooms = []] = await finished;
if (!proceed) {
stopUpdating();
return;
}
const fn = (roomId: string): Promise<unknown> => {
if (isBanned) {
return cli.unban(roomId, member.userId);
} else {
return cli.ban(roomId, member.userId, reason || undefined);
}
};
bulkSpaceBehaviour(room, rooms, (room) => fn(room.roomId))
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.log("Ban success");
},
function (err) {
logger.error("Ban error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("user_info|error_ban_user"),
});
},
)
.finally(() => {
stopUpdating();
});
};
let label = room.isSpaceRoom() ? _t("user_info|ban_button_space") : _t("user_info|ban_button_room");
if (isBanned) {
label = room.isSpaceRoom() ? _t("user_info|unban_button_space") : _t("user_info|unban_button_room");
}
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onBanOrUnban();
}}
disabled={isUpdating}
label={label}
kind="critical"
Icon={ChatProblemIcon}
/>
);
};
interface IBaseRoomProps extends IBaseProps {
room: Room;
powerLevels: IPowerLevelsContent;
children?: ReactNode;
}
// We do not show a Mute button for ourselves so it doesn't need to handle warning self demotion
const MuteToggleButton: React.FC<IBaseRoomProps> = ({
member,
room,
powerLevels,
isUpdating,
startUpdating,
stopUpdating,
}) => {
const cli = useContext(MatrixClientContext);
// Don't show the mute/unmute option if the user is not in the room
if (member.membership !== KnownMembership.Join) return null;
const muted = isMuted(member, powerLevels);
const onMuteToggle = async (): Promise<void> => {
if (isUpdating) return; // only allow one operation at a time
startUpdating();
const roomId = member.roomId;
const target = member.userId;
const powerLevelEvent = room.currentState.getStateEvents("m.room.power_levels", "");
const powerLevels = powerLevelEvent?.getContent();
const levelToSend = powerLevels?.events?.["m.room.message"] ?? powerLevels?.events_default;
let level;
if (muted) {
// unmute
level = levelToSend;
} else {
// mute
level = levelToSend - 1;
}
level = parseInt(level);
if (isNaN(level)) {
stopUpdating();
return;
}
cli.setPowerLevel(roomId, target, level)
.then(
() => {
// NO-OP; rely on the m.room.member event coming down else we could
// get out of sync if we force setState here!
logger.log("Mute toggle success");
},
function (err) {
logger.error("Mute error: " + err);
Modal.createDialog(ErrorDialog, {
title: _t("common|error"),
description: _t("user_info|error_mute_user"),
});
},
)
.finally(() => {
stopUpdating();
});
};
const muteLabel = muted ? _t("common|unmute") : _t("common|mute");
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
onMuteToggle();
}}
disabled={isUpdating}
label={muteLabel}
kind="critical"
Icon={VisibilityOffIcon}
/>
);
};
const IgnoreToggleButton: React.FC<{
member: User | RoomMember;
}> = ({ member }) => {
@@ -423,6 +786,96 @@ const IgnoreToggleButton: React.FC<{
);
};
export const RoomAdminToolsContainer: React.FC<IBaseRoomProps> = ({
room,
children,
member,
isUpdating,
startUpdating,
stopUpdating,
powerLevels,
}) => {
const cli = useContext(MatrixClientContext);
let kickButton;
let banButton;
let muteButton;
let redactButton;
const editPowerLevel =
(powerLevels.events ? powerLevels.events["m.room.power_levels"] : null) || powerLevels.state_default;
// if these do not exist in the event then they should default to 50 as per the spec
const { ban: banPowerLevel = 50, kick: kickPowerLevel = 50, redact: redactPowerLevel = 50 } = powerLevels;
const me = room.getMember(cli.getUserId() || "");
if (!me) {
// we aren't in the room, so return no admin tooling
return <div />;
}
const isMe = me.userId === member.userId;
const canAffectUser = member.powerLevel < me.powerLevel || isMe;
if (!isMe && canAffectUser && me.powerLevel >= kickPowerLevel) {
kickButton = (
<RoomKickButton
room={room}
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (me.powerLevel >= redactPowerLevel && !room.isSpaceRoom()) {
redactButton = (
<RedactMessagesButton
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (!isMe && canAffectUser && me.powerLevel >= banPowerLevel) {
banButton = (
<BanToggleButton
room={room}
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (!isMe && canAffectUser && me.powerLevel >= Number(editPowerLevel) && !room.isSpaceRoom()) {
muteButton = (
<MuteToggleButton
member={member}
room={room}
powerLevels={powerLevels}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (kickButton || banButton || muteButton || redactButton || children) {
return (
<Container>
{muteButton}
{redactButton}
{kickButton}
{banButton}
{children}
</Container>
);
}
return <div />;
};
const useIsSynapseAdmin = (cli?: MatrixClient): boolean => {
return useAsyncMemo(async () => (cli ? cli.isSynapseAdministrator().catch(() => false) : false), [cli], false);
};
@@ -830,7 +1283,7 @@ const BasicUserInfo: React.FC<{
}
adminToolsContainer = (
<UserInfoAdminToolsContainer
<RoomAdminToolsContainer
powerLevels={powerLevels}
member={member as RoomMember}
room={room}
@@ -839,7 +1292,7 @@ const BasicUserInfo: React.FC<{
stopUpdating={stopUpdating}
>
{synapseDeactivateButton}
</UserInfoAdminToolsContainer>
</RoomAdminToolsContainer>
);
} else if (synapseDeactivateButton) {
adminToolsContainer = <Container>{synapseDeactivateButton}</Container>;

View File

@@ -1,220 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type ReactNode } from "react";
import classNames from "classnames";
import { type RoomMember, type Room } from "matrix-js-sdk/src/matrix";
import { MenuItem } from "@vector-im/compound-web";
import CloseIcon from "@vector-im/compound-design-tokens/assets/web/icons/close";
import ChatProblemIcon from "@vector-im/compound-design-tokens/assets/web/icons/chat-problem";
import VisibilityOffIcon from "@vector-im/compound-design-tokens/assets/web/icons/visibility-off";
import LeaveIcon from "@vector-im/compound-design-tokens/assets/web/icons/leave";
import { _t } from "../../../../languageHandler";
import { type IPowerLevelsContent } from "../UserInfo";
import { useUserInfoAdminToolsContainerViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useMuteButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
import { useBanButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
import { useRoomKickButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
import { useRedactMessagesButtonViewModel } from "../../../viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
const Container: React.FC<{
children: ReactNode;
className?: string;
}> = ({ children, className }) => {
const classes = classNames("mx_UserInfo_container", className);
return <div className={classes}>{children}</div>;
};
interface IBaseProps {
member: RoomMember;
isUpdating: boolean;
startUpdating(): void;
stopUpdating(): void;
}
export const RoomKickButton = ({
room,
member,
isUpdating,
startUpdating,
stopUpdating,
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element | null => {
const vm = useRoomKickButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating });
// check if user can be kicked/disinvited
if (!vm.canUserBeKicked) return <></>;
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onKickClick();
}}
disabled={isUpdating}
label={vm.kickLabel}
kind="critical"
Icon={LeaveIcon}
/>
);
};
const RedactMessagesButton: React.FC<IBaseProps> = ({ member }) => {
const vm = useRedactMessagesButtonViewModel(member);
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onRedactAllMessagesClick();
}}
label={_t("user_info|redact_button")}
kind="critical"
Icon={CloseIcon}
/>
);
};
export const BanToggleButton = ({
room,
member,
isUpdating,
startUpdating,
stopUpdating,
}: Omit<IBaseRoomProps, "powerLevels">): JSX.Element => {
const vm = useBanButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating });
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onBanOrUnbanClick();
}}
disabled={isUpdating}
label={vm.banLabel}
kind="critical"
Icon={ChatProblemIcon}
/>
);
};
interface IBaseRoomProps extends IBaseProps {
room: Room;
powerLevels: IPowerLevelsContent;
children?: ReactNode;
}
// We do not show a Mute button for ourselves so it doesn't need to handle warning self demotion
const MuteToggleButton: React.FC<IBaseRoomProps> = ({
member,
room,
powerLevels,
isUpdating,
startUpdating,
stopUpdating,
}) => {
const vm = useMuteButtonViewModel({ room, member, isUpdating, startUpdating, stopUpdating });
// Don't show the mute/unmute option if the user is not in the room
if (!vm.isMemberInTheRoom) return null;
return (
<MenuItem
role="button"
onSelect={async (ev) => {
ev.preventDefault();
vm.onMuteButtonClick();
}}
disabled={isUpdating}
label={vm.muteLabel}
kind="critical"
Icon={VisibilityOffIcon}
/>
);
};
export const UserInfoAdminToolsContainer: React.FC<IBaseRoomProps> = ({
room,
children,
member,
isUpdating,
startUpdating,
stopUpdating,
powerLevels,
}) => {
let kickButton;
let banButton;
let muteButton;
let redactButton;
const vm = useUserInfoAdminToolsContainerViewModel({ room, member, powerLevels });
if (!vm.isCurrentUserInTheRoom) {
// we aren't in the room, so return no admin tooling
return <div />;
}
if (vm.shouldShowKickButton) {
kickButton = (
<RoomKickButton
room={room}
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (vm.shouldShowRedactButton) {
redactButton = (
<RedactMessagesButton
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (vm.shouldShowBanButton) {
banButton = (
<BanToggleButton
room={room}
member={member}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (vm.shouldShowMuteButton) {
muteButton = (
<MuteToggleButton
member={member}
room={room}
powerLevels={powerLevels}
isUpdating={isUpdating}
startUpdating={startUpdating}
stopUpdating={stopUpdating}
/>
);
}
if (kickButton || banButton || muteButton || redactButton || children) {
return (
<Container>
{muteButton}
{redactButton}
{kickButton}
{banButton}
{children}
</Container>
);
}
return <div />;
};

View File

@@ -76,14 +76,6 @@ export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefin
filter={vm.activePrimaryFilter}
/>
);
case FilterKey.LowPriorityFilter:
return (
<ActionPlaceholder
title={_t("room_list|empty|no_lowpriority")}
action={_t("room_list|empty|show_activity")}
filter={vm.activePrimaryFilter}
/>
);
default:
return undefined;
}

View File

@@ -13,7 +13,6 @@ import { RoomListSearch } from "./RoomListSearch";
import { RoomListHeaderView } from "./RoomListHeaderView";
import { RoomListView } from "./RoomListView";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
type RoomListPanelProps = {
/**
@@ -31,11 +30,11 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
return (
<Flex
as="nav"
as="section"
className="mx_RoomListPanel"
data-testid="room-list-panel"
direction="column"
align="stretch"
aria-label={_t("room_list|list_title")}
>
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
<RoomListHeaderView />

View File

@@ -5,9 +5,8 @@
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, useEffect, useId, useRef, useState, type RefObject } from "react";
import { ChatFilter, IconButton } from "@vector-im/compound-web";
import ChevronDownIcon from "@vector-im/compound-design-tokens/assets/web/icons/chevron-down";
import React, { type JSX } from "react";
import { ChatFilter } from "@vector-im/compound-web";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../utils/Flex";
@@ -24,146 +23,23 @@ interface RoomListPrimaryFiltersProps {
* The primary filters for the room list
*/
export function RoomListPrimaryFilters({ vm }: RoomListPrimaryFiltersProps): JSX.Element {
const id = useId();
const [isExpanded, setIsExpanded] = useState(false);
const { ref, isWrapping: displayChevron, wrappingIndex } = useCollapseFilters<HTMLUListElement>(isExpanded);
const filters = useVisibleFilters(vm.primaryFilters, wrappingIndex);
return (
<Flex
as="ul"
role="listbox"
aria-label={_t("room_list|primary_filters")}
className="mx_RoomListPrimaryFilters"
data-testid="primary-filters"
gap="var(--cpd-space-3x)"
direction="row-reverse"
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
>
{displayChevron && (
<IconButton
kind="secondary"
aria-expanded={isExpanded}
aria-controls={id}
className="mx_RoomListPrimaryFilters_IconButton"
aria-label={isExpanded ? _t("room_list|collapse_filters") : _t("room_list|expand_filters")}
size="28px"
onClick={() => setIsExpanded((_expanded) => !_expanded)}
>
<ChevronDownIcon />
</IconButton>
)}
<Flex
id={id}
as="ul"
role="listbox"
aria-label={_t("room_list|primary_filters")}
align="center"
gap="var(--cpd-space-2x)"
wrap="wrap"
ref={ref}
>
{filters.map((filter, i) => (
<li role="option" aria-selected={filter.active} key={i}>
<ChatFilter selected={filter.active} onClick={() => filter.toggle()}>
{filter.name}
</ChatFilter>
</li>
))}
</Flex>
{vm.primaryFilters.map((filter) => (
<li role="option" aria-selected={filter.active} key={filter.name}>
<ChatFilter selected={filter.active} onClick={filter.toggle}>
{filter.name}
</ChatFilter>
</li>
))}
</Flex>
);
}
/**
* A hook to manage the wrapping of filters in the room list.
* It observes the filter list and hides filters that are wrapping when the list is not expanded.
* @param isExpanded
* @returns an object containing:
* - `ref`: a ref to put on the filter list element
* - `isWrapping`: a boolean indicating if the filters are wrapping
* - `wrappingIndex`: the index of the first filter that is wrapping
*/
function useCollapseFilters<T extends HTMLElement>(
isExpanded: boolean,
): { ref: RefObject<T | null>; isWrapping: boolean; wrappingIndex: number } {
const ref = useRef<T>(null);
const [isWrapping, setIsWrapping] = useState(false);
const [wrappingIndex, setWrappingIndex] = useState(-1);
useEffect(() => {
if (!ref.current) return;
const hideFilters = (list: Element): void => {
let isWrapping = false;
Array.from(list.children).forEach((node, i): void => {
const child = node as HTMLElement;
const wrappingClass = "mx_RoomListPrimaryFilters_wrapping";
child.setAttribute("aria-hidden", "false");
child.classList.remove(wrappingClass);
// If the filter list is expanded, all filters are visible
if (isExpanded) return;
// If the previous element is on the left element of the current one, it means that the filter is wrapping
const previousSibling = child.previousElementSibling as HTMLElement | null;
if (previousSibling && child.offsetLeft < previousSibling.offsetLeft) {
if (!isWrapping) setWrappingIndex(i);
isWrapping = true;
}
// If the filter is wrapping, we hide it
child.classList.toggle(wrappingClass, isWrapping);
child.setAttribute("aria-hidden", isWrapping.toString());
});
if (!isWrapping) setWrappingIndex(-1);
setIsWrapping(isExpanded || isWrapping);
};
hideFilters(ref.current);
const observer = new ResizeObserver((entries) => entries.forEach((entry) => hideFilters(entry.target)));
observer.observe(ref.current);
return () => {
observer.disconnect();
};
}, [isExpanded]);
return { ref, isWrapping, wrappingIndex };
}
/**
* A hook to sort the filters by active state.
* The list is sorted if the current filter index is greater than or equal to the wrapping index.
* If the wrapping index is -1, the filters are not sorted.
*
* @param filters - the list of filters to sort.
* @param wrappingIndex - the index of the first filter that is wrapping.
*/
export function useVisibleFilters(
filters: RoomListViewState["primaryFilters"],
wrappingIndex: number,
): RoomListViewState["primaryFilters"] {
// By default, the filters are not sorted
const [sortedFilters, setSortedFilters] = useState(filters);
useEffect(() => {
const isActiveFilterWrapping = filters.findIndex((f) => f.active) >= wrappingIndex;
// If the active filter is not wrapping, we don't need to sort the filters
if (!isActiveFilterWrapping || wrappingIndex === -1) {
setSortedFilters(filters);
return;
}
// Sort the filters with the current filter at first position
setSortedFilters(
filters.slice().sort((filterA, filterB) => {
// If the filter is active, it should be at the top of the list
if (filterA.active && !filterB.active) return -1;
if (!filterA.active && filterB.active) return 1;
// If both filters are active or not, keep their original order
return 0;
}),
);
}, [filters, wrappingIndex]);
return sortedFilters;
}

View File

@@ -43,7 +43,6 @@ import ViewSourceEvent from "../components/views/messages/ViewSourceEvent";
import { shouldDisplayAsBeaconTile } from "../utils/beacon/timeline";
import { ElementCall } from "../models/Call";
import { type IBodyProps } from "../components/views/messages/IBodyProps";
import ModuleApi from "../modules/Api";
// Subset of EventTile's IProps plus some mixins
export interface EventTileTypeProps
@@ -258,14 +257,7 @@ export function renderTile(
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
if (!factory) {
// If we don't have a factory for this event, attempt
// to find a custom component that can render it.
// Will return null if no custom component can render it.
return ModuleApi.customComponents.renderMessage({
mxEvent: props.mxEvent,
});
}
if (!factory) return undefined;
// Note that we split off the ones we actually care about here just to be sure that we're
// not going to accidentally send things we shouldn't from lazy callers. Eg: EventTile's
@@ -292,48 +284,36 @@ export function renderTile(
case TimelineRenderingType.File:
case TimelineRenderingType.Notification:
case TimelineRenderingType.Thread:
return ModuleApi.customComponents.renderMessage(
{
mxEvent: props.mxEvent,
},
(origProps) =>
factory(props.ref, {
// We only want a subset of props, so we don't end up causing issues for downstream components.
mxEvent,
highlights,
highlightLink,
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
editState,
replacingEventId,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
permalinkCreator,
inhibitInteraction,
}),
);
// We only want a subset of props, so we don't end up causing issues for downstream components.
return factory(props.ref, {
mxEvent,
highlights,
highlightLink,
showUrlPreview,
editState,
replacingEventId,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
permalinkCreator,
inhibitInteraction,
});
default:
return ModuleApi.customComponents.renderMessage(
{
mxEvent: props.mxEvent,
},
(origProps) =>
factory(ref, {
// NEARLY ALL THE OPTIONS!
mxEvent,
forExport,
replacingEventId,
editState,
highlights,
highlightLink,
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
permalinkCreator,
callEventGrouper,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
timestamp,
inhibitInteraction,
}),
);
// NEARLY ALL THE OPTIONS!
return factory(ref, {
mxEvent,
forExport,
replacingEventId,
editState,
highlights,
highlightLink,
showUrlPreview,
permalinkCreator,
callEventGrouper,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
timestamp,
inhibitInteraction,
});
}
}
@@ -352,14 +332,7 @@ export function renderReplyTile(
cli = cli ?? MatrixClientPeg.safeGet(); // because param defaults don't do the correct thing
const factory = pickFactory(props.mxEvent, cli, showHiddenEvents);
if (!factory) {
// If we don't have a factory for this event, attempt
// to find a custom component that can render it.
// Will return null if no custom component can render it.
return ModuleApi.customComponents.renderMessage({
mxEvent: props.mxEvent,
});
}
if (!factory) return undefined;
// See renderTile() for why we split off so much
const {
@@ -377,25 +350,19 @@ export function renderReplyTile(
permalinkCreator,
} = props;
return ModuleApi.customComponents.renderMessage(
{
mxEvent: props.mxEvent,
},
(origProps) =>
factory(ref, {
mxEvent,
highlights,
highlightLink,
showUrlPreview: origProps?.showUrlPreview ?? showUrlPreview,
overrideBodyTypes,
overrideEventTypes,
replacingEventId,
maxImageHeight,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
permalinkCreator,
}),
);
return factory(ref, {
mxEvent,
highlights,
highlightLink,
showUrlPreview,
overrideBodyTypes,
overrideEventTypes,
replacingEventId,
maxImageHeight,
getRelationsForEvent,
isSeeingThroughMessageHiddenForModeration,
permalinkCreator,
});
}
// XXX: this'll eventually be dynamic based on the fields once we have extensible event types
@@ -419,12 +386,6 @@ export function haveRendererForEvent(
return false;
}
// Check to see if we have any hints for this message, which indicates
// there is a custom renderer for the event.
if (ModuleApi.customComponents.getHintsForMessage(mxEvent)) {
return true;
}
// No tile for replacement events since they update the original tile
if (mxEvent.isRelation(RelationType.Replace)) return false;

View File

@@ -38,10 +38,6 @@ function getPinnedEventIds(room?: Room): string[] {
.getState(EventTimeline.FORWARDS)
?.getStateEvents(EventType.RoomPinnedEvents, "")
?.getContent()?.pinned ?? [];
if (!Array.isArray(eventIds)) {
logger.warn("Encountered invalid pinned events state in room", room?.roomId, eventIds);
return [];
}
// Limit the number of pinned events to 100
return eventIds.slice(0, 100);
}

View File

@@ -788,7 +788,6 @@
"cross_signing_status": "Stav křížového podepisování:",
"cross_signing_untrusted": "Váš účet má v bezpečném úložišti identitu pro křížový podpis, ale v této relaci jí zatím nevěříte.",
"crypto_not_available": "Kryptografický modul není k dispozici",
"device_id": "ID zařízení",
"key_backup_active_version": "Verze aktivní zálohy:",
"key_backup_active_version_none": "Žádné",
"key_backup_inactive_warning": "Vaše klíče nejsou z této relace zálohovány.",
@@ -1959,7 +1958,6 @@
},
"face_pile_tooltip_shortcut": "Včetně %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Včetně vás, %(commaSeparatedMembers)s",
"failed_determine_user": "Nelze určit, kterého uživatele ignorovat, protože se změnila událost člena.",
"failed_reject_invite": "Nepodařilo se odmítnout pozvánku",
"forget_room": "Zapomenout na tuto místnost",
"forget_space": "Zapomenout tento prostor",
@@ -2052,7 +2050,6 @@
"read_topic": "Klikněte pro přečtení tématu",
"rejecting": "Odmítání pozvánky…",
"rejoin_button": "Znovu vstoupit",
"room_is_low_priority": "Toto je místnost s nízkou prioritou",
"search": {
"all_rooms_button": "Vyhledávat ve všech místnostech",
"placeholder": "Hledat zprávy…",
@@ -2125,7 +2122,6 @@
"filters": {
"favourite": "Oblíbené",
"invites": "Pozvánky",
"low_priority": "Nízká priorita",
"mentions": "Zmínky",
"people": "Lidé",
"rooms": "Místnosti",
@@ -2693,9 +2689,6 @@
"inline_url_previews_room": "Povolit náhledy URL adres pro členy této místnosti jako výchozí",
"inline_url_previews_room_account": "Povolit náhledy URL adres pro tuto místnost (ovlivňuje pouze vás)",
"insert_trailing_colon_mentions": "Vložit dvojtečku za zmínku o uživateli na začátku zprávy",
"invite_controls": {
"default_label": "Povolit uživatelům pozvat vás do místností"
},
"jump_to_bottom_on_send": "Po odeslání zprávy přejít na konec časové osy",
"key_backup": {
"backup_in_progress": "Klíče se zálohují (první záloha může trvat pár minut).",
@@ -2762,7 +2755,6 @@
"show_in_private": "V soukromých místnostech",
"show_media": "Vždy zobrazit"
},
"not_supported": "Váš server tuto funkci neimplementuje.",
"notifications": {
"default_setting_description": "Toto nastavení se ve výchozím stavu použije pro všechny vaše místnosti.",
"default_setting_section": "Chci být upozorňován na (Výchozí nastavení)",
@@ -2820,7 +2812,6 @@
"voip": "Hlasové a video hovory"
},
"preferences": {
"Electron.enableContentProtection": "Zabraňte zachycení obsahu okna jinými aplikacemi",
"Electron.enableHardwareAcceleration": "Povolit hardwarovou akceleraci (restaurtujte %(appName)s, aby se změna projevila)",
"always_show_menu_bar": "Vždy zobrazovat horní lištu okna",
"autocomplete_delay": "Zpožnění našeptávače (ms)",
@@ -2994,7 +2985,6 @@
"show_chat_effects": "Zobrazit efekty chatu (animace např. při přijetí konfet)",
"show_displayname_changes": "Zobrazovat změny zobrazovaného jména",
"show_join_leave": "Zobrazit zprávy o vstupu/odchodu (pozvánky/odebrání/vykázání nejsou ovlivněny)",
"show_message_previews": "Zobrazit náhledy zpráv",
"show_nsfw_content": "Zobrazit NSFW obsah",
"show_read_receipts": "Zobrazovat potvrzení o přečtení",
"show_redaction_placeholder": "Zobrazovat smazané zprávy",
@@ -3141,7 +3131,7 @@
"upgraderoom": "Aktualizuje místnost na novou verzi",
"upgraderoom_permission_error": "Na provedení tohoto příkazu nemáte dostatečná oprávnění.",
"usage": "Použití",
"verify": "Ruční ověření jednoho ze svých vlastních zařízení",
"verify": "Ověří uživatele, relaci a veřejné klíče",
"view": "Zobrazí místnost s danou adresou",
"whois": "Zobrazuje informace o uživateli"
},

View File

@@ -2021,7 +2021,6 @@
"jump_to_bottom_on_send": "Μεταβείτε στο τέλος του χρονολογίου όταν στέλνετε ένα μήνυμα",
"key_backup": {
"backup_in_progress": "Δημιουργούνται αντίγραφα ασφαλείας των κλειδιών σας (το πρώτο αντίγραφο ασφαλείας μπορεί να διαρκέσει μερικά λεπτά).",
"backup_starting": "Έναρξη δημιουργίας αντιγράφων ασφαλείας...",
"backup_success": "Επιτυχία!",
"cannot_create_backup": "Δεν είναι δυνατή η δημιουργία αντιγράφου ασφαλείας κλειδιού",
"create_title": "Δημιουργία αντιγράφου ασφαλείας κλειδιού",
@@ -2031,8 +2030,8 @@
"description": "Προστατευτείτε από την απώλεια πρόσβασης σε κρυπτογραφημένα μηνύματα και δεδομένα, δημιουργώντας αντίγραφα ασφαλείας των κλειδιών κρυπτογράφησης στον διακομιστή σας.",
"enter_phrase_title": "Εισαγάγετε τη Φράση Ασφαλείας",
"enter_phrase_to_confirm": "Εισαγάγετε τη Φράση Ασφαλείας σας για δεύτερη φορά για να την επιβεβαιώσετε.",
"generate_security_key_description": "Θα δημιουργήσουμε ένα κλειδί ανάκτησης για να το αποθηκεύσετε σε ασφαλές μέρος, όπως έναν διαχειριστή κωδικών πρόσβασης ή ένα χρηματοκιβώτιο.",
"generate_security_key_title": "Δημιουργήστε ένα Κλειδί Ανάκτησης",
"generate_security_key_description": "Θα δημιουργήσουμε ένα κλειδί ασφαλείας για να το αποθηκεύσετε σε ασφαλές μέρος, όπως έναν διαχειριστή κωδικών πρόσβασης ή ένα χρηματοκιβώτιο.",
"generate_security_key_title": "Δημιουργήστε ένα κλειδί ασφαλείας",
"pass_phrase_match_failed": "Αυτό δεν ταιριάζει.",
"pass_phrase_match_success": "Ταιριάζει!",
"phrase_strong_enough": "Τέλεια! Αυτή η Φράση Ασφαλείας φαίνεται αρκετά ισχυρή.",
@@ -2045,7 +2044,7 @@
"title_set_phrase": "Ορίστε μια Φράση Ασφαλείας",
"unable_to_setup": "Δεν είναι δυνατή η ρύθμιση του μυστικού χώρου αποθήκευσης",
"use_different_passphrase": "Να χρησιμοποιηθεί διαφορετική φράση;",
"use_phrase_only_you_know": "Χρησιμοποιήστε μια μυστική φράση που γνωρίζετε μόνο εσείς και, προαιρετικά, αποθηκεύστε ένα Κλειδί Ανάκτησης για να το χρησιμοποιήσετε ως αντίγραφο ασφαλείας."
"use_phrase_only_you_know": "Χρησιμοποιήστε μια μυστική φράση που γνωρίζετε μόνο εσείς και προαιρετικά αποθηκεύστε ένα κλειδί ασφαλείας για να το χρησιμοποιήσετε για τη δημιουργία αντιγράφων ασφαλείας."
}
},
"key_export_import": {
@@ -2146,7 +2145,6 @@
"prompt_invite": "Ερώτηση πριν από την αποστολή προσκλήσεων σε δυνητικά μη έγκυρα αναγνωριστικά matrix",
"replace_plain_emoji": "Αυτόματη αντικατάσταση απλού κειμένου Emoji",
"security": {
"analytics_description": "Μοιραστείτε ανώνυμα δεδομένα για να μας βοηθήσετε να εντοπίσουμε προβλήματα. Τίποτα προσωπικό. Χωρίς τρίτους.",
"bulk_options_accept_all_invites": "Αποδεχτείτε όλες τις %(invitedRooms)sπροσκλήσεις",
"bulk_options_reject_all_invites": "Απόρριψη όλων των προσκλήσεων %(invitedRooms)s",
"bulk_options_section": "Μαζικές επιλογές",
@@ -2177,14 +2175,12 @@
"message_search_unsupported_web": "Το %(brand)s δεν μπορεί να αποθηκεύσει με ασφάλεια κρυπτογραφημένα μηνύματα τοπικά ενώ εκτελείται σε πρόγραμμα περιήγησης ιστού. Χρησιμοποιήστε την <desktopLink>%(brand)s Επιφάνεια εργασίας</desktopLink> για να εμφανίζονται κρυπτογραφημένα μηνύματα στα αποτελέσματα αναζήτησης.",
"record_session_details": "Κατέγραψε το όνομα του πελάτη, την έκδοση και τη διεύθυνση URL για να αναγνωρίζεις τις συνεδρίες πιο εύκολα στον διαχειριστή συνεδρίας",
"send_analytics": "Αποστολή δεδομένων αναλυτικών στοιχείων",
"strict_encryption": "Αποστολή μηνυμάτων μόνο σε επαληθευμένους χρήστες"
"strict_encryption": "Μη στέλνετε ποτέ κρυπτογραφημένα μηνύματα σε μη επαληθευμένες συνεδρίες από αυτήν τη συνεδρία"
},
"send_read_receipts": "Αποστολή αποδείξεων ανάγνωσης",
"send_read_receipts_unsupported": "Ο διακομιστής σου δεν υποστηρίζει την απενεργοποίηση αποστολής αποδείξεων ανάγνωσης.",
"send_typing_notifications": "Αποστολή ειδοποιήσεων πληκτρολόγησης",
"sessions": {
"best_security_note": "Για τη βέλτιστη ασφάλεια, επαληθεύστε τις συνεδρίες σας και αποσυνδεθείτε από οποιαδήποτε συνεδρία που δεν αναγνωρίζετε ή χρησιμοποιείτε πλέον.",
"browser": "Πρόγραμμα περιήγησης",
"confirm_sign_out": {
"one": "Επιβεβαιώστε την αποσύνδεση αυτής της συσκευής",
"other": "Επιβεβαιώστε την αποσύνδεση αυτών των συσκευών"
@@ -2201,83 +2197,8 @@
"one": "Επιβεβαιώστε ότι αποσυνδέεστε από αυτήν τη συσκευή χρησιμοποιώντας Single Sign On για να αποδείξετε την ταυτότητά σας.",
"other": "Επιβεβαιώστε την αποσύνδεση από αυτές τις συσκευές χρησιμοποιώντας Single Sign On για να αποδείξετε την ταυτότητά σας."
},
"current_session": "Τρέχουσα συνεδρία",
"desktop_session": "Συνεδρία εφαρμογής υπολογιστή",
"details_heading": "Λεπτομέρειες συνεδρίας",
"device_unverified_description": "Επαληθεύστε ή αποσυνδεθείτε από αυτήν τη συνεδρία για βέλτιστη ασφάλεια και αξιοπιστία.",
"device_unverified_description_current": "Επαληθεύστε την τρέχουσα συνεδρία σας για βελτιωμένα ασφαλή μηνύματα.",
"device_verified_description": "Αυτή η συνεδρία είναι έτοιμη για ασφαλή ανταλλαγή μηνυμάτων.",
"device_verified_description_current": "Η τρέχουσα συνεδρία σας είναι έτοιμη για ασφαλή ανταλλαγή μηνυμάτων.",
"error_pusher_state": "Αποτυχία ορισμού κατάστασης pusher",
"error_set_name": "Αποτυχία ορισμού ονόματος συνεδρίας",
"filter_all": "Όλα",
"filter_inactive": "Ανενεργό",
"filter_inactive_description": "Ανενεργό για%(inactiveAgeDays)s ημέρες ή και περισσότερο",
"filter_label": "Φιλτράρισμα συσκευών",
"filter_unverified_description": "Δεν είναι έτοιμο για ασφαλή ανταλλαγή μηνυμάτων",
"filter_verified_description": "Έτοιμο για ασφαλή ανταλλαγή μηνυμάτων",
"hide_details": "Απόκρυψη λεπτομερειών",
"inactive_days": "Ανενεργό για %(inactiveAgeDays)s+ ημέρες",
"inactive_sessions": "Ανενεργές συνεδρίες",
"inactive_sessions_explainer_1": "Οι ανενεργές συνεδρίες είναι συνεδρίες που δεν έχετε χρησιμοποιήσει για κάποιο χρονικό διάστημα, αλλά συνεχίζουν να λαμβάνουν κλειδιά κρυπτογράφησης.",
"inactive_sessions_explainer_2": "Η αφαίρεση ανενεργών συνεδριών βελτιώνει την ασφάλεια και την απόδοση και σας διευκολύνει να εντοπίσετε αν μια νέα συνεδρία είναι ύποπτη.",
"inactive_sessions_list_description": "Εξετάστε το ενδεχόμενο να αποσυνδεθείτε από παλιές συνεδρίες (%(inactiveAgeDays)s ημέρες ή παλαιότερες) που δεν χρησιμοποιείτε πλέον.",
"ip": "Διεύθυνση IP",
"last_activity": "Τελευταία δραστηριότητα",
"mobile_session": "Συνεδρία κινητού",
"n_sessions_selected": {
"one": "%(count)s επιλεγμένη συνεδρία",
"other": "%(count)s επιλεγμένες συνεδρίες"
},
"no_inactive_sessions": "Δεν βρέθηκαν ανενεργές συνεδρίες.",
"no_sessions": "Δεν βρέθηκαν συνεδρίες.",
"no_unverified_sessions": "Δεν βρέθηκαν μη επαληθευμένες συνεδρίες.",
"no_verified_sessions": "Δεν βρέθηκαν επαληθευμένες συνεδρίες.",
"os": "Λειτουργικό σύστημα",
"other_sessions_heading": "Άλλες συνεδρίες",
"push_heading": "Ειδοποιήσεις push",
"push_subheading": "Λάβετε ειδοποιήσεις push σε αυτήν τη συνεδρία.",
"push_toggle": "Ενεργοποίηση/απενεργοποίηση ειδοποιήσεων push σε αυτήν τη συνεδρία.",
"rename_form_caption": "Λάβετε υπόψη ότι τα ονόματα των συνεδριών είναι επίσης ορατά στα άτομα με τα οποία επικοινωνείτε.",
"rename_form_heading": "Μετονομασία συνεδρίας",
"rename_form_learn_more": "Μετονομασία συνεδριών",
"rename_form_learn_more_description_1": "Άλλοι χρήστες σε απευθείας μηνύματα και αίθουσες στις οποίες συμμετέχετε μπορούν να δουν μια πλήρη λίστα των συνεδριών σας.",
"rename_form_learn_more_description_2": "Αυτό τους παρέχει την εμπιστοσύνη ότι πραγματικά μιλούν με εσάς, αλλά σημαίνει επίσης ότι μπορούν να δουν το όνομα της συνεδρίας που εισάγετε εδώ.",
"security_recommendations": "Συστάσεις ασφαλείας",
"security_recommendations_description": "Βελτιώστε την ασφάλεια του λογαριασμού σας ακολουθώντας αυτές τις συστάσεις.",
"session_id": "Αναγνωριστικό συνεδρίας",
"show_details": "Εμφάνιση λεπτομερειών",
"sign_in_with_qr": "Σύνδεση νέας συσκευής",
"sign_in_with_qr_button": "Εμφάνιση κωδικού QR",
"sign_in_with_qr_description": "Χρησιμοποιήστε έναν κωδικό QR για να συνδεθείτε σε άλλη συσκευή και να ρυθμίσετε την ασφαλή ανταλλαγή μηνυμάτων.",
"sign_out": "Αποσυνδεθείτε από αυτήν τη συνεδρία",
"sign_out_all_other_sessions": "Αποσύνδεση από όλες τις άλλες συνεδρίες (%(otherSessionsCount)s)",
"sign_out_confirm_description": {
"one": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε από τη %(count)s συνεδρία;",
"other": "Είστε βέβαιοι ότι θέλετε να αποσυνδεθείτε από τις %(count)s συνεδρίες;"
},
"sign_out_n_sessions": {
"one": "Αποσύνδεση από %(count)s συνεδρία",
"other": "Αποσύνδεση από %(count)s συνεδρίες"
},
"title": "Συνεδρίες",
"unknown_session": "Άγνωστος τύπος συνεδρίας",
"unverified_session": "Μη επαληθευμένη συνεδρία",
"unverified_session_explainer_1": "Αυτή η συνεδρία δεν υποστηρίζει κρυπτογράφηση και συνεπώς δεν μπορεί να επαληθευτεί.",
"unverified_session_explainer_2": "Δεν θα μπορείτε να συμμετάσχετε σε αίθουσες όπου είναι ενεργοποιημένη η κρυπτογράφηση κατά τη χρήση αυτής της συνεδρίας.",
"unverified_session_explainer_3": "Για καλύτερη ασφάλεια και ιδιωτικότητα, συνιστάται να χρησιμοποιείτε εφαρμογές Matrix που υποστηρίζουν κρυπτογράφηση.",
"unverified_sessions": "Μη επαληθευμένες συνεδρίες",
"unverified_sessions_explainer_1": "Οι μη επαληθευμένες συνεδρίες είναι συνεδρίες στις οποίες έχετε συνδεθεί με τα διαπιστευτήριά σας, αλλά δεν έχουν επαληθευτεί.",
"unverified_sessions_explainer_2": "Θα πρέπει να βεβαιωθείτε ιδιαίτερα ότι αναγνωρίζετε αυτές τις συνεδρίες, καθώς ενδέχεται να αποτελούν μη εξουσιοδοτημένη χρήση του λογαριασμού σας.",
"unverified_sessions_list_description": "Επαληθεύστε τις συνεδρίες σας για βελτιωμένα ασφαλή μηνύματα ή αποσυνδεθείτε από αυτές που δεν αναγνωρίζετε ή χρησιμοποιείτε πλέον.",
"url": "URL",
"verified_session": "Επαληθευμένη συνεδρία",
"verified_sessions": "Επαληθευμένες συνεδρίες",
"verified_sessions_explainer_1": "Οι επαληθευμένες συνεδρίες είναι οπουδήποτε χρησιμοποιείτε αυτόν τον λογαριασμό αφού εισαγάγετε τη φράση πρόσβασής σας ή επιβεβαιώσετε την ταυτότητά σας με άλλη επαληθευμένη συνεδρία.",
"verified_sessions_explainer_2": "Αυτό σημαίνει ότι έχετε όλα τα κλειδιά που απαιτούνται για να ξεκλειδώσετε τα κρυπτογραφημένα μηνύματά σας και να επιβεβαιώσετε σε άλλους χρήστες ότι εμπιστεύεστε αυτήν τη συνεδρία.",
"verified_sessions_list_description": "Για βέλτιστη ασφάλεια, αποσυνδεθείτε από οποιαδήποτε συνεδρία που δεν αναγνωρίζετε ή χρησιμοποιείτε πλέον.",
"verify_session": "Επαλήθευση συνεδρίας",
"web_session": "Συνεδρία web"
"verify_session": "Επαλήθευση συνεδρίας"
},
"show_avatar_changes": "Εμφάνιση αλλαγών εικόνας προφίλ",
"show_breadcrumbs": "Εμφάνιση συντομεύσεων σε δωμάτια που προβλήθηκαν πρόσφατα πάνω από τη λίστα δωματίων",
@@ -2298,7 +2219,6 @@
"metaspaces_orphans_description": "Ομαδοποιήστε σε ένα μέρος όλα τα δωμάτιά σας που δεν αποτελούν μέρος ενός χώρου.",
"metaspaces_people_description": "Ομαδοποιήστε όλα τα άτομα σας σε ένα μέρος.",
"metaspaces_subsection": "Χώροι για εμφάνιση",
"spaces_explainer": "Οι χώροι είναι τρόποι ομαδοποίησης αιθουσών και ανθρώπων. Παράλληλα με τους χώρους στους οποίους βρίσκεστε, μπορείτε να χρησιμοποιήσετε και κάποιους προκατασκευασμένους χώρους.",
"title": "Πλαϊνή μπάρα"
},
"start_automatically": "Αυτόματη έναρξη μετά τη σύνδεση",
@@ -2329,8 +2249,7 @@
"voice_processing": "Επεξεργασία φωνής",
"voice_section": "Ρυθμίσεις φωνής"
},
"warn_quit": "Προειδοποιήστε πριν την παραίτηση",
"warning": "<w>ΠΡΟΕΙΔΟΠΟΙΗΣΗ:</w> <description/>"
"warn_quit": "Προειδοποιήστε πριν την παραίτηση"
},
"share": {
"permalink_message": "Σύνδεσμος στο επιλεγμένο μήνυμα",

View File

@@ -2066,7 +2066,6 @@
"read_topic": "Click to read topic",
"rejecting": "Rejecting invite…",
"rejoin_button": "Re-join",
"room_content": "Room content",
"room_is_low_priority": "This is a low priority room",
"search": {
"all_rooms_button": "Search all rooms",
@@ -2116,7 +2115,6 @@
"add_space_label": "Add space",
"breadcrumbs_empty": "No recently visited rooms",
"breadcrumbs_label": "Recently visited rooms",
"collapse_filters": "Collapse filter list",
"empty": {
"no_chats": "No chats yet",
"no_chats_description": "Get started by messaging someone or by creating a room",
@@ -2124,7 +2122,6 @@
"no_favourites": "You don't have favourite chat yet",
"no_favourites_description": "You can add a chat to your favourites in the chat settings",
"no_invites": "You don't have any unread invites",
"no_lowpriority": "You don't have any low priority rooms",
"no_mentions": "You don't have any unread mentions",
"no_people": "You dont have direct chats with anyone yet",
"no_people_description": "You can deselect filters in order to see your other chats",
@@ -2134,7 +2131,6 @@
"show_activity": "See all activity",
"show_chats": "Show all chats"
},
"expand_filters": "Expand filter list",
"failed_add_tag": "Failed to add tag %(tagName)s to room",
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
"failed_set_dm_tag": "Failed to set direct message tag",

View File

@@ -2115,7 +2115,6 @@
"add_space_label": "Lisa kogukonnakeskus",
"breadcrumbs_empty": "Hiljuti külastatud jututubasid ei leidu",
"breadcrumbs_label": "Hiljuti külastatud jututoad",
"collapse_filters": "Ahenda filtriloendit",
"empty": {
"no_chats": "Vestlusi veel ei leidu",
"no_chats_description": "Alusta sellest, et leia mõni vestluspartner või loo oma jututuba",
@@ -2123,7 +2122,6 @@
"no_favourites": "Sa pole veel ühtegi vestlust märkinud lemmikuks",
"no_favourites_description": "Vestluse saad märkida lemmikuks tema seadistustest",
"no_invites": "Sul pole lugemata kutseid",
"no_lowpriority": "Sul pole ühtegi vähetähtsat jututuba",
"no_mentions": "Sul pole lugemata mainimisi",
"no_people": "Sul pole veel ühtegi otsevestlust kellegagi",
"no_people_description": "Kõikide muude vestluste nägemiseks eemalda otsingufiltrid",
@@ -2133,7 +2131,6 @@
"show_activity": "Vaata kõiki tegevusi",
"show_chats": "Näita kõiki vestlusi"
},
"expand_filters": "Laienda filtriloendit",
"failed_add_tag": "Sildi %(tagName)s lisamine jututoale ebaõnnestus",
"failed_remove_tag": "Sildi %(tagName)s eemaldamine jututoast ebaõnnestus",
"failed_set_dm_tag": "Otsevestluse sildi seadmine ei õnnestunud",

View File

@@ -784,7 +784,6 @@
"cross_signing_status": "Status penandatanganan silang:",
"cross_signing_untrusted": "Akun Anda memiliki identitas penandatanganan silang dalam penyimpanan rahasia, tetapi belum dipercaya oleh sesi ini.",
"crypto_not_available": "Modul kriptografi tidak tersedia",
"device_id": "ID Perangkat",
"key_backup_active_version": "Versi cadangan aktif:",
"key_backup_active_version_none": "Tidak ada",
"key_backup_inactive_warning": "Kunci Anda tidak dicadangkan dari sesi ini.",
@@ -797,8 +796,6 @@
"secret_storage_ready": "siap",
"secret_storage_status": "Penyimpanan rahasia:",
"self_signing_private_key_cached_status": "Kunci pribadi penandatanganan sendiri:",
"session": "Sesi",
"session_fingerprint": "Sidik jari (kunci sesi)",
"title": "Enkripsi ujung ke ujung",
"user_signing_private_key_cached_status": "Kunci pribadi penandatanganan pengguna:"
},
@@ -824,7 +821,6 @@
"low_bandwidth_mode": "Mode bandwidth rendah",
"low_bandwidth_mode_description": "Membutuhkan homeserver yang kompatibel.",
"main_timeline": "Lini masa utama",
"manual_device_verification": "Verifikasi perangkat manual",
"no_receipt_found": "Tidak ada laporan yang ditemukan",
"notification_state": "Keadaan notifikasi adalah <strong>%(notificationState)s</strong>",
"notifications_debug": "Pengawakutuan notifikasi",
@@ -1008,21 +1004,6 @@
"incoming_sas_dialog_waiting": "Menunggu pengguna untuk konfirmasi…",
"incoming_sas_user_dialog_text_1": "Verifikasi pengguna ini untuk menandainya sebagai terpercaya. Mempercayai pengguna memberikan Anda ketenangan saat menggunakan pesan terenkripsi secara ujung ke ujung.",
"incoming_sas_user_dialog_text_2": "Memverifikasi pengguna ini akan menandai sesinya sebagai terpercaya, dan juga menandai sesi Anda sebagai terpercaya kepadanya.",
"manual": {
"already_verified": "Perangkat ini sudah diverifikasi",
"already_verified_and_wrong_fingerprint": "Sidik jari yang disediakan tidak cocok, tetapi perangkat sudah diverifikasi!",
"device_id": "ID Perangkat",
"failure_description": "Gagal memverifikasi '%(deviceId)s': %(error)s",
"failure_title": "Verifikasi gagal",
"fingerprint": "Sidik jari (kunci sesi)",
"no_crypto": "Tidak dapat memverifikasi perangkat - kripto tidak diaktifkan",
"no_device": "Tidak dapat memverifikasi perangkat - perangkat %(deviceId)s '' tidak ditemukan",
"no_userid": "Tidak dapat memverifikasi perangkat - tidak dapat menemukan ID Pengguna kami",
"success_description": "Perangkat (%(deviceId)s) sekarang ditandatangani silang",
"success_title": "Verifikasi berhasil",
"text": "Berikan ID dan sidik jari salah satu perangkat Anda untuk memverifikasinya. PERHATIKAN bahwa ini memungkinkan perangkat lain untuk mengirim dan menerima pesan seperti Anda. JIKA SESEORANG MEMINTA ANDA UNTUK MENEMPELKAN SESUATU DI SINI, KEMUNGKINAN ANDA SEDANG DITIPU!",
"wrong_fingerprint": "Tidak dapat memverifikasi perangkat '%(deviceId)s' - sidik jari yang disediakan '%(fingerprint)s' tidak cocok dengan sidik jari perangkat, '%(fprint)s'"
},
"no_key_or_device": "Sepertinya Anda tidak memiliki Kunci Pemulihan atau perangkat lain yang dapat Anda verifikasi. Perangkat ini tidak akan dapat mengakses pesan terenkripsi lama. Untuk memverifikasi identitas Anda di perangkat ini, Anda harus mengatur ulang kunci verifikasi Anda.",
"no_support_qr_emoji": "Perangkat yang Anda sedang verifikasi tidak mendukung pemindaian kode QR atau verifikasi emoji, yang didukung oleh %(brand)s. Coba menggunakan klien yang lain.",
"other_party_cancelled": "Pengguna yang lain membatalkan proses verifikasi ini.",
@@ -1968,7 +1949,6 @@
},
"face_pile_tooltip_shortcut": "Termasuk %(commaSeparatedMembers)s",
"face_pile_tooltip_shortcut_joined": "Termasuk Anda, %(commaSeparatedMembers)s",
"failed_determine_user": "Tidak dapat menentukan pengguna mana yang akan diabaikan karena peristiwa anggota telah berubah.",
"failed_reject_invite": "Gagal untuk menolak undangan",
"forget_room": "Lupakan ruangan ini",
"forget_space": "Lupakan space ini",
@@ -2059,7 +2039,6 @@
"read_topic": "Klik untuk membaca topik",
"rejecting": "Menolak undangan…",
"rejoin_button": "Bergabung Ulang",
"room_is_low_priority": "Ini adalah ruangan dengan prioritas rendah",
"search": {
"all_rooms_button": "Cari semua ruangan",
"placeholder": "Cari pesan...",
@@ -2107,7 +2086,6 @@
"add_space_label": "Tambahkan space",
"breadcrumbs_empty": "Tidak ada ruangan yang baru saja dilihat",
"breadcrumbs_label": "Ruangan yang baru saja dilihat",
"collapse_filters": "Tutup daftar filter",
"empty": {
"no_chats": "Belum ada obrolan",
"no_chats_description": "Mulailah dengan mengirim pesan kepada seseorang atau dengan membuat ruangan",
@@ -2115,7 +2093,6 @@
"no_favourites": "Anda belum memiliki obrolan favorit",
"no_favourites_description": "Anda dapat menambahkan obrolan ke favorit Anda di pengaturan obrolan",
"no_invites": "Anda tidak memiliki undangan yang belum dibaca",
"no_lowpriority": "Anda tidak memiliki ruangan dengan prioritas rendah",
"no_mentions": "Anda tidak memiliki sebutan yang belum dibaca",
"no_people": "Anda belum memiliki obrolan langsung dengan siapa pun",
"no_people_description": "Anda dapat membatalkan pilihan saringan untuk melihat percakapan Anda yang lain",
@@ -2125,14 +2102,12 @@
"show_activity": "Lihat semua aktivitas",
"show_chats": "Tampilkan semua obrolan"
},
"expand_filters": "Buka daftar filter",
"failed_add_tag": "Gagal menambahkan tag %(tagName)s ke ruangan",
"failed_remove_tag": "Gagal menghapus tanda %(tagName)s dari ruangan",
"failed_set_dm_tag": "Gagal menetapkan tanda pesan langsung",
"filters": {
"favourite": "Favorit",
"invites": "Undangan",
"low_priority": "Prioritas rendah",
"mentions": "Sebutan",
"people": "Orang",
"rooms": "Ruangan",
@@ -2700,9 +2675,6 @@
"inline_url_previews_room": "Aktifkan tampilan URL secara bawaan untuk anggota di ruangan ini",
"inline_url_previews_room_account": "Aktifkan tampilan URL secara bawaan (hanya memengaruhi Anda)",
"insert_trailing_colon_mentions": "Tambahkan sebuah karakter titik dua sesudah sebutan pengguna dari awal pesan",
"invite_controls": {
"default_label": "Izinkan pengguna mengundang Anda ke ruangan"
},
"jump_to_bottom_on_send": "Pergi ke bawah lini masa ketika Anda mengirim pesan",
"key_backup": {
"backup_in_progress": "Kunci Anda sedang dicadangkan (cadangan pertama mungkin membutuhkan beberapa menit).",
@@ -2769,7 +2741,6 @@
"show_in_private": "Di ruangan privat",
"show_media": "Selalu tampilkan"
},
"not_supported": "Server Anda tidak menerapkan fitur ini.",
"notifications": {
"default_setting_description": "Pengaturan ini akan diterapkan secara bawaan ke semua ruangan Anda.",
"default_setting_section": "Saya ingin diberi tahu (Pengaturan Bawaan)",
@@ -2827,7 +2798,6 @@
"voip": "Panggilan Audio dan Video"
},
"preferences": {
"Electron.enableContentProtection": "Mencegah konten jendela agar tidak ditangkap oleh aplikasi lain",
"Electron.enableHardwareAcceleration": "Aktifkan akselerasi perangkat keras (mulai ulang %(appName)s untuk menerapkan)",
"always_show_menu_bar": "Selalu tampilkan bilah menu window",
"autocomplete_delay": "Delay penyelesaian otomatis (md)",
@@ -3000,7 +2970,6 @@
"show_chat_effects": "Tampilkan efek (animasi ketika menerima konfeti, misalnya)",
"show_displayname_changes": "Tampilkan perubahan nama tampilan",
"show_join_leave": "Tampilkan pesan-pesan gabung/keluar (undangan/pengeluaran/cekalan tidak terpengaruh)",
"show_message_previews": "Tampilkan pratinjau pesan",
"show_nsfw_content": "Tampilkan konten NSFW",
"show_read_receipts": "Tampilkan laporan dibaca terkirim oleh pengguna lain",
"show_redaction_placeholder": "Tampilkan sebuah penampung untuk pesan terhapus",
@@ -3107,8 +3076,6 @@
"jumptodate": "Pergi ke tanggal yang diberikan di lini masa",
"jumptodate_invalid_input": "Kami tidak dapat mengerti tanggal yang dicantumkan (%(inputDate)s). Coba menggunakan format TTTT-BB-HH.",
"lenny": "Menambahkan ( ͡° ͜ʖ ͡°) ke pesan teks biasa",
"manual_device_verification_confirm_description": "Ini akan memungkinkan perangkat lain untuk mengirim dan menerima pesan seperti Anda. JIKA SESEORANG MENYURUH ANDA MENEMPELKAN SESUATU DI SINI, KEMUNGKINAN ANDA SEDANG DITIPU! Apakah Anda yakin ingin memverifikasi perangkat lain ini?",
"manual_device_verification_confirm_title": "Perhatian: verifikasi perangkat manual",
"me": "Menampilkan aksi",
"msg": "Mengirim sebuah pesan ke pengguna yang dicantumkan",
"myavatar": "Ubah foto profil Anda dalam semua ruangan",
@@ -3149,7 +3116,7 @@
"upgraderoom": "Meningkatkan ruangan ke versi yang baru",
"upgraderoom_permission_error": "Anda tidak memiliki izin yang dibutuhkan untuk menggunakan perintah ini.",
"usage": "Penggunaan",
"verify": "Verifikasi salah satu perangkat Anda secara manual",
"verify": "Memverifikasi sebuah pengguna, sesi, dan tupel pubkey",
"view": "Menampilkan ruangan dengan alamat yang ditentukan",
"whois": "Menampilkan informasi tentang sebuah pengguna"
},

View File

@@ -1020,11 +1020,7 @@
"fingerprint": "Fingeravtrykk (sesjonsnøkkel)",
"no_crypto": "Kan ikke verifisere enheten - krypto er ikke aktivert",
"no_device": "Kunne ikke verifisere enheten - enheten '%(deviceId)s' ble ikke funnet",
"no_userid": "Kan ikke verifisere enheten - finner ikke bruker-ID",
"success_description": "Enheten (%(deviceId)s) er nå krysssignert",
"success_title": "Verifiseringen var vellykket",
"text": "Oppgi ID-en og fingeravtrykket til en av dine egne enheter for å verifisere det. MERK at dette lar den andre enheten sende og motta meldinger som deg. HVIS NOEN HAR FORTALT DEG BARE Å LIME INN NOE HER, ER DET SANNSYNLIGVIS AT DU BLIR SVINDLET!",
"wrong_fingerprint": "Kan ikke verifisere enheten %(deviceId)s &#39;- det medfølgende fingeravtrykket&#39;%(fingerprint)s «samsvarer ikke med enhetens fingeravtrykk»%(fprint)s &#39;"
"no_userid": "Kan ikke verifisere enheten - finner ikke bruker-ID"
},
"no_key_or_device": "Det ser ut til at du ikke har en gjenopprettingsnøkkel eller andre enheter du kan verifisere mot. Denne enheten vil ikke kunne få tilgang til gamle krypterte meldinger. For å bekrefte identiteten din på denne enheten, må du tilbakestille verifiseringsnøklene dine.",
"no_support_qr_emoji": "Enheten du prøver å bekrefte støtter ikke skanning av en QR-kode eller emoji-verifikasjon, som er det som %(brand)s støtter. Prøv med en annen klient.",
@@ -2066,7 +2062,6 @@
"read_topic": "Klikk for å lese emnet",
"rejecting": "Avviser invitasjon...",
"rejoin_button": "Bli med igjen",
"room_content": "Rominnhold",
"room_is_low_priority": "Dette er et lavt prioritert rom",
"search": {
"all_rooms_button": "Søk i alle rom",
@@ -2116,7 +2111,6 @@
"add_space_label": "Legg til område",
"breadcrumbs_empty": "Ingen nylig besøkte rom",
"breadcrumbs_label": "Nylig besøkte rom",
"collapse_filters": "Skjul filterlisten",
"empty": {
"no_chats": "Ingen chatter ennå",
"no_chats_description": "Kom i gang ved å sende meldinger til noen eller ved å opprette et rom",
@@ -2124,7 +2118,6 @@
"no_favourites": "Du har ikke favorittchat ennå",
"no_favourites_description": "Du kan legge til en chat til dine favoritter i chat-innstillingene",
"no_invites": "Du har ingen uleste invitasjoner",
"no_lowpriority": "Du har ingen rom med lav prioritet",
"no_mentions": "Du har ingen uleste omtaler",
"no_people": "Du har ikke direkte chatter med noen ennå",
"no_people_description": "Du kan fjerne merket for filtre for å se de andre chattene dine",
@@ -2134,7 +2127,6 @@
"show_activity": "Se alle aktiviteter",
"show_chats": "Vis alle chatter"
},
"expand_filters": "Utvid filterlisten",
"failed_add_tag": "Kunne ikke legge til tagg %(tagName)s til rom",
"failed_remove_tag": "Kunne ikke fjerne tagg %(tagName)s fra rommet",
"failed_set_dm_tag": "Kan ikke sette kode på direktemeldingen",
@@ -3115,8 +3107,6 @@
"jumptodate": "Gå til den gitte datoen i tidslinjen",
"jumptodate_invalid_input": "Vi klarte ikke å forstå den gitte datoen (%(inputDate)s). Prøv å bruke formatet ÅÅÅÅ-MM-DD.",
"lenny": "Legger til ( ͡° ͜ʖ ͡°) foran en ren tekstmelding",
"manual_device_verification_confirm_description": "Dette vil tillate en annen enhet å sende og motta meldinger som deg. HVIS NOEN BA DEG LIME INN NOE HER, ER DET SANNSYNLIG AT DU BLIR LURT! Er du sikker på at du vil bekrefte denne andre enheten?",
"manual_device_verification_confirm_title": "Forsiktig: manuell enhetsverifisering",
"me": "Viser handling",
"msg": "Sender en melding til den angitte brukeren",
"myavatar": "Endrer profilbildet ditt i alle rom",
@@ -3157,7 +3147,7 @@
"upgraderoom": "Oppgraderer et rom til en ny versjon",
"upgraderoom_permission_error": "Du har ikke de rette tilgangene til å bruke denne kommandoen.",
"usage": "Bruk",
"verify": "Verifiser en av dine egne enheter manuelt",
"verify": "Verifiserer en bruker-, økt- og pubkey-tuple",
"view": "Viser rom med oppgitt adresse",
"whois": "Viser informasjon om en bruker"
},

View File

@@ -762,7 +762,6 @@
"backup_key_not_stored": "не сохранено",
"backup_key_stored": "в секретном хранилище",
"backup_key_stored_status": "Сохраненный резервный ключ:",
"backup_key_unexpected_type": "непредвиденный тип",
"backup_key_well_formed": "корректный",
"cross_signing": "Кросс-подпись",
"cross_signing_cached": "сохранено локально",
@@ -776,7 +775,6 @@
"cross_signing_status": "Статус кросс-подписи:",
"cross_signing_untrusted": "У вашей учётной записи есть кросс-подпись в секретное хранилище, но она пока не является доверенной в этом сеансе.",
"crypto_not_available": "Криптографический модуль недоступен",
"device_id": "Идентификатор устройства",
"key_backup_active_version": "Активная резервная версия:",
"key_backup_active_version_none": "Нет",
"key_backup_inactive_warning": "Резервное копирование ваших ключей из этого сеанса не выполняется.",
@@ -789,8 +787,6 @@
"secret_storage_ready": "готово",
"secret_storage_status": "Секретное хранилище:",
"self_signing_private_key_cached_status": "Самоподписанный закрытый ключ:",
"session": "Сессия",
"session_fingerprint": "Отпечаток пальца (ключ сессии)",
"title": "Сквозное шифрование",
"user_signing_private_key_cached_status": "Закрытый ключ подписи пользователей:"
},
@@ -816,7 +812,6 @@
"low_bandwidth_mode": "Режим низкой пропускной способности",
"low_bandwidth_mode_description": "Требуется совместимый сервер.",
"main_timeline": "Основная хронология",
"manual_device_verification": "Ручная проверка устройства",
"no_receipt_found": "Квитанция не найдена",
"notification_state": "Состояние уведомления <strong>%(notificationState)s</strong>",
"notifications_debug": "Отладка уведомлений",
@@ -1001,15 +996,6 @@
"incoming_sas_dialog_waiting": "Ожидаем подтверждения от партнера…",
"incoming_sas_user_dialog_text_1": "Проверить этого пользователя, чтобы отметить его, как доверенного. Доверенные пользователи дают вам больше уверенности при использовании шифрованных сообщений.",
"incoming_sas_user_dialog_text_2": "Подтверждение этого пользователя сделает его сеанс доверенным у вас, а также сделает ваш сеанс доверенным у него.",
"manual": {
"already_verified": "Это устройство уже проверено",
"already_verified_and_wrong_fingerprint": "Предоставленный отпечаток пальца не совпадает, но устройство уже проверено!",
"device_id": "Идентификатор устройства",
"failure_description": "Не удалось проверить '%(deviceId)s': %(error)s",
"failure_title": "Сбой проверки",
"fingerprint": "Отпечаток пальца (ключ сессии)",
"success_title": "Проверка прошла успешно"
},
"no_key_or_device": "Похоже, у вас нет Ключа Восстановления, или других сеансов, с которыми вы могли бы свериться. В этом сеансе вы не сможете получить доступ к старым зашифрованным сообщениям. Чтобы подтвердить свою личность в этом сеансе, вам нужно будет сбросить свои ключи шифрования.",
"no_support_qr_emoji": "Устройство, которое вы пытаетесь проверить, не поддерживает сканирование QR-кода или проверку смайликов, которые поддерживает %(brand)s. Попробуйте использовать другой клиент.",
"other_party_cancelled": "Другая сторона отменила проверку.",
@@ -1049,7 +1035,7 @@
"unverified_sessions_toast_description": "Проверьте, чтобы убедиться, что ваша учётная запись в безопасности",
"unverified_sessions_toast_reject": "Позже",
"unverified_sessions_toast_title": "У вас есть незаверенные сеансы",
"verification_description": "Подтвердите свою личность, чтобы получить доступ к зашифрованным сообщениям и подтвердить свою личность другим. Если вы также используете мобильное устройство, откройте приложение там, прежде чем продолжить.",
"verification_description": "Подтвердите свою личность, чтобы получить доступ к зашифрованным сообщениям и доказать свою личность другим.",
"verification_dialog_title_device": "Проверить другое устройство",
"verification_dialog_title_user": "Запрос на сверку",
"verification_skip_warning": "Без проверки вы не сможете получить доступ ко всем своим сообщениям и можете показаться другим людям недоверенным.",
@@ -1068,10 +1054,8 @@
"waiting_other_user": "Ожидание %(displayName)s для проверки…"
},
"verification_requested_toast_title": "Запрошено подтверждение",
"verified_identity_changed": "Подтвержденная личность %(displayName)s (<b>%(userId)s</b>) изменилась. <a>Узнайте больше</a>",
"verify_toast_description": "Другие пользователи могут не доверять этому сеансу",
"verify_toast_title": "Заверьте этот сеанс",
"withdraw_verification_action": "Подтверждение верификации"
"verify_toast_title": "Заверьте этот сеанс"
},
"error": {
"admin_contact": "Пожалуйста, <a>обратитесь к вашему администратору</a>, чтобы продолжить использовать этот сервис.",
@@ -1112,14 +1096,13 @@
"unknown_error_code": "неизвестный код ошибки",
"update_power_level": "Не удалось изменить уровень прав"
},
"error_app_open_in_another_tab": "Переключитесь на другую вкладку, чтобы подключиться к %(brand)s . Теперь эту вкладку можно закрыть.",
"error_app_open_in_another_tab_title": "%(brand)s подключен в другой вкладке",
"error_app_open_in_another_tab": "%(brand)s был открыт в другой вкладке.",
"error_app_opened_in_another_window": "%(brand)s открыт в другом окне. Нажмите \"%(label)s\" чтобы использовать %(brand)s в данном окне и отключить другое.",
"error_database_closed_description": {
"for_desktop": "Возможно, ваш диск переполнен. Освободите место и перезагрузите компьютер.",
"for_web": "Если вы очистили данные браузера, то это сообщение ожидаемо. %(brand)s также может быть открыт в другой вкладке, или ваш диск заполнен. Пожалуйста, освободите место и перезагрузите"
},
"error_database_closed_title": "%(brand)s перестал работать",
"error_database_closed_title": "База данных неожиданно закрылась",
"error_dialog": {
"copy_room_link_failed": {
"description": "Не удалось скопировать ссылку на комнату в буфер обмена.",
@@ -1158,8 +1141,7 @@
"image": "Изображение",
"poll": "Опрос",
"video": "Видео"
},
"preview": "<bold>%(prefix)s:</bold> %(preview)s"
}
},
"export_chat": {
"cancelled": "Экспорт отменён",
@@ -1284,16 +1266,12 @@
},
"incompatible_browser": {
"continue": "Продолжить в любом случае",
"description": "%(brand)s использует некоторые функции браузера, которые недоступны в вашем текущем браузере. %(detail)s",
"detail_can_continue": "Если вы продолжите, некоторые функции могут перестать работать, и существует риск потери данных в будущем.",
"detail_no_continue": "Попробуйте обновить этот браузер, если вы используете не последнюю версию, и повторите попытку.",
"learn_more": "Подробнее",
"linux": "Linux",
"macos": "Mac",
"supported_browsers": "Для наилучшего впечатления используйте <Chrome>Chrome</Chrome>, <Firefox>Firefox</Firefox>, <Edge>Edge</Edge> или <Safari>Safari</Safari>.",
"title": "Неподдерживаемый браузер",
"use_desktop_heading": "Вместо этого используйте %(brand)s Desktop",
"use_mobile_heading": "Для этого используйте %(brand)s на мобильном телефоне",
"use_mobile_heading_after_desktop": "Или воспользуйтесь нашим мобильным приложением",
"windows_64bit": "Windows (64-бит)",
"windows_arm_64bit": "Windows (ARM 64-бит)"
@@ -1306,8 +1284,8 @@
"explainer": "Менеджеры по интеграции получают данные конфигурации и могут изменять виджеты, отправлять приглашения в комнаты и устанавливать уровни доступа от вашего имени.",
"manage_title": "Управление интеграциями",
"toggle_label": "Включить менеджер интеграции",
"use_im": "Используйте менеджер интеграций для управления ботами, виджетами и наборами стикеров.",
"use_im_default": "Используйте менеджер интеграций <b>%(serverName)s</b> для управления ботами, виджетами и наборами стикеров."
"use_im": "Используйте менеджер интеграций для управления ботами, виджетами и наклейками.",
"use_im_default": "Используйте менеджер интеграций <b>%(serverName)s</b> для управления ботами, виджетами и наклейками."
},
"integrations": {
"disabled_dialog_description": "Включите '%(manageIntegrations)s' в Настройках.",
@@ -1349,7 +1327,7 @@
"name_email_mxid_share_room": "Пригласите кого-нибудь, используя его имя, адрес электронной почты, имя пользователя (например, <userId/>) или <a>поделитесь этой комнатой</a>.",
"name_email_mxid_share_space": "Пригласите кого-нибудь, используя их имя, адрес электронной почты, имя пользователя (например, <userId/>) или <a>поделитесь этим пространством</a>.",
"name_mxid_share_room": "Пригласите кого-нибудь, используя его имя, имя пользователя (например, <userId/>) или <a>поделитесь этой комнатой</a>.",
"name_mxid_share_space": "Пригласите кого-нибудь, используя их отображаемое имя или имя учётной записи (например, <userId/>) или <a>поделитесь этим пространством</a>.",
"name_mxid_share_space": "Пригласите кого-нибудь, используя их имя, учётную запись (как <userId/>) или <a>поделитесь этим пространством</a>.",
"recents_section": "Недавние Диалоги",
"room_failed_partial": "Мы отправили остальных, но нижеперечисленные люди не могут быть приглашены в <RoomName/>",
"room_failed_partial_title": "Некоторые приглашения не могут быть отправлены",
@@ -2040,10 +2018,7 @@
"pinned_message_badge": "Закреплённое сообщение",
"pinned_message_banner": {
"button_close_list": "Закрыть список",
"button_view_all": "Посмотреть все",
"description": "В этой комнате есть закрепленные сообщения. Нажмите, чтобы просмотреть их.",
"go_to_message": "Показать прикрепленное сообщение на временной шкале.",
"title": "<bold>%(index)s из %(length)s</bold> Закрепленные сообщения"
"button_view_all": "Посмотреть все"
},
"read_topic": "Нажмите, чтобы увидеть тему",
"rejecting": "Отклонение приглашения…",
@@ -2140,8 +2115,7 @@
"other": "Удаляются сообщения в %(count)s комнатах"
},
"room": {
"more_options": "Дополнительные параметры",
"open_room": "Открыть комнату %(roomName)s"
"more_options": "Дополнительные параметры"
},
"show_less": "Показать меньше",
"show_n_more": {
@@ -2224,8 +2198,6 @@
"error_deleting_alias_description": "Произошла ошибка при удалении этого адреса. Возможно, он больше не существует или произошла временная ошибка.",
"error_deleting_alias_description_forbidden": "У вас нет прав для удаления этого адреса.",
"error_deleting_alias_title": "Ошибка при удалении адреса",
"error_publishing": "Невозможно опубликовать комнату",
"error_publishing_detail": "Произошла ошибка при публикации этой комнаты",
"error_save_space_settings": "Не удалось сохранить настройки пространства.",
"error_updating_alias_description": "Произошла ошибка при обновлении альтернативных адресов комнаты. Это может быть запрещено сервером или произошел временный сбой.",
"error_updating_canonical_alias_description": "При обновлении основного адреса комнаты произошла ошибка. Возможно, это не разрешено сервером или произошел временный сбой.",
@@ -2466,26 +2438,20 @@
},
"settings": {
"account": {
"dialog_title": "<strong>Настройки:</strong> Учетная запись",
"title": "Учетная запись"
},
"all_rooms_home": "Показывать все комнаты на Главной",
"all_rooms_home_description": "Все комнаты, в которых вы находитесь, будут отображаться на Главной.",
"always_show_message_timestamps": "Всегда показывать время отправки сообщений",
"appearance": {
"bundled_emoji_font": "Использовать встроенный шрифт эмодзи",
"compact_layout": "Показывать компактный текст и сообщения",
"compact_layout_description": "Для использования этой функции необходимо выбрать современный макет.",
"custom_font": "Использовать системный шрифт",
"custom_font_description": "Установите имя шрифта, установленного в вашей системе, и %(brand)s попытается его использовать.",
"custom_font_name": "Название системного шрифта",
"custom_font_size": "Использовать другой размер",
"custom_theme_add": "Добавить пользовательскую тему",
"custom_theme_downloading": "Загрузка пользовательской темы…",
"custom_theme_error_downloading": "Ошибка при загрузке темы",
"custom_theme_help": "Введите URL-адрес пользовательской темы, которую вы хотите применить.",
"custom_theme_error_downloading": "Ошибка при загрузке информации темы.",
"custom_theme_invalid": "Неверная схема темы.",
"dialog_title": "<strong>Настройки:</strong> Внешний вид",
"font_size": "Размер шрифта",
"font_size_default": "%(fontSize)s (по умолчанию)",
"high_contrast": "Высокая контрастность",
@@ -2540,7 +2506,6 @@
"title": "Вы уверены, что хотите отключить хранение ключей и удалить их?"
},
"device_not_verified_button": "Проверить это устройство",
"device_not_verified_description": "Для просмотра настроек шифрования необходимо подтвердить это устройство.",
"device_not_verified_title": "Устройство не проверено",
"dialog_title": "<strong>Настройки:</strong> Шифрование",
"key_storage": {
@@ -2550,26 +2515,19 @@
},
"recovery": {
"change_recovery_confirm_button": "Подтвердите новый ключ восстановления",
"change_recovery_confirm_description": "Чтобы завершить, введите новый Ключ Восстановления ниже. Ваш старый ключ больше работать не будет.",
"change_recovery_confirm_title": "Введите новый ключ восстановления",
"change_recovery_key": "Изменить ключ восстановления",
"change_recovery_key_description": "Запишите новый ключ восстановления в безопасном месте. Затем нажмите «Продолжить», чтобы подтвердить изменение.",
"change_recovery_key_title": "Изменить ключ восстановления?",
"description": "Восстановите свою идентификацию и историю сообщений с помощью ключа восстановления, если вы потеряли все существующие устройства.",
"enter_key_error": "Ключ восстановления, который вы ввел, неверный.",
"enter_recovery_key": "Введите ключ восстановления",
"forgot_recovery_key": "Забыли ключ восстановления?",
"key_storage_warning": "Хранилище ключей не синхронизировано. Нажмите кнопку ниже, чтобы устранить проблему.",
"save_key_description": "Не сообщайте эту информацию никому!",
"save_key_title": "Ключ восстановления",
"set_up_recovery": "Настройка восстановления",
"set_up_recovery_confirm_button": "Завершить настройку",
"set_up_recovery_confirm_description": "Введите ключ восстановления, показанный на предыдущем экране, чтобы завершить настройку восстановления.",
"set_up_recovery_confirm_title": "Для подтверждения введите ключ восстановления",
"set_up_recovery_description": "Хранилище ключей защищено ключом восстановления. Если после установки вам понадобится новый ключ восстановления, вы можете создать его заново, выбрав '%(changeRecoveryKeyButton)s'.",
"set_up_recovery_save_key_description": "Запишите ключ восстановления в безопасном месте, например в диспетчере паролей, зашифрованной заметке или физическом сейфе.",
"set_up_recovery_save_key_title": "Сохраните ключ восстановления в безопасном месте",
"set_up_recovery_secondary_description": "После нажатия кнопки «Продолжить» мы сгенерируем для вас ключ восстановления.",
"title": "Восстановление"
},
"title": "Шифрование"
@@ -2651,7 +2609,6 @@
"password_change_success": "Ваш пароль успешно изменён.",
"personal_info": "Личная информация",
"profile_subtitle": "Так вас видят другие пользователи приложения.",
"profile_subtitle_oidc": "Ваша учетная запись управляется отдельным поставщиком идентификационных данных, поэтому некоторые ваши личные данные изменить нельзя.",
"remove_email_prompt": "Удалить %(email)s?",
"remove_msisdn_prompt": "Удалить %(phone)s?",
"spell_check_locale_placeholder": "Выберите регион",
@@ -2681,7 +2638,7 @@
"enter_phrase_title": "Введите секретную фразу",
"enter_phrase_to_confirm": "Введите секретную фразу второй раз, чтобы подтвердить ее.",
"generate_security_key_description": "Мы создадим ключ восстановления, который вы сможете хранить в безопасном месте, например в менеджере паролей или сейфе.",
"generate_security_key_title": "Создание Ключа Восстановления",
"generate_security_key_title": "Создание ключа безопасности",
"pass_phrase_match_failed": "Они не совпадают.",
"pass_phrase_match_success": "Они совпадают!",
"phrase_strong_enough": "Отлично! Эта контрольная фраза выглядит достаточно сильной.",
@@ -2690,11 +2647,11 @@
"set_phrase_again": "Задать другой пароль.",
"settings_reminder": "Вы также можете настроить безопасное резервное копирование и управлять своими ключами в настройках.",
"title_confirm_phrase": "Подтвердите секретную фразу",
"title_save_key": "Сохраните ключ восстановления",
"title_save_key": "Сохраните свой ключ безопасности",
"title_set_phrase": "Задайте секретную фразу",
"unable_to_setup": "Невозможно настроить секретное хранилище",
"use_different_passphrase": "Использовать другую кодовую фразу?",
"use_phrase_only_you_know": "Используйте секретную фразу, известную только вам, и при необходимости сохраните Ключ Восстановления от резервной копии."
"use_phrase_only_you_know": "Используйте секретную фразу, известную только вам, и при необходимости сохраните ключ безопасности для резервного копирования."
}
},
"key_export_import": {
@@ -2784,7 +2741,6 @@
"code_blocks_heading": "Блоки кода",
"compact_modern": "Использовать более компактный \"Современный\" макет",
"composer_heading": "Редактор",
"default_timezone": "Браузер по умолчанию (%(timezone)s)",
"dialog_title": "<strong>Настройки:</strong> Параметры",
"enable_hardware_acceleration": "Включить аппаратное ускорение",
"enable_tray_icon": "Показывать значок в трее и сворачивать в него окно при закрытии",
@@ -2810,7 +2766,6 @@
"bulk_options_accept_all_invites": "Принять все приглашения (%(invitedRooms)s)",
"bulk_options_reject_all_invites": "Отклонить все %(invitedRooms)s приглашения",
"bulk_options_section": "Основные опции",
"dehydrated_device_description": "Функция автономного устройства позволяет вам получать зашифрованные сообщения, даже если вы не вошли в систему ни на одном устройстве.",
"dehydrated_device_enabled": "Устройство в автономном режиме",
"dialog_title": "<strong>Настройки:</strong> Безопасность и конфиденциальность",
"e2ee_default_disabled_warning": "Администратор вашего сервера отключил сквозное шифрование по умолчанию в приватных комнатах и диалогах.",
@@ -3326,7 +3281,6 @@
"download_action_decrypting": "Расшифровка",
"download_action_downloading": "Загрузка",
"download_failed": "Загрузка не удалась",
"download_failed_description": "Произошла ошибка при загрузке этого файла",
"e2e_state": "Состояние сквозного шифрования",
"edits": {
"tooltip_label": "Изменено %(date)s. Нажмите для посмотра истории изменений.",
@@ -3482,7 +3436,6 @@
"left_reason": "%(targetName)s покинул(а) комнату: %(reason)s",
"no_change": "%(senderName)s не сделал(а) изменений",
"reject_invite": "%(targetName)s отклонил(а) приглашение",
"reject_invite_reason": "%(targetName)s отклонил приглашение: %(reason)s",
"remove_avatar": "%(senderName)s удалил(а) аватар",
"remove_name": "%(senderName)s удалил(а) отображаемое имя (%(oldDisplayName)s)",
"set_avatar": "%(senderName)s установил(а) аватар",
@@ -3519,10 +3472,9 @@
},
"m.room.tombstone": "%(senderDisplayName)s обновил(а) эту комнату.",
"m.room.topic": {
"changed": "%(senderDisplayName)s изменил(а) тему комнаты на \"%(topic)s\".",
"removed": "%(senderDisplayName)s удалил тему."
"changed": "%(senderDisplayName)s изменил(а) тему комнаты на \"%(topic)s\"."
},
"m.sticker": "%(senderDisplayName)s отправил(а) стикер.",
"m.sticker": "%(senderDisplayName)s отправил(а) наклейку.",
"m.video": {
"error_decrypting": "Ошибка расшифровки видео"
},
@@ -3572,8 +3524,7 @@
"reactions": {
"add_reaction_prompt": "Отреагировать",
"custom_reaction_fallback_label": "Пользовательская реакция",
"label": "%(reactors)s отреагировали %(content)s",
"tooltip_caption": "отреагировал с %(shortName)s"
"label": "%(reactors)s отреагировали %(content)s"
},
"read_receipt_title": {
"one": "Просмотрел %(count)s человек",
@@ -3762,10 +3713,6 @@
"few": "И еще %(count)s...",
"many": "И еще %(count)s..."
},
"unsupported_browser": {
"description": "Если вы продолжите, некоторые функции могут перестать работать, и существует риск потери данных в будущем. Обновите браузер, чтобы продолжить использование %(brand)s.",
"title": "%(brand)s не поддерживает этот браузер"
},
"unsupported_server_description": "На этом сервере используется старая версия Matrix. Перейдите на Matrix%(version)s, чтобы использовать %(brand)s ее без ошибок.",
"unsupported_server_title": "Ваш сервер не поддерживается",
"update": {
@@ -3783,12 +3730,6 @@
"toast_title": "Обновление %(brand)s",
"unavailable": "Недоступен"
},
"update_room_access_modal": {
"description": "Чтобы создать ссылку для совместного доступа, сделайте эту комнату <b>общедоступной</b> и разрешите пользователям <b>запрашивать присоединение</b>. Это позволит гостям присоединиться без приглашения.",
"dont_change_description": "Кроме того, вы можете провести звонок в отдельной комнате.",
"no_change": "Я не хочу менять уровень доступа.",
"title": "Изменить уровень доступа в комнату"
},
"upload_failed_generic": "Файл '%(fileName)s' не был загружен.",
"upload_failed_size": "Размер файла '%(fileName)s' превышает допустимый предел загрузки, установленный на этом сервере",
"upload_failed_title": "Сбой отправки файла",
@@ -3798,7 +3739,6 @@
"error_files_too_large": "Эти файлы <b>слишком большие</b> для загрузки. Лимит размера файла составляет %(limit)s.",
"error_some_files_too_large": "Некоторые файлы имеют <b>слишком большой размер</b>, чтобы их можно было загрузить. Лимит размера файла составляет %(limit)s.",
"error_title": "Ошибка загрузки",
"not_image": "Выбранный вами файл не является изображением.",
"title": "Загрузка файлов",
"title_progress": "Загрузка файлов (%(current)s из %(total)s)",
"upload_all_button": "Загрузить всё",
@@ -3818,7 +3758,7 @@
"deactivate_confirm_description": "Деактивация этого пользователя приведет к его выходу из системы и запрету повторного входа. Кроме того, они оставит все комнаты, в которых он участник. Это действие безповоротно. Вы уверены, что хотите деактивировать этого пользователя?",
"deactivate_confirm_title": "Деактивировать пользователя?",
"demote_button": "Понижение",
"demote_self_confirm_description_space": "Вы не сможете отменить это изменение, поскольку вы понижаете свои права, если вы являетесь последним привилегированным пользователем в пространстве, будет невозможно восстановить привилегии в будущем.",
"demote_self_confirm_description_space": "Вы не сможете отменить это изменение, поскольку вы понижаете свои права, если вы являетесь последним привилегированным пользователем в пространстве, будет невозможно восстановить привилегии вбудущем.",
"demote_self_confirm_room": "После понижения своих привилегий вы не сможете это отменить. Если вы являетесь последним привилегированным пользователем в этой комнате, выдать права кому-либо заново будет невозможно.",
"demote_self_confirm_title": "Понизить самого себя?",
"disinvite_button_room": "Отозвать приглашение в комнату",
@@ -3830,11 +3770,10 @@
"error_mute_user": "Не удалось заглушить пользователя",
"error_revoke_3pid_invite_description": "Не удалось отозвать приглашение. Возможно, на сервере возникла вре́менная проблема или у вас недостаточно прав для отзыва приглашения.",
"error_revoke_3pid_invite_title": "Не удалось отменить приглашение",
"ignore_button": "Игнорировать",
"ignore_confirm_description": "Все сообщения и приглашения от этого пользователя будут скрыты. Вы действительно хотите их игнорировать?",
"ignore_confirm_title": "Игнорировать %(user)s",
"invited_by": "Приглашен %(sender)s",
"jump_to_rr_button": "Перейти к последнему прочитанному сообщению",
"jump_to_rr_button": "Перейти к последнему прочтённому",
"kick_button_room": "Удалить из комнаты",
"kick_button_room_name": "Удалить из %(roomName)s",
"kick_button_space": "Исключить из пространства",
@@ -3858,22 +3797,19 @@
"no_recent_messages_description": "Попробуйте пролистать ленту сообщений вверх, чтобы увидеть, есть ли более ранние.",
"no_recent_messages_title": "Последние сообщения от %(user)s не найдены"
},
"redact_button": "Удалить сообщения",
"redact_button": "Удалить последние сообщения",
"revoke_invite": "Отозвать приглашение",
"room_encrypted": "Сообщения в этой комнате защищены сквозным шифрованием.",
"room_encrypted_detail": "Ваши сообщения в безопасности, ключи для расшифровки есть только у вас и получателя.",
"room_unencrypted": "Сообщения в этой комнате не защищены сквозным шифрованием.",
"room_unencrypted_detail": "В зашифрованных комнатах ваши сообщения в безопасности: только у вас и у получателя есть ключи для расшифровки.",
"send_message": "Отправить сообщение",
"share_button": "Поделиться профилем",
"share_button": "Поделиться ссылкой на пользователя",
"unban_button_room": "Разблокировать в комнате",
"unban_button_space": "Разблокировать в пространстве",
"unban_room_confirm_title": "Разблокировать в %(roomName)s",
"unban_space_everything": "Разблокировать их везде, где я могу это сделать",
"unban_space_specific": "Разблокировать их из определённых мест, где я могу это сделать",
"unban_space_warning": "Они не смогут получить доступ к тем местам, где вы не являетесь администратором.",
"unignore_button": "Не игнорировать",
"verification_unavailable": "Проверка пользователя недоступна",
"verify_button": "Подтвердить пользователя",
"verify_explainer": "Для дополнительной безопасности подтвердите этого пользователя, сравнив одноразовый код на ваших устройствах."
},
@@ -3902,7 +3838,6 @@
"camera_disabled": "Ваша камера выключена",
"camera_enabled": "Ваша камера всё ещё включена",
"cannot_call_yourself_description": "Вы не можете позвонить самому себе.",
"close_lobby": "Закрыть лобби",
"connecting": "Подключение",
"connection_lost": "Соединение с сервером потеряно",
"connection_lost_description": "Вы не можете совершать вызовы без подключения к серверу.",
@@ -3916,23 +3851,14 @@
"disabled_no_perms_start_video_call": "У вас нет разрешения для запуска видеозвонка",
"disabled_no_perms_start_voice_call": "У вас нет разрешения для запуска звонка",
"disabled_ongoing_call": "Текущий звонок",
"element_call": "Element Call",
"enable_camera": "Включить камеру",
"enable_microphone": "Включить микрофон",
"expand": "Вернуться к звонку",
"get_call_link": "Поделиться ссылкой на звонок",
"hangup": "Повесить трубку",
"hide_sidebar_button": "Скрыть боковую панель",
"input_devices": "Устройства ввода",
"jitsi_call": "Конференция Jitsi",
"join_button_tooltip_call_full": "Извините — этот вызов в настоящее время заполнен",
"legacy_call": "Звонок (устаревший)",
"maximise": "Заполнить экран",
"maximise_call": "Развернуть звонок",
"metaspace_video_rooms": {
"conference_room_section": "Конференции"
},
"minimise_call": "Свернуть звонок",
"misconfigured_server": "Вызов не состоялся из-за неправильно настроенного сервера",
"misconfigured_server_description": "Попросите администратора вашего домашнего сервера (<code>%(homeserverDomain)s</code>) настроить сервер TURN для надежной работы звонков.",
"misconfigured_server_fallback": "В качестве альтернативы вы можете попробовать использовать общедоступный сервер по адресу <server/>, но он не будет таким надежным, и ваш IP-адрес будет передаваться на сервер. Вы также можете управлять этим в настройках.",
@@ -3980,7 +3906,6 @@
"user_is_presenting": "%(sharerName)s показывает",
"video_call": "Видеовызов",
"video_call_started": "Начался видеозвонок",
"video_call_using": "Видеозвонок с использованием:",
"voice_call": "Голосовой вызов",
"you_are_presenting": "Вы представляете"
},
@@ -4080,7 +4005,7 @@
"error_need_to_be_logged_in": "Вы должны войти в систему.",
"error_unable_start_audio_stream_description": "Невозможно запустить аудио трансляцию.",
"error_unable_start_audio_stream_title": "Не удалось запустить прямую трансляцию",
"modal_data_warning": "Приведенные ниже данные передаются %(widgetDomain)s",
"modal_data_warning": "Данные на этом экране используются %(widgetDomain)s",
"modal_title_default": "Модальный виджет",
"no_name": "Неизвестное приложение",
"open_id_permissions_dialog": {
@@ -4089,7 +4014,7 @@
"title": "Разрешите этому виджету проверить ваш идентификатор"
},
"popout": "Всплывающий виджет",
"set_room_layout": "Установить макет для всех",
"set_room_layout": "Установить мой макет комнаты для всех",
"shared_data_avatar": "URL-адрес изображения вашего профиля",
"shared_data_device_id": "Идентификатор вашего устройства",
"shared_data_lang": "Ваш язык",

View File

@@ -3098,7 +3098,7 @@
"upgraderoom": "Uppgraderar ett rum till en ny version",
"upgraderoom_permission_error": "Du har inte de behörigheter som krävs för att använda det här kommandot.",
"usage": "Användande",
"verify": "Verifiera en av dina egna enheter manuellt",
"verify": "Verifierar en användar-, sessions- och pubkey-tupel",
"view": "Visar rum med den angivna adressen",
"whois": "Visar information om en användare"
},
@@ -3207,7 +3207,6 @@
"heading_without_query": "Sök efter",
"join_button_text": "Gå med i %(roomAddress)s",
"keyboard_scroll_hint": "Använd <arrows/> för att skrolla",
"messages_label": "Meddelanden",
"other_rooms_in_space": "Andra rum i %(spaceName)s",
"public_rooms_label": "Offentliga rum",
"public_spaces_label": "Offentliga utrymmen",

View File

@@ -6,8 +6,8 @@ Please see LICENSE files in the repository root for full details.
*/
import { createRoot, type Root } from "react-dom/client";
import { type Api, type RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
import type { Api, RuntimeModuleConstructor } from "@element-hq/element-web-module-api";
import { ModuleRunner } from "./ModuleRunner.ts";
import AliasCustomisations from "../customisations/Alias.ts";
import { RoomListCustomisations } from "../customisations/RoomList.ts";
@@ -21,8 +21,6 @@ import { WidgetPermissionCustomisations } from "../customisations/WidgetPermissi
import { WidgetVariableCustomisations } from "../customisations/WidgetVariables.ts";
import { ConfigApi } from "./ConfigApi.ts";
import { I18nApi } from "./I18nApi.ts";
import { CustomComponentsApi } from "./customComponentApi.ts";
import { BrandApi } from "./brandApi.ts";
const legacyCustomisationsFactory = <T extends object>(baseCustomisations: T) => {
let used = false;
@@ -60,9 +58,7 @@ class ModuleApi implements Api {
public readonly config = new ConfigApi();
public readonly i18n = new I18nApi();
public readonly customComponents = new CustomComponentsApi();
public readonly rootNode = document.getElementById("matrixchat")!;
public readonly brand = new BrandApi();
public createRoot(element: Element): Root {
return createRoot(element);

View File

@@ -1,36 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import type {
BrandApi as IBrandApi,
TitleRenderFunction,
TitleRenderOptions
} from "@element-hq/element-web-module-api";
export class BrandApi implements IBrandApi {
private registeredTitleFunction?: TitleRenderFunction;
public registerTitleRenderer(
func: TitleRenderFunction
): void {
if (this.registeredTitleFunction) {
throw Error('A custom title rendering function has already been registered');
}
this.registeredTitleFunction = func;
}
/**
* Returns the title text if a module has generated one, otherwise
* this returns undefined.
* @param opts Options to pass to the render function.
* @returns Title text, or undefined.
*/
public renderTitle(opts: TitleRenderOptions): string|undefined {
return this.registeredTitleFunction?.(opts);
}
}

View File

@@ -1,126 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { type MatrixEvent } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import type {
CustomComponentsApi as ICustomComponentsApi,
CustomMessageRenderFunction,
CustomMessageComponentProps as ModuleCustomMessageComponentProps,
OriginalComponentProps,
CustomMessageRenderHints,
MatrixEvent as ModuleMatrixEvent,
} from "@element-hq/element-web-module-api";
import type React from "react";
type EventTypeOrFilter = Parameters<ICustomComponentsApi["registerMessageRenderer"]>[0];
type EventRenderer = {
eventTypeOrFilter: EventTypeOrFilter;
renderer: CustomMessageRenderFunction;
hints: CustomMessageRenderHints;
};
interface CustomMessageComponentProps extends Omit<ModuleCustomMessageComponentProps, "mxEvent"> {
mxEvent: MatrixEvent;
}
export class CustomComponentsApi implements ICustomComponentsApi {
/**
* Convert a matrix-js-sdk event into a ModuleMatrixEvent.
* @param mxEvent
* @returns An event object, or `null` if the event was not a message event.
*/
private static getModuleMatrixEvent(mxEvent: MatrixEvent): ModuleMatrixEvent | null {
const eventId = mxEvent.getId();
const roomId = mxEvent.getRoomId();
const sender = mxEvent.sender;
// Typically we wouldn't expect messages without these keys to be rendered
// by the timeline, but for the sake of type safety.
if (!eventId || !roomId || !sender) {
// Not a message event.
return null;
}
return {
content: mxEvent.getContent(),
eventId,
originServerTs: mxEvent.getTs(),
roomId,
sender: sender.userId,
stateKey: mxEvent.getStateKey(),
type: mxEvent.getType(),
unsigned: mxEvent.getUnsigned(),
};
}
private readonly registeredMessageRenderers: EventRenderer[] = [];
public registerMessageRenderer(
eventTypeOrFilter: EventTypeOrFilter,
renderer: CustomMessageRenderFunction,
hints: CustomMessageRenderHints = {},
): void {
this.registeredMessageRenderers.push({ eventTypeOrFilter: eventTypeOrFilter, renderer, hints });
}
/**
* Select the correct renderer based on the event information.
* @param mxEvent The message event being rendered.
* @returns The registered renderer.
*/
private selectRenderer(mxEvent: ModuleMatrixEvent): EventRenderer | undefined {
return this.registeredMessageRenderers.find((renderer) => {
if (typeof renderer.eventTypeOrFilter === "string") {
return renderer.eventTypeOrFilter === mxEvent.type;
} else {
try {
return renderer.eventTypeOrFilter(mxEvent);
} catch (ex) {
logger.warn("Message renderer failed to process filter", ex);
return false; // Skip erroring renderers.
}
}
});
}
/**
* Render the component for a message event.
* @param props Props to be passed to the custom renderer.
* @param originalComponent Function that will be rendered if no custom renderers are present, or as a child of a custom component.
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
*/
public renderMessage(
props: CustomMessageComponentProps,
originalComponent?: (props?: OriginalComponentProps) => React.JSX.Element,
): React.JSX.Element | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(props.mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) {
try {
return renderer.renderer({ ...props, mxEvent: moduleEv }, originalComponent);
} catch (ex) {
logger.warn("Message renderer failed to render", ex);
// Fall through to original component. If the module encounters an error we still want to display messages to the user!
}
}
return originalComponent?.() ?? null;
}
/**
* Get hints about an message before rendering it.
* @param mxEvent The message event being rendered.
* @returns A component if a custom renderer exists, or originalComponent returns a value. Otherwise null.
*/
public getHintsForMessage(mxEvent: MatrixEvent): CustomMessageRenderHints | null {
const moduleEv = CustomComponentsApi.getModuleMatrixEvent(mxEvent);
const renderer = moduleEv && this.selectRenderer(moduleEv);
if (renderer) {
return renderer.hints;
}
return null;
}
}

View File

@@ -41,7 +41,6 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<EmptyObject
// pass SyncState.Error.
this.emitUpdateIfStateChanged(SyncState.Syncing, false);
});
this.emitUpdateIfStateChanged(SyncState.Syncing, true);
}
/**
@@ -108,7 +107,6 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<EmptyObject
* @internal public for test
*/
public emitUpdateIfStateChanged = (state: SyncState, forceEmit: boolean): void => {
console.log("emitUpdateIfStateChanged", this.matrixClient!);
if (!this.matrixClient) return;
// Only count visible rooms to not torment the user with notification counts in rooms they can't see.
// This will include highlights from the previous version of the room internally
@@ -136,8 +134,6 @@ export class RoomNotificationStateStore extends AsyncStoreWithClient<EmptyObject
) {
this._globalState = globalState;
this.emit(UPDATE_STATUS_INDICATOR, globalState, state);
} else {
console.log("skipping emit");
}
};

View File

@@ -1302,11 +1302,11 @@ export class SpaceStoreClass extends AsyncStoreWithClient<EmptyObject> {
const newValue = SettingsStore.getValue("Spaces.allRoomsInHome");
if (this.allRoomsInHome !== newValue) {
this._allRoomsInHome = newValue;
this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome);
if (this.enabledMetaSpaces.includes(MetaSpace.Home)) {
this.rebuildHomeSpace();
}
this.sendUserProperties();
this.emit(UPDATE_HOME_BEHAVIOUR, this.allRoomsInHome);
}
break;
}

View File

@@ -30,7 +30,6 @@ import { type TimelineRenderingType } from "../contexts/RoomContext";
import { launchPollEditor } from "../components/views/messages/MPollBody";
import { Action } from "../dispatcher/actions";
import { type ViewRoomPayload } from "../dispatcher/payloads/ViewRoomPayload";
import ModuleApi from "../modules/Api";
/**
* Returns whether an event should allow actions like reply, reactions, edit, etc.
@@ -78,10 +77,6 @@ export function canEditContent(matrixClient: MatrixClient, mxEvent: MatrixEvent)
return false;
}
if (ModuleApi.customComponents.getHintsForMessage(mxEvent)?.allowEditingEvent === false) {
return false;
}
const { msgtype, body } = mxEvent.getOriginalContent();
return (
M_POLL_START.matches(mxEvent.getType()) ||

View File

@@ -16,7 +16,6 @@ import { shouldPolyfill as shouldPolyFillIntlSegmenter } from "@formatjs/intl-se
// These are things that can run before the skin loads - be careful not to reference the react-sdk though.
import { parseQsFromFragment } from "./url_utils";
import "./modernizr";
import moduleApi from "../modules/Api";
// Require common CSS here; this will make webpack process it into bundle.css.
// Our own CSS (which is themed) is imported via separate webpack entry points
@@ -223,12 +222,6 @@ async function start(): Promise<void> {
await loadThemePromise;
await loadLanguagePromise;
// Render the title as early as we can so that the true brand pops up.
const moduleTitle = moduleApi.brand.renderTitle({});
if (moduleTitle) {
document.title = moduleTitle;
}
// We don't care if the log persistence made it through successfully, but we do want to
// make sure it had a chance to load before we move on. It's prepared much higher up in
// the process, making this the first time we check that it did something.

View File

@@ -89,10 +89,7 @@ export default class ElectronPlatform extends BasePlatform {
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
private readonly initialised: Promise<void>;
private readonly electron: Electron;
private protocol!: string;
private sessionId!: string;
private config!: IConfigOptions;
private supportedSettings?: Record<string, boolean>;
private parameters?: Awaited<ReturnType<Electron["initialise"]>>;
public constructor() {
super();
@@ -189,21 +186,17 @@ export default class ElectronPlatform extends BasePlatform {
super.onAction(payload);
// Whitelist payload actions, no point sending most across
if (["call_state"].includes(payload.action)) {
this.electron.send("app_onAction", payload);
this.electron.onCallState(payload.state);
}
}
private async initialise(): Promise<void> {
const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise();
this.protocol = protocol;
this.sessionId = sessionId;
this.config = config;
this.supportedSettings = supportedSettings;
this.parameters = await this.electron.initialise();
}
public async getConfig(): Promise<IConfigOptions | undefined> {
await this.initialised;
return this.config;
return this.parameters?.config;
}
private onBreadcrumbsUpdate = (): void => {
@@ -298,12 +291,13 @@ export default class ElectronPlatform extends BasePlatform {
}
public async getAppVersion(): Promise<string> {
return this.ipc.call("getAppVersion");
await this.initialised;
return this.parameters!.version;
}
public supportsSetting(settingName?: string): boolean {
if (settingName === undefined) return true;
return this.supportedSettings?.[settingName] === true;
return this.parameters?.supportedSettings[settingName] === true;
}
public getSettingValue(settingName: string): Promise<any> {
@@ -315,8 +309,8 @@ export default class ElectronPlatform extends BasePlatform {
}
public async canSelfUpdate(): Promise<boolean> {
const feedUrl = await this.ipc.call("getUpdateFeedUrl");
return Boolean(feedUrl);
await this.initialised;
return this.parameters!.canSelfUpdate;
}
public startUpdateCheck(): void {
@@ -352,7 +346,7 @@ export default class ElectronPlatform extends BasePlatform {
}
public async setLanguage(preferredLangs: string[]): Promise<any> {
return this.ipc.call("setLanguage", preferredLangs);
return this.electron.setSettingValue("locale", preferredLangs);
}
public setSpellCheckEnabled(enabled: boolean): void {
@@ -397,7 +391,7 @@ export default class ElectronPlatform extends BasePlatform {
public getSSOCallbackUrl(fragmentAfterLogin?: string): URL {
const url = super.getSSOCallbackUrl(fragmentAfterLogin);
url.protocol = "element";
url.searchParams.set(SSO_ID_KEY, this.sessionId);
url.searchParams.set(SSO_ID_KEY, this.parameters!.sessionId);
return url;
}
@@ -475,7 +469,7 @@ export default class ElectronPlatform extends BasePlatform {
}
public getOidcClientState(): string {
return `:${SSO_ID_KEY}:${this.sessionId}`;
return `:${SSO_ID_KEY}:${this.parameters!.sessionId}`;
}
/**
@@ -483,7 +477,7 @@ export default class ElectronPlatform extends BasePlatform {
*/
public getOidcCallbackUrl(): URL {
const url = super.getOidcCallbackUrl();
url.protocol = this.protocol;
url.protocol = this.parameters!.protocol;
// Trim the double slash into a single slash to comply with https://datatracker.ietf.org/doc/html/rfc8252#section-7.1
if (url.href.startsWith(`${url.protocol}//`)) {
url.href = url.href.replace("://", ":/");

View File

@@ -1040,7 +1040,7 @@ describe("<MatrixChat />", () => {
localStorage.removeItem("must_verify_device");
});
it("should show the Complete Security screen if unskippable verification is enabled", async () => {
it("should show the complete security screen if unskippable verification is enabled", async () => {
// Given we have force verification on, and an existing logged-in session
// that is not verified (see beforeEach())
@@ -1053,6 +1053,7 @@ describe("<MatrixChat />", () => {
// Sanity: we are not racing with another screen update, so this heading stays visible
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
});
it("should not open app after cancelling device verify if unskippable verification is on", async () => {
// See https://github.com/element-hq/element-web/issues/29230
// We used to allow bypassing force verification by choosing "Verify with
@@ -1080,50 +1081,6 @@ describe("<MatrixChat />", () => {
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
});
describe("when query params have a loginToken", () => {
const loginToken = "test-login-token";
const realQueryParams = {
loginToken,
};
let loginClient!: ReturnType<typeof getMockClientWithEventEmitter>;
const deviceId = "test-device-id";
const accessToken = "test-access-token";
const clientLoginResponse = {
user_id: userId,
device_id: deviceId,
access_token: accessToken,
};
beforeEach(() => {
localStorage.setItem("mx_sso_hs_url", serverConfig.hsUrl);
localStorage.setItem("mx_sso_is_url", serverConfig.isUrl);
loginClient = getMockClientWithEventEmitter(getMockClientMethods());
// this is used to create a temporary client during login
jest.spyOn(MatrixJs, "createClient").mockReturnValue(loginClient);
loginClient.login.mockClear().mockResolvedValue(clientLoginResponse);
});
it("should show the Complete Security screen after OIDC login if unskippable ver. is on", async () => {
// Given force_verification is on (outer describe)
// And we just logged in via OIDC (inner describe)
// When we load the page
getComponent({ realQueryParams });
defaultDispatcher.dispatch({
action: "will_start_client",
});
await waitFor(() =>
expect(defaultDispatcher.dispatch).toHaveBeenCalledWith({ action: "client_started" }),
);
// Then we are not allowed in - we are being asked to verify
await screen.findByRole("heading", { name: "Verify this device", level: 1 });
});
});
function createMockCrypto(): CryptoApi {
return {
getVersion: jest.fn().mockReturnValue("Version 0"),

View File

@@ -20,8 +20,7 @@ exports[`FilePanel renders empty state 1`] = `
</div>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="secondary"
class="_icon-button_m2erp_8 _subtle-bg_m2erp_29"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"

View File

@@ -48,8 +48,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
<button
aria-disabled="false"
aria-label="Video call"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -76,8 +75,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«rg9»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -102,8 +100,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
<button
aria-label="Threads"
aria-labelledby="«rge»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -129,8 +126,7 @@ exports[`RoomView for a local room in state CREATING should match the snapshot 1
<button
aria-label="Room info"
aria-labelledby="«rgj»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -267,8 +263,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
<button
aria-disabled="false"
aria-label="Video call"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -295,8 +290,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«rh7»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -321,8 +315,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
<button
aria-label="Threads"
aria-labelledby="«rhc»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -348,8 +341,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
<button
aria-label="Room info"
aria-labelledby="«rhh»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -411,7 +403,6 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
</div>
</header>
<main
aria-label="Room content"
class="mx_RoomView_body"
>
<div
@@ -572,8 +563,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
<button
aria-disabled="false"
aria-label="Video call"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -600,8 +590,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«rbt»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -626,8 +615,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
<button
aria-label="Threads"
aria-labelledby="«rc2»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -653,8 +641,7 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
<button
aria-label="Room info"
aria-labelledby="«rc7»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -716,7 +703,6 @@ exports[`RoomView for a local room in state NEW should match the snapshot 1`] =
</div>
</header>
<main
aria-label="Room content"
class="mx_RoomView_body"
>
<div
@@ -954,8 +940,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
<button
aria-disabled="false"
aria-label="Video call"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -982,8 +967,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
aria-disabled="false"
aria-label="Voice call"
aria-labelledby="«re3»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1008,8 +992,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
<button
aria-label="Threads"
aria-labelledby="«re8»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1035,8 +1018,7 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
<button
aria-label="Room info"
aria-labelledby="«red»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1098,7 +1080,6 @@ exports[`RoomView for a local room in state NEW that is encrypted should match t
</div>
</header>
<main
aria-label="Room content"
class="mx_RoomView_body"
>
<div
@@ -1417,8 +1398,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="true"
aria-label="There's no one here to call"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1445,8 +1425,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
aria-disabled="true"
aria-label="There's no one here to call"
aria-labelledby="«r2h»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1471,8 +1450,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-label="Threads"
aria-labelledby="«r2m»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1498,8 +1476,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-label="Room info"
aria-labelledby="«r2r»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1629,8 +1606,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-disabled="true"
aria-label="There's no one here to call"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1657,8 +1633,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
aria-disabled="true"
aria-label="There's no one here to call"
aria-labelledby="«r2h»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1683,8 +1658,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-label="Threads"
aria-labelledby="«r2m»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -1710,8 +1684,7 @@ exports[`RoomView should not display the timeline when the room encryption is lo
<button
aria-label="Room info"
aria-labelledby="«r2r»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -2014,8 +1987,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
<button
aria-label="Chat"
aria-labelledby="«r7c»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -2041,8 +2013,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
<button
aria-label="Threads"
aria-labelledby="«r7h»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -2068,8 +2039,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
<button
aria-label="Room info"
aria-labelledby="«r7m»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 32px;"
tabindex="0"
@@ -2172,8 +2142,7 @@ exports[`RoomView video rooms should render joined video room view 1`] = `
</div>
<button
aria-labelledby="«r84»"
class="_icon-button_1pz9o_8"
data-kind="secondary"
class="_icon-button_m2erp_8 _subtle-bg_m2erp_29"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"

View File

@@ -7,8 +7,7 @@ exports[`ThreadPanel Header expect that All filter for ThreadPanelHeader properl
>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"
@@ -46,8 +45,7 @@ exports[`ThreadPanel Header expect that My filter for ThreadPanelHeader properly
>
<button
aria-labelledby="«r6»"
class="_icon-button_1pz9o_8"
data-kind="primary"
class="_icon-button_m2erp_8"
role="button"
style="--cpd-icon-button-size: 28px;"
tabindex="0"

View File

@@ -1,173 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember, type IPowerLevelsContent } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import {
type RoomAdminToolsContainerProps,
useUserInfoAdminToolsContainerViewModel,
} from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("UserInfoAdminToolsContainerViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
let mockPowerLevels: IPowerLevelsContent;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultContainerProps: RoomAdminToolsContainerProps;
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockPowerLevels = {
users: {
"@currentuser:example.com": 100,
},
events: {},
state_default: 50,
ban: 50,
kick: 50,
redact: 50,
};
defaultContainerProps = {
room: mockRoom,
member: defaultMember,
powerLevels: mockPowerLevels,
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
const renderAdminToolsContainerHook = (props = defaultContainerProps) => {
return renderHook(
() => useUserInfoAdminToolsContainerViewModel(props),
withClientContextRenderOptions(mockClient),
);
};
describe("useUserInfoAdminToolsContainerViewModel", () => {
it("should return false when user is not in the room", () => {
mockRoom.getMember.mockReturnValue(null);
const { result } = renderAdminToolsContainerHook();
expect(result.current).toEqual({
isCurrentUserInTheRoom: false,
shouldShowKickButton: false,
shouldShowBanButton: false,
shouldShowMuteButton: false,
shouldShowRedactButton: false,
});
});
it("should not show kick, ban and mute buttons if user is me", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const props = {
...defaultContainerProps,
room: mockRoom,
member: mockMeMember,
powerLevels: mockPowerLevels,
};
const { result } = renderAdminToolsContainerHook(props);
expect(result.current).toEqual({
isCurrentUserInTheRoom: true,
shouldShowKickButton: false,
shouldShowBanButton: false,
shouldShowMuteButton: false,
shouldShowRedactButton: true,
});
});
it("returns mute toggle button if conditions met", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevelAndJoinMembership = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderAdminToolsContainerHook({
...defaultContainerProps,
member: defaultMemberWithPowerLevelAndJoinMembership,
powerLevels: { events: { "m.room.power_levels": 1 } },
});
expect(result.current.shouldShowMuteButton).toBe(true);
});
it("should not show mute button for one's own member", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getSafeUserId());
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
mockClient.getUserId.mockReturnValueOnce(mockMeMember.userId);
const { result } = renderAdminToolsContainerHook({
...defaultContainerProps,
member: mockMeMember,
powerLevels: { events: { "m.room.power_levels": 100 } },
});
expect(result.current.shouldShowMuteButton).toBe(false);
});
});
});

View File

@@ -1,224 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { cleanup, renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useBanButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
import Modal from "../../../../../../../src/Modal";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useBanButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockSpace: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithBanMembership = { ...defaultMember, membership: KnownMembership.Ban } as RoomMember;
let defaultAdminToolsProps: RoomAdminToolsProps;
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockSpace = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue("m.space"),
isSpaceRoom: jest.fn().mockReturnValue(true),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
defaultAdminToolsProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
mockRoom.getMember.mockReturnValue(defaultMember);
});
const renderBanButtonHook = (props = defaultAdminToolsProps) => {
return renderHook(() => useBanButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("renders the correct labels for banned and unbanned members", () => {
// test for room
const propsWithBanMembership = {
...defaultAdminToolsProps,
member: memberWithBanMembership,
};
// defaultMember is not banned
const { result } = renderBanButtonHook();
expect(result.current.banLabel).toBe("Ban from room");
cleanup();
const { result: result2 } = renderBanButtonHook(propsWithBanMembership);
expect(result2.current.banLabel).toBe("Unban from room");
cleanup();
// test for space
const { result: result3 } = renderBanButtonHook({ ...defaultAdminToolsProps, room: mockSpace });
expect(result3.current.banLabel).toBe("Ban from space");
cleanup();
const { result: result4 } = renderBanButtonHook({
...propsWithBanMembership,
room: mockSpace,
});
expect(result4.current.banLabel).toBe("Unban from space");
cleanup();
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user is not banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
const propsWithSpace = {
...defaultAdminToolsProps,
room: mockSpace,
};
const { result } = renderBanButtonHook(propsWithSpace);
await result.current.onBanOrUnbanClick();
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// truthy my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: "is not ban", powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user _is_ banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
const propsWithBanMembership = {
...defaultAdminToolsProps,
member: memberWithBanMembership,
room: mockSpace,
};
const { result } = renderBanButtonHook(propsWithBanMembership);
await result.current.onBanOrUnbanClick();
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: KnownMembership.Ban, powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});

View File

@@ -1,232 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { cleanup, renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useRoomKickButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
import Modal from "../../../../../../../src/Modal";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useRoomKickButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockSpace: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithInviteMembership = { ...defaultMember, membership: KnownMembership.Invite } as RoomMember;
const memberWithJoinMembership = { ...defaultMember, membership: KnownMembership.Join } as RoomMember;
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
let defaultAdminToolsProps: RoomAdminToolsProps;
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockSpace = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue("m.space"),
isSpaceRoom: jest.fn().mockReturnValue(true),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
defaultAdminToolsProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
// mock useContext to return mockClient
// jest.spyOn(React, "useContext").mockReturnValue(mockClient);
mockRoom.getMember.mockReturnValue(defaultMember);
});
afterEach(() => {
createDialogSpy.mockReset();
});
const renderKickButtonHook = (props = defaultAdminToolsProps) => {
return renderHook(() => useRoomKickButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("renders nothing if member.membership is undefined", () => {
// .membership is undefined in our member by default
const { result } = renderKickButtonHook();
expect(result.current.canUserBeKicked).toBe(false);
});
it("renders something if member.membership is 'invite' or 'join'", () => {
let props = {
...defaultAdminToolsProps,
member: memberWithInviteMembership,
};
const { result } = renderKickButtonHook(props);
expect(result.current.canUserBeKicked).toBe(true);
cleanup();
props = {
...defaultAdminToolsProps,
member: memberWithJoinMembership,
};
const { result: result2 } = renderKickButtonHook(props);
expect(result2.current.canUserBeKicked).toBe(true);
});
it("renders the correct label", () => {
// test for room
const propsWithJoinMembership = {
...defaultAdminToolsProps,
member: memberWithJoinMembership,
};
const { result } = renderKickButtonHook(propsWithJoinMembership);
expect(result.current.kickLabel).toBe("Remove from room");
cleanup();
const propsWithInviteMembership = {
...defaultAdminToolsProps,
member: memberWithInviteMembership,
};
const { result: result2 } = renderKickButtonHook(propsWithInviteMembership);
expect(result2.current.kickLabel).toBe("Disinvite from room");
cleanup();
});
it("renders the correct label for space", () => {
const propsWithInviteMembership = {
...defaultAdminToolsProps,
room: mockSpace,
member: memberWithInviteMembership,
};
const propsWithJoinMembership = {
...defaultAdminToolsProps,
room: mockSpace,
member: memberWithJoinMembership,
};
const { result: result3 } = renderKickButtonHook(propsWithJoinMembership);
expect(result3.current.kickLabel).toBe("Remove from space");
cleanup();
const { result: result4 } = renderKickButtonHook(propsWithInviteMembership);
expect(result4.current.kickLabel).toBe("Disinvite from space");
cleanup();
});
it("clicking the kick button calls Modal.createDialog with the correct arguments when room is a space", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
const propsWithInviteMembership = {
...defaultAdminToolsProps,
room: mockSpace,
member: memberWithInviteMembership,
};
const { result } = renderKickButtonHook(propsWithInviteMembership);
await result.current.onKickClick();
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: KnownMembership.Invite, powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});

View File

@@ -1,230 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import {
type Room,
type MatrixClient,
RoomMember,
type MatrixEvent,
type ISendEventResponse,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { type RoomAdminToolsProps } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useMuteButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
import { isMuted } from "../../../../../../../src/components/views/right_panel/UserInfo";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useMuteButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
let defaultAdminToolsProps: RoomAdminToolsProps;
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
defaultAdminToolsProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
mockClient.setPowerLevel.mockImplementation(() => Promise.resolve({} as ISendEventResponse));
mockRoom.currentState.getStateEvents.mockReturnValueOnce({
getContent: jest.fn().mockReturnValue({
events: {
"m.room.message": 0,
},
events_default: 0,
}),
} as unknown as MatrixEvent);
jest.spyOn(mockClient, "setPowerLevel").mockImplementation(() => Promise.resolve({} as ISendEventResponse));
jest.spyOn(mockRoom.currentState, "getStateEvents").mockReturnValue({
getContent: jest.fn().mockReturnValue({
events: {
"m.room.message": 0,
},
events_default: 0,
}),
} as unknown as MatrixEvent);
});
afterEach(() => {
jest.clearAllMocks();
});
const renderMuteButtonHook = (props = defaultAdminToolsProps) => {
return renderHook(() => useMuteButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("should early return when isUpdating=true", async () => {
const defaultMemberWithPowerLevelAndJoinMembership = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultMemberWithPowerLevelAndJoinMembership,
isUpdating: true,
});
const resultClick = await result.current.onMuteButtonClick();
expect(resultClick).toBe(undefined);
});
it("should stop updating when level is NaN", async () => {
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultMember,
isUpdating: false,
});
jest.spyOn(mockRoom.currentState, "getStateEvents").mockReturnValueOnce({
getContent: jest.fn().mockReturnValue({
events: {
"m.room.message": NaN,
},
events_default: NaN,
}),
} as unknown as MatrixEvent);
await result.current.onMuteButtonClick();
expect(defaultAdminToolsProps.stopUpdating).toHaveBeenCalled();
});
it("should set powerlevel to default when user is muted", async () => {
const defaultMutedMember = {
...defaultMember,
powerLevel: -1,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultMutedMember,
isUpdating: false,
});
await result.current.onMuteButtonClick();
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, 0);
});
it("should set powerlevel - 1 when user is unmuted", async () => {
const defaultUnmutedMember = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
} as RoomMember;
const { result } = renderMuteButtonHook({
...defaultAdminToolsProps,
member: defaultUnmutedMember,
isUpdating: false,
});
await result.current.onMuteButtonClick();
expect(mockClient.setPowerLevel).toHaveBeenCalledWith(mockRoom.roomId, defaultMember.userId, -1);
});
it("returns false if either argument is falsy", () => {
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(defaultMember, null)).toBe(false);
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(null, {})).toBe(false);
});
it("when powerLevelContent.events and .events_default are undefined, returns false", () => {
const powerLevelContents = {};
expect(isMuted(defaultMember, powerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is undefined, uses .events_default", () => {
const higherPowerLevelContents = { events_default: 10 };
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events_default: -10 };
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => {
const higherPowerLevelContents = { events: {}, events_default: 10 };
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events: {}, events_default: -10 };
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => {
const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 };
expect(isMuted(defaultMember, higherPowerLevelContents)).toBe(false);
const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 };
expect(isMuted(defaultMember, lowerPowerLevelContents)).toBe(true);
});
});

View File

@@ -1,98 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import { renderHook } from "jest-matrix-react";
import { type Mocked, mocked } from "jest-mock";
import { type Room, type MatrixClient, RoomMember } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../../../../../../../src/MatrixClientPeg";
import { useRedactMessagesButtonViewModel } from "../../../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
import Modal from "../../../../../../../src/Modal";
import BulkRedactDialog from "../../../../../../../src/components/views/dialogs/BulkRedactDialog";
import { withClientContextRenderOptions } from "../../../../../../test-utils";
describe("useRedactMessagesButtonViewModel", () => {
const defaultRoomId = "!fkfk";
const defaultUserId = "@user:example.com";
let mockRoom: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
beforeEach(() => {
mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockClient = mocked({
getUser: jest.fn(),
isGuest: jest.fn().mockReturnValue(false),
isUserIgnored: jest.fn(),
getIgnoredUsers: jest.fn(),
setIgnoredUsers: jest.fn(),
getUserId: jest.fn().mockReturnValue(defaultUserId),
getSafeUserId: jest.fn(),
getDomain: jest.fn(),
on: jest.fn(),
off: jest.fn(),
isSynapseAdministrator: jest.fn().mockResolvedValue(false),
doesServerSupportUnstableFeature: jest.fn().mockReturnValue(false),
doesServerSupportExtendedProfiles: jest.fn().mockResolvedValue(false),
getExtendedProfileProperty: jest.fn().mockRejectedValue(new Error("Not supported")),
mxcUrlToHttp: jest.fn().mockReturnValue("mock-mxcUrlToHttp"),
removeListener: jest.fn(),
currentState: {
on: jest.fn(),
},
getRoom: jest.fn(),
credentials: {},
setPowerLevel: jest.fn(),
} as unknown as MatrixClient);
jest.spyOn(MatrixClientPeg, "get").mockReturnValue(mockClient);
jest.spyOn(MatrixClientPeg, "safeGet").mockReturnValue(mockClient);
});
const renderRedactButtonHook = (props = defaultMember) => {
return renderHook(() => useRedactMessagesButtonViewModel(props), withClientContextRenderOptions(mockClient));
};
it("should show BulkRedactDialog upon clicking the Remove messages button", async () => {
const spy = jest.spyOn(Modal, "createDialog");
mockClient.getRoom.mockReturnValue(mockRoom);
mockClient.getUserId.mockReturnValue("@arbitraryId:server");
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getUserId()!);
mockMeMember.powerLevel = 51; // defaults to 50
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 } as RoomMember;
mockRoom.getMember.mockImplementation((userId) =>
userId === mockClient.getUserId() ? mockMeMember : defaultMemberWithPowerLevel,
);
const { result } = renderRedactButtonHook();
await result.current.onRedactAllMessagesClick();
expect(spy).toHaveBeenCalledWith(
BulkRedactDialog,
expect.objectContaining({ member: defaultMemberWithPowerLevel }),
);
});
});

View File

@@ -13,12 +13,17 @@ import { DeviceVerificationStatus } from "matrix-js-sdk/src/crypto-api";
import { stubClient } from "../../../../test-utils";
import { ManualDeviceKeyVerificationDialog } from "../../../../../src/components/views/dialogs/ManualDeviceKeyVerificationDialog";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
describe("ManualDeviceKeyVerificationDialog", () => {
let mockClient: MatrixClient;
function renderDialog(onFinished: (confirm: boolean) => void) {
return render(<ManualDeviceKeyVerificationDialog onFinished={onFinished} />);
return render(
<MatrixClientContext.Provider value={mockClient}>
<ManualDeviceKeyVerificationDialog onFinished={onFinished} />
</MatrixClientContext.Provider>,
);
}
beforeEach(() => {

View File

@@ -32,8 +32,7 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] =
</div>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="secondary"
class="_icon-button_m2erp_8 _subtle-bg_m2erp_29"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { fireEvent, render, screen, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import { fireEvent, render, screen, cleanup, act, waitForElementToBeRemoved, waitFor } from "jest-matrix-react";
import userEvent from "@testing-library/user-event";
import { type Mocked, mocked } from "jest-mock";
import {
@@ -19,6 +19,7 @@ import {
EventType,
Device,
} from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types";
import { EventEmitter } from "events";
import {
UserVerificationStatus,
@@ -29,9 +30,13 @@ import {
} from "matrix-js-sdk/src/crypto-api";
import UserInfo, {
BanToggleButton,
disambiguateDevices,
getPowerLevels,
isMuted,
PowerLevelEditor,
RoomAdminToolsContainer,
RoomKickButton,
UserInfoHeader,
UserOptionsSection,
} from "../../../../../src/components/views/right_panel/UserInfo";
@@ -48,6 +53,7 @@ import { shouldShowComponent } from "../../../../../src/customisations/helpers/U
import { UIComponent } from "../../../../../src/settings/UIFeature";
import { Action } from "../../../../../src/dispatcher/actions";
import { ShareDialog } from "../../../../../src/components/views/dialogs/ShareDialog";
import BulkRedactDialog from "../../../../../src/components/views/dialogs/BulkRedactDialog";
jest.mock("../../../../../src/utils/direct-messages", () => ({
...jest.requireActual("../../../../../src/utils/direct-messages"),
@@ -86,6 +92,7 @@ const defaultUserId = "@user:example.com";
const defaultUser = new User(defaultUserId);
let mockRoom: Mocked<Room>;
let mockSpace: Mocked<Room>;
let mockClient: Mocked<MatrixClient>;
let mockCrypto: Mocked<CryptoApi>;
const origDate = global.Date.prototype.toLocaleString;
@@ -108,6 +115,23 @@ beforeEach(() => {
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockSpace = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue("m.space"),
isSpaceRoom: jest.fn().mockReturnValue(true),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
mockCrypto = mocked({
getDeviceVerificationStatus: jest.fn(),
getUserDeviceInfo: jest.fn(),
@@ -776,6 +800,384 @@ describe("<PowerLevelEditor />", () => {
});
});
describe("<RoomKickButton />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithInviteMembership = { ...defaultMember, membership: KnownMembership.Invite };
const memberWithJoinMembership = { ...defaultMember, membership: KnownMembership.Join };
let defaultProps: Parameters<typeof RoomKickButton>[0];
beforeEach(() => {
defaultProps = {
room: mockRoom,
member: defaultMember,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
isUpdating: false,
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<RoomKickButton {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
afterEach(() => {
createDialogSpy.mockReset();
});
it("renders nothing if member.membership is undefined", () => {
// .membership is undefined in our member by default
const { container } = renderComponent();
expect(container).toBeEmptyDOMElement();
});
it("renders something if member.membership is 'invite' or 'join'", () => {
let result = renderComponent({ member: memberWithInviteMembership });
expect(result.container).not.toBeEmptyDOMElement();
cleanup();
result = renderComponent({ member: memberWithJoinMembership });
expect(result.container).not.toBeEmptyDOMElement();
});
it("renders the correct label", () => {
// test for room
renderComponent({ member: memberWithJoinMembership });
expect(screen.getByText(/remove from room/i)).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithInviteMembership });
expect(screen.getByText(/disinvite from room/i)).toBeInTheDocument();
cleanup();
// test for space
mockRoom.isSpaceRoom.mockReturnValue(true);
renderComponent({ member: memberWithJoinMembership });
expect(screen.getByText(/remove from space/i)).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithInviteMembership });
expect(screen.getByText(/disinvite from space/i)).toBeInTheDocument();
cleanup();
mockRoom.isSpaceRoom.mockReturnValue(false);
});
it("clicking the kick button calls Modal.createDialog with the correct arguments", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
renderComponent({ room: mockSpace, member: memberWithInviteMembership });
await userEvent.click(screen.getByText(/disinvite from/i));
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: KnownMembership.Invite, powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});
describe("<BanToggleButton />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
const memberWithBanMembership = { ...defaultMember, membership: KnownMembership.Ban };
let defaultProps: Parameters<typeof BanToggleButton>[0];
beforeEach(() => {
defaultProps = {
room: mockRoom,
member: defaultMember,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
isUpdating: false,
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<BanToggleButton {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
const createDialogSpy: jest.SpyInstance = jest.spyOn(Modal, "createDialog");
afterEach(() => {
createDialogSpy.mockReset();
});
it("renders the correct labels for banned and unbanned members", () => {
// test for room
// defaultMember is not banned
renderComponent();
expect(screen.getByText("Ban from room")).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithBanMembership });
expect(screen.getByText("Unban from room")).toBeInTheDocument();
cleanup();
// test for space
mockRoom.isSpaceRoom.mockReturnValue(true);
renderComponent();
expect(screen.getByText("Ban from space")).toBeInTheDocument();
cleanup();
renderComponent({ member: memberWithBanMembership });
expect(screen.getByText("Unban from space")).toBeInTheDocument();
cleanup();
mockRoom.isSpaceRoom.mockReturnValue(false);
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user is not banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
renderComponent({ room: mockSpace });
await userEvent.click(screen.getByText(/ban from/i));
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// truthy my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: "is not ban", powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
it("clicking the ban or unban button calls Modal.createDialog with the correct arguments if user _is_ banned", async () => {
createDialogSpy.mockReturnValueOnce({ finished: Promise.resolve([]), close: jest.fn() });
renderComponent({ room: mockSpace, member: memberWithBanMembership });
await userEvent.click(screen.getByText(/ban from/i));
// check the last call arguments and the presence of the spaceChildFilter callback
expect(createDialogSpy).toHaveBeenLastCalledWith(
expect.any(Function),
expect.objectContaining({ spaceChildFilter: expect.any(Function) }),
"mx_ConfirmSpaceUserActionDialog_wrapper",
);
// test the spaceChildFilter callback
const callback = createDialogSpy.mock.lastCall[1].spaceChildFilter;
// make dummy values for myMember and theirMember, then we will test
// null vs their member followed by
// my member vs their member
const mockMyMember = { powerLevel: 1 };
const mockTheirMember = { membership: KnownMembership.Ban, powerLevel: 0 };
const mockRoom = {
getMember: jest
.fn()
.mockReturnValueOnce(null)
.mockReturnValueOnce(mockTheirMember)
.mockReturnValueOnce(mockMyMember)
.mockReturnValueOnce(mockTheirMember),
currentState: {
hasSufficientPowerLevelFor: jest.fn().mockReturnValue(true),
},
};
expect(callback(mockRoom)).toBe(false);
expect(callback(mockRoom)).toBe(true);
});
});
describe("<RoomAdminToolsContainer />", () => {
const defaultMember = new RoomMember(defaultRoomId, defaultUserId);
defaultMember.membership = KnownMembership.Invite;
let defaultProps: Parameters<typeof RoomAdminToolsContainer>[0];
beforeEach(() => {
defaultProps = {
room: mockRoom,
member: defaultMember,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
powerLevels: {},
};
});
const renderComponent = (props = {}) => {
const Wrapper = (wrapperProps = {}) => {
return <MatrixClientContext.Provider value={mockClient} {...wrapperProps} />;
};
return render(<RoomAdminToolsContainer {...defaultProps} {...props} />, {
wrapper: Wrapper,
});
};
it("returns a single empty div if room.getMember is falsy", () => {
const { asFragment } = renderComponent();
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div />
</DocumentFragment>
`);
});
it("can return a single empty div in case where room.getMember is not falsy", () => {
mockRoom.getMember.mockReturnValueOnce(defaultMember);
const { asFragment } = renderComponent();
expect(asFragment()).toMatchInlineSnapshot(`
<DocumentFragment>
<div />
</DocumentFragment>
`);
});
it("returns kick, redact messages, ban buttons if conditions met", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 };
renderComponent({ member: defaultMemberWithPowerLevel });
expect(screen.getByRole("button", { name: "Disinvite from room" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Ban from room" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Remove messages" })).toBeInTheDocument();
});
it("should show BulkRedactDialog upon clicking the Remove messages button", async () => {
const spy = jest.spyOn(Modal, "createDialog");
mockClient.getRoom.mockReturnValue(mockRoom);
mockClient.getUserId.mockReturnValue("@arbitraryId:server");
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getUserId()!);
mockMeMember.powerLevel = 51; // defaults to 50
const defaultMemberWithPowerLevel = { ...defaultMember, powerLevel: 0 } as RoomMember;
mockRoom.getMember.mockImplementation((userId) =>
userId === mockClient.getUserId() ? mockMeMember : defaultMemberWithPowerLevel,
);
renderComponent({ member: defaultMemberWithPowerLevel });
await userEvent.click(screen.getByRole("button", { name: "Remove messages" }));
expect(spy).toHaveBeenCalledWith(
BulkRedactDialog,
expect.objectContaining({ member: defaultMemberWithPowerLevel }),
);
});
it("returns mute toggle button if conditions met", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevelAndJoinMembership = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
};
renderComponent({
member: defaultMemberWithPowerLevelAndJoinMembership,
powerLevels: { events: { "m.room.power_levels": 1 } },
});
const button = screen.getByText(/mute/i);
expect(button).toBeInTheDocument();
fireEvent.click(button);
expect(defaultProps.startUpdating).toHaveBeenCalled();
});
it("should disable buttons when isUpdating=true", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, "arbitraryId");
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
const defaultMemberWithPowerLevelAndJoinMembership = {
...defaultMember,
powerLevel: 0,
membership: KnownMembership.Join,
};
renderComponent({
member: defaultMemberWithPowerLevelAndJoinMembership,
powerLevels: { events: { "m.room.power_levels": 1 } },
isUpdating: true,
});
const button = screen.getByRole("button", { name: "Mute" });
expect(button).toBeInTheDocument();
expect(button).toBeDisabled();
});
it("should not show mute button for one's own member", () => {
const mockMeMember = new RoomMember(mockRoom.roomId, mockClient.getSafeUserId());
mockMeMember.powerLevel = 51; // defaults to 50
mockRoom.getMember.mockReturnValueOnce(mockMeMember);
renderComponent({
member: mockMeMember,
powerLevels: { events: { "m.room.power_levels": 100 } },
});
const button = screen.queryByText(/mute/i);
expect(button).not.toBeInTheDocument();
});
});
describe("disambiguateDevices", () => {
it("does not add ambiguous key to unique names", () => {
const initialDevices = [
@@ -815,6 +1217,47 @@ describe("disambiguateDevices", () => {
});
});
describe("isMuted", () => {
// this member has a power level of 0
const isMutedMember = new RoomMember(defaultRoomId, defaultUserId);
it("returns false if either argument is falsy", () => {
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(isMutedMember, null)).toBe(false);
// @ts-ignore to let us purposely pass incorrect args
expect(isMuted(null, {})).toBe(false);
});
it("when powerLevelContent.events and .events_default are undefined, returns false", () => {
const powerLevelContents = {};
expect(isMuted(isMutedMember, powerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is undefined, uses .events_default", () => {
const higherPowerLevelContents = { events_default: 10 };
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events_default: -10 };
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events is defined but '.m.room.message' isn't, uses .events_default", () => {
const higherPowerLevelContents = { events: {}, events_default: 10 };
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(true);
const lowerPowerLevelContents = { events: {}, events_default: -10 };
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(false);
});
it("when powerLevelContent.events and '.m.room.message' are defined, uses the value", () => {
const higherPowerLevelContents = { events: { "m.room.message": -10 }, events_default: 10 };
expect(isMuted(isMutedMember, higherPowerLevelContents)).toBe(false);
const lowerPowerLevelContents = { events: { "m.room.message": 10 }, events_default: -10 };
expect(isMuted(isMutedMember, lowerPowerLevelContents)).toBe(true);
});
});
describe("getPowerLevels", () => {
it("returns an empty object when room.currentState.getStateEvents return null", () => {
mockRoom.currentState.getStateEvents.mockReturnValueOnce(null);

View File

@@ -1,306 +0,0 @@
/*
Copyright 2025 New Vector Ltd.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { render, screen, fireEvent } from "jest-matrix-react";
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import { mocked } from "jest-mock";
import { UserInfoAdminToolsContainer } from "../../../../../src/components/views/right_panel/user_info/UserInfoAdminToolsContainer";
import { useUserInfoAdminToolsContainerViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel";
import { useRoomKickButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel";
import { useBanButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel";
import { useMuteButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel";
import { useRedactMessagesButtonViewModel } from "../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel";
import { stubClient } from "../../../../test-utils";
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
jest.mock("../../../../../src/utils/DMRoomMap", () => {
const mock = {
getUserIdForRoomId: jest.fn(),
getDMRoomsForUserId: jest.fn(),
};
return {
shared: jest.fn().mockReturnValue(mock),
sharedInstance: mock,
};
});
jest.mock(
"../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoAdminToolsContainerViewModel",
() => ({
useUserInfoAdminToolsContainerViewModel: jest.fn().mockReturnValue({
isCurrentUserInTheRoom: true,
shouldShowKickButton: true,
shouldShowBanButton: true,
shouldShowMuteButton: true,
shouldShowRedactButton: true,
}),
}),
);
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoKickButtonViewModel", () => ({
useRoomKickButtonViewModel: jest.fn().mockReturnValue({
canUserBeKicked: true,
kickLabel: "Kick",
onKickClick: jest.fn(),
}),
}));
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoBanButtonViewModel", () => ({
useBanButtonViewModel: jest.fn().mockReturnValue({
banLabel: "Ban",
onBanOrUnbanClick: jest.fn(),
}),
}));
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoMuteButtonViewModel", () => ({
useMuteButtonViewModel: jest.fn().mockReturnValue({
isMemberInTheRoom: true,
muteLabel: "Mute",
onMuteButtonClick: jest.fn(),
}),
}));
jest.mock("../../../../../src/components/viewmodels/right_panel/user_info/admin/UserInfoRedactButtonViewModel", () => ({
useRedactMessagesButtonViewModel: jest.fn().mockReturnValue({
onRedactAllMessagesClick: jest.fn(),
}),
}));
const defaultRoomId = "!fkfk";
describe("UserInfoAdminToolsContainer", () => {
// Setup it data
const mockRoom = mocked({
roomId: defaultRoomId,
getType: jest.fn().mockReturnValue(undefined),
isSpaceRoom: jest.fn().mockReturnValue(false),
getMember: jest.fn().mockReturnValue(undefined),
getMxcAvatarUrl: jest.fn().mockReturnValue("mock-avatar-url"),
name: "test room",
on: jest.fn(),
off: jest.fn(),
currentState: {
getStateEvents: jest.fn(),
on: jest.fn(),
off: jest.fn(),
},
getEventReadUpTo: jest.fn(),
} as unknown as Room);
const mockMember = {
userId: "@user:example.com",
membership: "join",
powerLevel: 0,
} as unknown as RoomMember;
const mockPowerLevels = {
users: {
"@currentuser:example.com": 100,
},
events: {},
state_default: 50,
ban: 50,
kick: 50,
redact: 50,
};
const defaultProps = {
room: mockRoom,
member: mockMember,
powerLevels: mockPowerLevels,
isUpdating: false,
startUpdating: jest.fn(),
stopUpdating: jest.fn(),
};
const mockMatrixClient = stubClient();
const renderComponent = (props = defaultProps) => {
return render(
<MatrixClientContext.Provider value={mockMatrixClient}>
<UserInfoAdminToolsContainer {...props} />
</MatrixClientContext.Provider>,
);
};
beforeEach(() => {
mocked(useUserInfoAdminToolsContainerViewModel).mockReturnValue({
isCurrentUserInTheRoom: true,
shouldShowKickButton: true,
shouldShowBanButton: true,
shouldShowMuteButton: true,
shouldShowRedactButton: true,
});
jest.clearAllMocks();
});
it("renders all admin tools when user has permissions", () => {
renderComponent();
// Check that all buttons are rendered
expect(screen.getByText("Mute")).toBeInTheDocument();
expect(screen.getByText("Kick")).toBeInTheDocument();
expect(screen.getByText("Ban")).toBeInTheDocument();
expect(screen.getByText("Remove messages")).toBeInTheDocument();
});
it("renders no admin tools when current user is not in the room", () => {
mocked(useUserInfoAdminToolsContainerViewModel).mockReturnValue({
isCurrentUserInTheRoom: false,
shouldShowKickButton: false,
shouldShowBanButton: false,
shouldShowMuteButton: false,
shouldShowRedactButton: false,
});
const { container } = renderComponent();
// Should render an empty div
expect(container.firstChild).toBeEmptyDOMElement();
});
it("renders children when provided", () => {
render(
<UserInfoAdminToolsContainer {...defaultProps}>
<div data-testid="child-element">Custom Child</div>
</UserInfoAdminToolsContainer>,
);
expect(screen.getByTestId("child-element")).toBeInTheDocument();
expect(screen.getByText("Custom Child")).toBeInTheDocument();
});
describe("Kick behavior", () => {
it("clicking kick button calls the appropriate handler", () => {
const mockedOnKickClick = jest.fn();
mocked(useRoomKickButtonViewModel).mockReturnValue({
canUserBeKicked: true,
kickLabel: "Kick",
onKickClick: mockedOnKickClick,
});
renderComponent();
const kickButton = screen.getByText("Kick");
fireEvent.click(kickButton);
expect(mockedOnKickClick).toHaveBeenCalled();
});
it("should not display kick buttun if user can't be kicked", () => {
mocked(useRoomKickButtonViewModel).mockReturnValue({
canUserBeKicked: false,
kickLabel: "Kick",
onKickClick: jest.fn(),
});
renderComponent();
expect(screen.queryByText("Kick")).not.toBeInTheDocument();
});
it("should display the correct label when user can be disinvited", () => {
mocked(useRoomKickButtonViewModel).mockReturnValue({
canUserBeKicked: true,
kickLabel: "Disinvite",
onKickClick: jest.fn(),
});
renderComponent({
...defaultProps,
member: mockMember,
});
expect(screen.getByText("Disinvite")).toBeInTheDocument();
});
});
describe("Ban behavior", () => {
it("clicking ban button calls the appropriate handler", () => {
const mockedOnBanOrUnbanClick = jest.fn();
mocked(useBanButtonViewModel).mockReturnValue({
banLabel: "Ban",
onBanOrUnbanClick: mockedOnBanOrUnbanClick,
});
renderComponent();
const banButton = screen.getByText("Ban");
fireEvent.click(banButton);
expect(mockedOnBanOrUnbanClick).toHaveBeenCalled();
});
it("should display the correct label", () => {
const mockedOnBanOrUnbanClick = jest.fn();
mocked(useBanButtonViewModel).mockReturnValue({
banLabel: "Unban",
onBanOrUnbanClick: mockedOnBanOrUnbanClick,
});
renderComponent();
// The label should be "Unban"
expect(screen.getByText("Unban")).toBeInTheDocument();
});
});
describe("Mute behavior", () => {
it("clicking mute button calls the appropriate handler", () => {
const mockedOnMuteButtonClick = jest.fn();
mocked(useMuteButtonViewModel).mockReturnValue({
isMemberInTheRoom: true,
muteLabel: "Mute",
onMuteButtonClick: mockedOnMuteButtonClick,
});
renderComponent();
const muteButton = screen.getByText("Mute");
fireEvent.click(muteButton);
expect(mockedOnMuteButtonClick).toHaveBeenCalled();
});
it("should not display mute button if user is not in the room", () => {
mocked(useMuteButtonViewModel).mockReturnValue({
isMemberInTheRoom: false,
muteLabel: "Mute",
onMuteButtonClick: jest.fn(),
});
renderComponent();
expect(screen.queryByText("Mute")).not.toBeInTheDocument();
});
it("should display the correct label", () => {
mocked(useMuteButtonViewModel).mockReturnValue({
isMemberInTheRoom: true,
muteLabel: "Mute",
onMuteButtonClick: jest.fn(),
});
renderComponent();
expect(screen.getByText("Mute")).toBeInTheDocument();
});
});
describe("Redact behavior", () => {
it("clicking redact button calls the appropriate handler", () => {
const mockedOnRedactAllMessagesClick = jest.fn();
mocked(useRedactMessagesButtonViewModel).mockReturnValue({
onRedactAllMessagesClick: mockedOnRedactAllMessagesClick,
});
renderComponent();
const redactButton = screen.getByText("Remove messages");
fireEvent.click(redactButton);
expect(mockedOnRedactAllMessagesClick).toHaveBeenCalled();
});
});
});

View File

@@ -20,8 +20,7 @@ exports[`<BaseCard /> should close when clicking X button 1`] = `
</div>
<button
aria-labelledby="«r0»"
class="_icon-button_1pz9o_8"
data-kind="secondary"
class="_icon-button_m2erp_8 _subtle-bg_m2erp_29"
data-testid="base-card-close-button"
role="button"
style="--cpd-icon-button-size: 28px;"

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