Compare commits

..

25 Commits

Author SHA1 Message Date
Half-Shot
d7a185baea Move props into IProps 2025-03-17 10:07:36 +00:00
Half-Shot
93009d4613 Add a comment 2025-03-17 10:01:12 +00:00
Half-Shot
71257d97e7 Merge remote-tracking branch 'origin/develop' into hs/add-hide-image-button 2025-03-17 09:59:37 +00:00
Half-Shot
60eeb8a7de Support functional components for message body rendering. 2025-03-17 09:59:25 +00:00
Half-Shot
571a2e373d Fixup tests 2025-03-17 09:06:58 +00:00
Half-Shot
d0b8564660 Fixup MImageBody test 2025-03-17 08:47:43 +00:00
Will Hunt
28ea91566a Merge branch 'develop' into hs/add-hide-image-button 2025-03-17 08:27:55 +00:00
Half-Shot
ef32747473 Drop setting hook usage. 2025-03-17 08:27:15 +00:00
Half-Shot
7696516e8b Use a hook for media visibility. 2025-03-17 08:22:19 +00:00
Will Hunt
46b1234a1d Merge branch 'develop' into hs/add-hide-image-button 2025-03-13 16:49:27 +00:00
Half-Shot
b9c0d63e3e lint 2025-03-11 15:57:09 +00:00
Half-Shot
cf7e52c6fc lint 2025-03-11 15:55:30 +00:00
Half-Shot
e87eb127ee Add tests for HideActionButton 2025-03-11 15:51:05 +00:00
Half-Shot
83e421daf2 appese prettier 2025-03-11 15:20:56 +00:00
Half-Shot
d6fb24dea7 i18n 2025-03-11 15:14:46 +00:00
Half-Shot
a518c8d662 add type 2025-03-11 15:14:25 +00:00
Half-Shot
c759e516bd docs fixes 2025-03-11 15:13:42 +00:00
Half-Shot
c8b55c3dfe add description for migration 2025-03-11 15:11:14 +00:00
Half-Shot
7197093744 Fixup and add tests 2025-03-11 15:08:48 +00:00
Half-Shot
4e34adb854 Tweaks to MImageBody to support new setting. 2025-03-11 11:32:49 +00:00
Half-Shot
72c2a3eb07 Add an action button to hide settings. 2025-03-11 11:32:26 +00:00
Half-Shot
4d290461c4 Add a migration path 2025-03-11 11:32:17 +00:00
Half-Shot
0cc06450d7 Add new setting showMediaEventIds 2025-03-11 11:32:10 +00:00
Half-Shot
9376d71831 Move useSettingsValueWithSetter to useSettings 2025-03-11 11:31:51 +00:00
Half-Shot
6d5442a87b start hide 2025-03-11 10:13:06 +00:00
251 changed files with 5006 additions and 7971 deletions

View File

@@ -11,7 +11,7 @@ runs:
using: composite using: composite
steps: steps:
- name: Download release tarball - name: Download release tarball
uses: robinraju/release-downloader@daf26c55d821e836577a15f77d86ddc078948b05 # v1 uses: robinraju/release-downloader@a96f54c1b5f5e09e47d9504526e96febd949d4c2 # v1
with: with:
tag: ${{ inputs.tag }} tag: ${{ inputs.tag }}
fileName: element-*.tar.gz* fileName: element-*.tar.gz*

View File

@@ -26,6 +26,12 @@ jobs:
R2_URL: ${{ vars.CF_R2_S3_API }} R2_URL: ${{ vars.CF_R2_S3_API }}
R2_PUBLIC_URL: "https://element-web-develop.element.io" R2_PUBLIC_URL: "https://element-web-develop.element.io"
steps: steps:
# Workaround for https://www.cloudflarestatus.com/incidents/t5nrjmpxc1cj
- uses: unfor19/install-aws-cli-action@v1
with:
version: 2.22.35
verbose: false
arch: amd64
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: actions/setup-node@v4 - uses: actions/setup-node@v4
@@ -109,11 +115,10 @@ jobs:
# We keep the latest develop.tar.gz on R2 instead of relying on the github artifact uploaded earlier # We keep the latest develop.tar.gz on R2 instead of relying on the github artifact uploaded earlier
# as the expires after 24h and requires auth to download. # as the expires after 24h and requires auth to download.
# Element Desktop's fetch script uses this tarball to fetch latest develop to build Nightlies. # Element Desktop's fetch script uses this tarball to fetch latest develop to build Nightlies.
# Checksum algorithm specified as per https://developers.cloudflare.com/r2/examples/aws/aws-cli/
- name: Deploy to R2 - name: Deploy to R2
run: | run: |
aws s3 cp dist/develop.tar.gz s3://$R2_BUCKET/develop.tar.gz --endpoint-url $R2_URL --region=auto --checksum-algorithm CRC32 aws s3 cp dist/develop.tar.gz s3://$R2_BUCKET/develop.tar.gz --endpoint-url $R2_URL --region=auto
aws s3 cp _deploy/ s3://$R2_BUCKET/ --recursive --endpoint-url $R2_URL --region=auto --checksum-algorithm CRC32 aws s3 cp _deploy/ s3://$R2_BUCKET/ --recursive --endpoint-url $R2_URL --region=auto
env: env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }} AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_TOKEN }} AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_TOKEN }}

View File

@@ -37,14 +37,14 @@ jobs:
install: true install: true
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@74a5d142397b4f367a81961eba4e8cd7edddf772 # v3 uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567 # v3
if: github.event_name != 'pull_request' if: github.event_name != 'pull_request'
with: with:
registry: ghcr.io registry: ghcr.io

View File

@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue - name: Skip SonarCloud in merge queue
if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true' if: github.event_name == 'merge_group' || inputs.disable_coverage == 'true'
uses: guibranco/github-status-action-v2@fe98467f9071758c7fc214af9dbac7f301bd23d4 uses: guibranco/github-status-action-v2@5ef6e175c333bc629f3718b083c8a2ff6e0bbfbc
with: with:
authToken: ${{ secrets.GITHUB_TOKEN }} authToken: ${{ secrets.GITHUB_TOKEN }}
state: success state: success

View File

@@ -68,13 +68,13 @@
"update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js" "update:jitsi": "curl -s https://meet.element.io/libs/external_api.min.js > ./res/jitsi_external_api.min.js"
}, },
"resolutions": { "resolutions": {
"@playwright/test": "1.51.1", "@playwright/test": "1.50.1",
"@types/react": "18.3.18", "@types/react": "18.3.18",
"@types/react-dom": "18.3.5", "@types/react-dom": "18.3.5",
"oidc-client-ts": "3.2.0", "oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0", "jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001704", "caniuse-lite": "1.0.30001701",
"testcontainers": "10.21.0", "testcontainers": "10.20.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0" "wrap-ansi": "npm:wrap-ansi@^7.0.0"
}, },
@@ -107,7 +107,6 @@
"css-tree": "^3.0.0", "css-tree": "^3.0.0",
"diff-dom": "^5.0.0", "diff-dom": "^5.0.0",
"diff-match-patch": "^1.0.5", "diff-match-patch": "^1.0.5",
"domutils": "^3.2.2",
"emojibase-regex": "15.3.2", "emojibase-regex": "15.3.2",
"escape-html": "^1.0.3", "escape-html": "^1.0.3",
"file-saver": "^2.0.5", "file-saver": "^2.0.5",
@@ -116,12 +115,12 @@
"glob-to-regexp": "^0.4.1", "glob-to-regexp": "^0.4.1",
"highlight.js": "^11.3.1", "highlight.js": "^11.3.1",
"html-entities": "^2.0.0", "html-entities": "^2.0.0",
"html-react-parser": "^5.2.2",
"is-ip": "^3.1.0", "is-ip": "^3.1.0",
"js-xxhash": "^4.0.0", "js-xxhash": "^4.0.0",
"jsrsasign": "^11.0.0", "jsrsasign": "^11.0.0",
"jszip": "^3.7.0", "jszip": "^3.7.0",
"katex": "^0.16.0", "katex": "^0.16.0",
"linkify-element": "4.2.0",
"linkify-react": "4.2.0", "linkify-react": "4.2.0",
"linkify-string": "4.2.0", "linkify-string": "4.2.0",
"linkifyjs": "4.2.0", "linkifyjs": "4.2.0",
@@ -145,7 +144,6 @@
"react-blurhash": "^0.3.0", "react-blurhash": "^0.3.0",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-focus-lock": "^2.5.1", "react-focus-lock": "^2.5.1",
"react-string-replace": "^1.1.1",
"react-transition-group": "^4.4.1", "react-transition-group": "^4.4.1",
"react-virtualized": "^9.22.5", "react-virtualized": "^9.22.5",
"rfc4648": "^1.4.0", "rfc4648": "^1.4.0",
@@ -276,7 +274,7 @@
"postcss-preset-env": "^10.0.0", "postcss-preset-env": "^10.0.0",
"postcss-scss": "^4.0.4", "postcss-scss": "^4.0.4",
"postcss-simple-vars": "^7.0.1", "postcss-simple-vars": "^7.0.1",
"prettier": "3.5.3", "prettier": "3.5.2",
"process": "^0.11.10", "process": "^0.11.10",
"raw-loader": "^4.0.2", "raw-loader": "^4.0.2",
"rimraf": "^6.0.0", "rimraf": "^6.0.0",

View File

@@ -324,7 +324,7 @@ test.describe("Cryptography", function () {
await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/); await expect(lastE2eIcon).toHaveClass(/mx_EventTile_e2eIcon_warning/);
await lastE2eIcon.focus(); await lastE2eIcon.focus();
await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText( await expect(await app.getTooltipForElement(lastE2eIcon)).toContainText(
"Sender's verified identity was reset", "Sender's verified identity has changed",
); );
}); });
}); });

View File

@@ -52,6 +52,6 @@ test.describe("Invisible cryptography", () => {
/* should show an error for a message from a previously verified device */ /* should show an error for a message from a previously verified device */
await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified"); await bobSecondDevice.sendMessage(testRoomId, "test encrypted from user that was previously verified");
const lastTile = page.locator(".mx_EventTile_last"); const lastTile = page.locator(".mx_EventTile_last");
await expect(lastTile).toContainText("Sender's verified identity was reset"); await expect(lastTile).toContainText("Sender's verified identity has changed");
}); });
}); });

View File

@@ -29,9 +29,7 @@ test.describe("Key storage out of sync toast", () => {
}); });
test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => { test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => {
// We need to wait for there to be two toasts as the wait below won't work in isolation: // Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work
// playwright only evaluates the 'first()' call initially, not subsequent times it checks, so
// it would always be checking the same toast, even if another one is now the first.
await expect(page.getByRole("alert")).toHaveCount(2); await expect(page.getByRole("alert")).toHaveCount(2);
await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png"); await expect(page.getByRole("alert").first()).toMatchScreenshot("key-storage-out-of-sync-toast.png");

View File

@@ -221,9 +221,6 @@ export async function logIntoElement(page: Page, credentials: Credentials, secur
await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click(); await page.locator(".mx_AuthPage").getByRole("button", { name: "Verify with Recovery Key" }).click();
const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" }); const useSecurityKey = page.locator(".mx_Dialog").getByRole("button", { name: "use your Recovery Key" });
// If the user has set a recovery *passphrase*, they'll be prompted for that first and have to click
// through to enter the recovery key which is what we have here. If they haven't, they'll be prompted
// for a recovery key straight away. We click the button if it's there so this works in both cases.
if (await useSecurityKey.isVisible()) { if (await useSecurityKey.isVisible()) {
await useSecurityKey.click(); await useSecurityKey.click();
} }

View File

@@ -18,6 +18,14 @@ test.describe("Room list filters and sort", () => {
labsFlags: ["feature_new_room_list"], labsFlags: ["feature_new_room_list"],
}); });
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
function getPrimaryFilters(page: Page) { function getPrimaryFilters(page: Page) {
return page.getByRole("listbox", { name: "Room list filters" }); return page.getByRole("listbox", { name: "Room list filters" });
} }
@@ -25,113 +33,56 @@ test.describe("Room list filters and sort", () => {
test.beforeEach(async ({ page, app, bot, user }) => { test.beforeEach(async ({ page, app, bot, user }) => {
// The notification toast is displayed above the search section // The notification toast is displayed above the search section
await app.closeNotificationToast(); await app.closeNotificationToast();
await app.client.createRoom({ name: "empty room" });
const unReadDmId = await bot.createRoom({
name: "unread dm",
invite: [user.userId],
is_direct: true,
});
await bot.sendMessage(unReadDmId, "I am a robot. Beep.");
const unReadRoomId = await app.client.createRoom({ name: "unread room" });
await app.client.inviteUser(unReadRoomId, bot.credentials.userId);
await bot.joinRoom(unReadRoomId);
await bot.sendMessage(unReadRoomId, "I am a robot. Beep.");
const favouriteId = await app.client.createRoom({ name: "favourite room" });
await app.client.evaluate(async (client, favouriteId) => {
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 });
}, favouriteId);
}); });
test.describe("Room list", () => { test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
/** const roomList = getRoomList(page);
* Get the room list const primaryFilters = getPrimaryFilters(page);
* @param page
*/ const allFilters = await primaryFilters.locator("option").all();
function getRoomList(page: Page) { for (const filter of allFilters) {
return page.getByTestId("room-list"); expect(await filter.getAttribute("aria-selected")).toBe("false");
} }
await expect(primaryFilters).toMatchScreenshot("unselected-primary-filters.png");
test.beforeEach(async ({ page, app, bot, user }) => { await primaryFilters.getByRole("option", { name: "Unread" }).click();
await app.client.createRoom({ name: "empty room" }); // only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
await expect(primaryFilters).toMatchScreenshot("unread-primary-filters.png");
const unReadDmId = await bot.createRoom({ await primaryFilters.getByRole("option", { name: "Favourite" }).click();
name: "unread dm", await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
invite: [user.userId], expect(await roomList.locator("role=gridcell").count()).toBe(1);
is_direct: true,
});
await bot.sendMessage(unReadDmId, "I am a robot. Beep.");
const unReadRoomId = await app.client.createRoom({ name: "unread room" }); await primaryFilters.getByRole("option", { name: "People" }).click();
await app.client.inviteUser(unReadRoomId, bot.credentials.userId); await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await bot.joinRoom(unReadRoomId); expect(await roomList.locator("role=gridcell").count()).toBe(1);
await bot.sendMessage(unReadRoomId, "I am a robot. Beep.");
const favouriteId = await app.client.createRoom({ name: "favourite room" }); await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await app.client.evaluate(async (client, favouriteId) => { await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await client.setRoomTag(favouriteId, "m.favourite", { order: 0.5 }); await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
}, favouriteId); await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
}); expect(await roomList.locator("role=gridcell").count()).toBe(3);
test("should filter the list (with primary filters)", { tag: "@screenshot" }, async ({ page, app, user }) => {
const roomList = getRoomList(page);
const primaryFilters = getPrimaryFilters(page);
const allFilters = await primaryFilters.locator("option").all();
for (const filter of allFilters) {
expect(await filter.getAttribute("aria-selected")).toBe("false");
}
await expect(primaryFilters).toMatchScreenshot("unselected-primary-filters.png");
await primaryFilters.getByRole("option", { name: "Unread" }).click();
// only one room should be visible
await expect(roomList.getByRole("gridcell", { name: "unread dm" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(2);
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();
expect(await roomList.locator("role=gridcell").count()).toBe(1);
await primaryFilters.getByRole("option", { name: "Rooms" }).click();
await expect(roomList.getByRole("gridcell", { name: "unread room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "favourite room" })).toBeVisible();
await expect(roomList.getByRole("gridcell", { name: "empty room" })).toBeVisible();
expect(await roomList.locator("role=gridcell").count()).toBe(3);
});
});
test.describe("Empty room list", () => {
/**
* Get the empty state
* @param page
*/
function getEmptyRoomList(page: Page) {
return page.getByTestId("empty-room-list");
}
test(
"should render the default placeholder when there is no filter",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("default-empty-room-list.png");
await expect(page.getByTestId("room-list-panel")).toMatchScreenshot("room-panel-empty-room-list.png");
},
);
test("should render the placeholder for unread filter", { tag: "@screenshot" }, async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: "Unread" }).click();
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot("unread-empty-room-list.png");
await emptyRoomList.getByRole("button", { name: "show all chats" }).click();
await expect(primaryFilters.getByRole("option", { name: "Unread" })).not.toBeChecked();
});
["People", "Rooms", "Favourite"].forEach((filter) => {
test(
`should render the placeholder for ${filter} filter`,
{ tag: "@screenshot" },
async ({ page, app, user }) => {
const primaryFilters = getPrimaryFilters(page);
await primaryFilters.getByRole("option", { name: filter }).click();
const emptyRoomList = getEmptyRoomList(page);
await expect(emptyRoomList).toMatchScreenshot(`${filter}-empty-room-list.png`);
},
);
});
}); });
}); });

View File

@@ -77,57 +77,4 @@ test.describe("Room list", () => {
await page.getByRole("menuitem", { name: "leave room" }).click(); await page.getByRole("menuitem", { name: "leave room" }).click();
await expect(roomItem).not.toBeVisible(); await expect(roomItem).not.toBeVisible();
}); });
test("should scroll to the current room", async ({ page, app, user }) => {
const roomListView = getRoomList(page);
await roomListView.hover();
// Scroll to the end of the room list
await page.mouse.wheel(0, 1000);
await roomListView.getByRole("gridcell", { name: "Open room room0" }).click();
const filters = page.getByRole("listbox", { name: "Room list filters" });
await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).not.toBeVisible();
await filters.getByRole("option", { name: "People" }).click();
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
});
test("unread filter should only match unread rooms that have a count", async ({ page, app, bot }) => {
const roomListView = getRoomList(page);
// Let's create a new room and invite the bot
const room1Id = await app.client.createRoom({
name: "Unread Room 1",
invite: [bot.credentials?.userId],
});
await bot.awaitRoomMembership(room1Id);
// Let's create another room as well
const room2Id = await app.client.createRoom({
name: "Unread Room 2",
invite: [bot.credentials?.userId],
});
await bot.awaitRoomMembership(room2Id);
// Let's configure unread room 1 so that we only get notification for mentions and keywords
await app.viewRoomById(room1Id);
await app.settings.openRoomSettings("Notifications");
await page.getByText("@mentions & keywords").click();
await app.settings.closeDialog();
// Let's open a room other than room 1 or room 2
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
// Let's make the bot send a new message in both room 1 and room 2
await bot.sendMessage(room1Id, "Hello!");
await bot.sendMessage(room2Id, "Hello!");
// Let's activate the unread filter now
await page.getByRole("option", { name: "Unread" }).click();
// Unread filter should only show room 2!!
await expect(roomListView.getByRole("gridcell", { name: "Open room Unread Room 2" })).toBeVisible();
await expect(roomListView.getByRole("gridcell", { name: "Open room Unread Room 1" })).not.toBeVisible();
});
}); });

View File

@@ -13,7 +13,6 @@ import { selectHomeserver } from "../utils";
import { type Credentials, type HomeserverInstance } from "../../plugins/homeserver"; import { type Credentials, type HomeserverInstance } from "../../plugins/homeserver";
import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts"; import { consentHomeserver } from "../../plugins/homeserver/synapse/consentHomeserver.ts";
import { isDendrite } from "../../plugins/homeserver/dendrite"; import { isDendrite } from "../../plugins/homeserver/dendrite";
import { createBot } from "../crypto/utils.ts";
// This test requires fixed credentials for the device signing keys below to work // This test requires fixed credentials for the device signing keys below to work
const username = "user1234"; const username = "user1234";
@@ -259,34 +258,6 @@ test.describe("Login", () => {
await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0); await expect(h1.locator(".mx_CompleteSecurity_skip")).toHaveCount(0);
}); });
test("Continues to show verification prompt after cancelling device verification", async ({
page,
homeserver,
credentials,
}) => {
// Create a different device which is cross-signed, meaning we need to verify this device
await createBot(page, homeserver, credentials, true);
// Wait to avoid homeserver rate limit on logins
await page.waitForTimeout(100);
// Load the page and see that we are asked to verify
await page.goto("/#/welcome");
await login(page, homeserver, credentials);
let h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
// Click "Verify with another device"
await page.getByRole("button", { name: "Verify with another device" }).click();
// Cancel the new dialog
await page.getByRole("button", { name: "Close dialog" }).click();
// Check that we are still being asked to verify
h1 = page.getByRole("heading", { name: "Verify this device", level: 1 });
await expect(h1).toBeVisible();
});
}); });
}); });
}); });

View File

@@ -73,33 +73,4 @@ test.describe("OIDC Native", { tag: ["@no-firefox", "@no-webkit"] }, () => {
await revokeAccessTokenPromise; await revokeAccessTokenPromise;
await revokeRefreshTokenPromise; await revokeRefreshTokenPromise;
}); });
test(
"it should log out the user & wipe data when logging out via MAS",
{ tag: "@screenshot" },
async ({ mas, page, mailpitClient }, testInfo) => {
// We use this over the `user` fixture to ensure we get an OIDC session rather than a compatibility one
await page.goto("/#/login");
await page.getByRole("button", { name: "Continue" }).click();
const userId = `alice_${testInfo.testId}`;
await registerAccountMas(page, mailpitClient, userId, "alice@email.com", "Pa$sW0rD!");
await expect(page.getByText("Welcome")).toBeVisible();
await page.goto("about:blank");
// @ts-expect-error
const result = await mas.manage("kill-sessions", userId);
expect(result.output).toContain("Ended 1 active OAuth 2.0 session");
await page.goto("http://localhost:8080");
await expect(
page.getByText("For security, this session has been signed out. Please sign in again."),
).toBeVisible();
await expect(page).toMatchScreenshot("token-expired.png", { includeDialogBackground: true });
const localStorageKeys = await page.evaluate(() => Object.keys(localStorage));
expect(localStorageKeys).toHaveLength(0);
},
);
}); });

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024, 2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -10,7 +10,6 @@ import { type Locator, type Page } from "@playwright/test";
import { test, expect } from "../../element-web-test"; import { test, expect } from "../../element-web-test";
import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils"; import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils";
import { isDendrite } from "../../plugins/homeserver/dendrite";
const ROOM_NAME = "Test room"; const ROOM_NAME = "Test room";
const ROOM_NAME_LONG = const ROOM_NAME_LONG =
@@ -134,17 +133,6 @@ test.describe("RightPanel", () => {
await page.getByLabel("Room info").nth(1).click(); await page.getByLabel("Room info").nth(1).click();
await checkRoomSummaryCard(page, ROOM_NAME); await checkRoomSummaryCard(page, ROOM_NAME);
}); });
test.describe("room reporting", () => {
test.skip(isDendrite, "Dendrite does not implement room reporting");
test("should handle reporting a room", async ({ page, app }) => {
await viewRoomSummaryByName(page, app, ROOM_NAME);
await page.getByRole("menuitem", { name: "Report room" }).click();
const dialog = await page.getByRole("dialog", { name: "Report Room" });
await dialog.getByLabel("reason").fill("This room should be reported");
await dialog.getByRole("button", { name: "Send report" }).click();
await expect(page.getByText("Your report was sent.")).toBeVisible();
});
});
}); });
test.describe("in spaces", () => { test.describe("in spaces", () => {

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2023 Suguru Hirahara Copyright 2023 Suguru Hirahara
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -50,8 +50,8 @@ test.describe("Appearance user settings tab", () => {
// Click "Show advanced" link button // Click "Show advanced" link button
await tab.getByRole("button", { name: "Show advanced" }).click(); await tab.getByRole("button", { name: "Show advanced" }).click();
await tab.getByLabel("Use bundled emoji font").click(); await tab.locator(".mx_Checkbox", { hasText: "Use bundled emoji font" }).click();
await tab.getByLabel("Use a system font").click(); await tab.locator(".mx_Checkbox", { hasText: "Use a system font" }).click();
// Assert that the font-family value was removed // Assert that the font-family value was removed
await expect(page.locator("body")).toHaveCSS("font-family", '""'); await expect(page.locator("body")).toHaveCSS("font-family", '""');

View File

@@ -7,15 +7,47 @@ Please see LICENSE files in the repository root for full details.
*/ */
import { type Page, type Request } from "@playwright/test"; import { type Page, type Request } from "@playwright/test";
import { GenericContainer, type StartedTestContainer, Wait } from "testcontainers";
import { test as base, expect } from "../../element-web-test"; import { test as base, expect } from "../../element-web-test";
import type { ElementAppPage } from "../../pages/ElementAppPage"; import type { ElementAppPage } from "../../pages/ElementAppPage";
import type { Bot } from "../../pages/bot"; import type { Bot } from "../../pages/bot";
const test = base.extend<{ const test = base.extend<{
slidingSyncProxy: StartedTestContainer;
testRoom: { roomId: string; name: string }; testRoom: { roomId: string; name: string };
joinedBot: Bot; joinedBot: Bot;
}>({ }>({
slidingSyncProxy: async ({ logger, network, postgres, page, homeserver }, use, testInfo) => {
const container = await new GenericContainer("ghcr.io/matrix-org/sliding-sync:v0.99.3")
.withNetwork(network)
.withExposedPorts(8008)
.withLogConsumer(logger.getConsumer("sliding-sync-proxy"))
.withWaitStrategy(Wait.forHttp("/client/server.json", 8008))
.withEnvironment({
SYNCV3_SECRET: "bwahahaha",
SYNCV3_DB: `user=${postgres.getUsername()} dbname=postgres password=${postgres.getPassword()} host=postgres sslmode=disable`,
SYNCV3_SERVER: `http://homeserver:8008`,
})
.start();
const proxyAddress = `http://${container.getHost()}:${container.getMappedPort(8008)}`;
await page.addInitScript((proxyAddress) => {
window.localStorage.setItem(
"mx_local_settings",
JSON.stringify({
feature_sliding_sync_proxy_url: proxyAddress,
}),
);
window.localStorage.setItem("mx_labs_feature_feature_sliding_sync", "true");
}, proxyAddress);
await use(container);
await container.stop();
},
// Ensure slidingSyncProxy is set up before the user fixture as it relies on an init script
credentials: async ({ slidingSyncProxy, credentials }, use) => {
await use(credentials);
},
testRoom: async ({ user, app }, use) => { testRoom: async ({ user, app }, use) => {
const name = "Test Room"; const name = "Test Room";
const roomId = await app.client.createRoom({ name }); const roomId = await app.client.createRoom({ name });
@@ -50,14 +82,6 @@ test.describe("Sliding Sync", () => {
}); });
}; };
test.use({
config: {
features: {
feature_simplified_sliding_sync: true,
},
},
});
// Load the user fixture for all tests // Load the user fixture for all tests
test.beforeEach(({ user }) => {}); test.beforeEach(({ user }) => {});
@@ -164,7 +188,15 @@ test.describe("Sliding Sync", () => {
).not.toBeAttached(); ).not.toBeAttached();
}); });
test("should show unread indicators", async ({ page, app, joinedBot: bot, testRoom }) => { test("should not show unread indicators", async ({ page, app, joinedBot: bot, testRoom }) => {
// TODO: for now. Later we should.
// disable notifs in this room (TODO: CS API call?)
const locator = page.getByRole("treeitem", { name: "Test Room" });
await locator.hover();
await locator.getByRole("button", { name: "Notification options" }).click();
await page.getByRole("menuitemradio", { name: "Mute room" }).click();
// create a new room so we know when the message has been received as it'll re-shuffle the room list // create a new room so we know when the message has been received as it'll re-shuffle the room list
await app.client.createRoom({ name: "Dummy" }); await app.client.createRoom({ name: "Dummy" });
@@ -175,7 +207,9 @@ test.describe("Sliding Sync", () => {
// wait for this message to arrive, tell by the room list resorting // wait for this message to arrive, tell by the room list resorting
await checkOrder(["Test Room", "Dummy"], page); await checkOrder(["Test Room", "Dummy"], page);
await expect(page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge")).toBeAttached(); await expect(
page.getByRole("treeitem", { name: "Test Room" }).locator(".mx_NotificationBadge"),
).not.toBeAttached();
}); });
test("should update user settings promptly", async ({ page, app }) => { test("should update user settings promptly", async ({ page, app }) => {
@@ -187,37 +221,6 @@ test.describe("Sliding Sync", () => {
await expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached(); await expect(locator.locator(".mx_ToggleSwitch_on")).toBeAttached();
}); });
test("should send subscribe_rooms on room switch if room not already subscribed", async ({ page, app }) => {
// create rooms and check room names are correct
const roomIds: string[] = [];
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
const id = await app.client.createRoom({ name: fruit });
roomIds.push(id);
await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible();
}
const [roomAId, roomPId] = roomIds;
const matchRoomSubRequest = (subRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
return body.room_subscriptions?.[subRoomId];
};
// Select the Test Room and wait for playwright to get the request
const [request] = await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomAId)),
page.getByRole("treeitem", { name: "Apple", exact: true }).click(),
]);
const roomSubscriptions = request.postDataJSON().room_subscriptions;
expect(roomSubscriptions, "room_subscriptions is object").toBeDefined();
// Switch to another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomPId)),
page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(),
]);
});
test("should show and be able to accept/reject/rescind invites", async ({ test("should show and be able to accept/reject/rescind invites", async ({
page, page,
app, app,
@@ -358,4 +361,52 @@ test.describe("Sliding Sync", () => {
// ensure the reply-to does not disappear // ensure the reply-to does not disappear
await expect(page.locator(".mx_ReplyPreview")).toBeVisible(); await expect(page.locator(".mx_ReplyPreview")).toBeVisible();
}); });
test("should send unsubscribe_rooms for every room switch", async ({ page, app }) => {
// create rooms and check room names are correct
const roomIds: string[] = [];
for (const fruit of ["Apple", "Pineapple", "Orange"]) {
const id = await app.client.createRoom({ name: fruit });
roomIds.push(id);
await expect(page.getByRole("treeitem", { name: fruit })).toBeVisible();
}
const [roomAId, roomPId, roomOId] = roomIds;
const matchRoomSubRequest = (subRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
return body.txn_id && body.room_subscriptions?.[subRoomId];
};
const matchRoomUnsubRequest = (unsubRoomId: string) => (request: Request) => {
if (!request.url().includes("/sync")) return false;
const body = request.postDataJSON();
return (
body.txn_id && body.unsubscribe_rooms?.includes(unsubRoomId) && !body.room_subscriptions?.[unsubRoomId]
);
};
// Select the Test Room and wait for playwright to get the request
const [request] = await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomAId)),
page.getByRole("treeitem", { name: "Apple", exact: true }).click(),
]);
const roomSubscriptions = request.postDataJSON().room_subscriptions;
expect(roomSubscriptions, "room_subscriptions is object").toBeDefined();
// Switch to another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomPId)),
page.waitForRequest(matchRoomUnsubRequest(roomAId)),
page.getByRole("treeitem", { name: "Pineapple", exact: true }).click(),
]);
// And switch to even another room and wait for playwright to get the request
await Promise.all([
page.waitForRequest(matchRoomSubRequest(roomOId)),
page.waitForRequest(matchRoomUnsubRequest(roomPId)),
page.getByRole("treeitem", { name: "Orange", exact: true }).click(),
]);
// TODO: Add tests for encrypted rooms
});
}); });

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -35,18 +35,17 @@ function spaceCreateOptions(spaceName: string, roomIds: string[] = []): ICreateR
name: spaceName, name: spaceName,
}, },
}, },
...roomIds.map((r) => spaceChildInitialState(r)), ...roomIds.map(spaceChildInitialState),
], ],
}; };
} }
function spaceChildInitialState(roomId: string, order?: string): ICreateRoomOpts["initial_state"]["0"] { function spaceChildInitialState(roomId: string): ICreateRoomOpts["initial_state"]["0"] {
return { return {
type: "m.space.child", type: "m.space.child",
state_key: roomId, state_key: roomId,
content: { content: {
via: [roomId.split(":")[1]], via: [roomId.split(":")[1]],
order,
}, },
}; };
} }
@@ -122,10 +121,9 @@ test.describe("Spaces", () => {
await page.getByRole("button", { name: "Skip for now" }).click(); await page.getByRole("button", { name: "Skip for now" }).click();
// Assert rooms exist in the room list // Assert rooms exist in the room list
const roomList = page.getByRole("tree", { name: "Rooms" }); await expect(page.getByRole("treeitem", { name: "General", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "General", exact: true })).toBeVisible(); await expect(page.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Random", exact: true })).toBeVisible(); await expect(page.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible();
await expect(roomList.getByRole("treeitem", { name: "Projects", exact: true })).toBeVisible();
// Assert rooms exist in the space explorer // Assert rooms exist in the space explorer
await expect( await expect(
@@ -157,7 +155,7 @@ test.describe("Spaces", () => {
await page.getByRole("button", { name: "Just me" }).click(); await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click(); await page.getByText("Sample Room").click({ force: true }); // force click as checkbox size is zero
// Temporal implementation as multiple elements with the role "button" and name "Add" are found // Temporal implementation as multiple elements with the role "button" and name "Add" are found
await page.locator(".mx_AddExistingToSpace_footer").getByRole("button", { name: "Add" }).click(); await page.locator(".mx_AddExistingToSpace_footer").getByRole("button", { name: "Add" }).click();
@@ -167,50 +165,6 @@ test.describe("Spaces", () => {
).toBeVisible(); ).toBeVisible();
}); });
test(
"should allow user to add an existing room to a space after creation",
{ tag: "@screenshot" },
async ({ page, app, user }) => {
await app.client.createRoom({
name: "Sample Room",
});
await app.client.createRoom({
name: "A Room that will not be selected",
});
const menu = await openSpaceCreateMenu(page);
await menu.getByRole("button", { name: "Private" }).click();
await menu
.locator('.mx_SpaceBasicSettings_avatarContainer input[type="file"]')
.setInputFiles("playwright/sample-files/riot.png");
await expect(menu.getByRole("textbox", { name: "Address" })).not.toBeVisible();
await menu
.getByRole("textbox", { name: "Description" })
.fill("This is a personal space to mourn Riot.im...");
await menu.getByRole("textbox", { name: "Name" }).fill("This is my Riot");
await menu.getByRole("textbox", { name: "Name" }).press("Enter");
await page.getByRole("button", { name: "Just me" }).click();
await page.getByRole("button", { name: "Skip for now" }).click();
await page.getByRole("button", { name: "Add room" }).click();
await page.getByRole("menuitem", { name: "Add existing room" }).click();
await page.getByRole("checkbox", { name: "Sample Room" }).click();
await expect(page.getByRole("dialog", { name: "Avatar Add existing rooms" })).toMatchScreenshot(
"add-existing-rooms-dialog.png",
);
await page.getByRole("button", { name: "Add" }).click();
await expect(
page.locator(".mx_SpaceHierarchy_list").getByRole("treeitem", { name: "Sample Room" }),
).toBeVisible();
},
);
test("should allow user to invite another to a space", { tag: "@no-webkit" }, async ({ page, app, user, bot }) => { test("should allow user to invite another to a space", { tag: "@no-webkit" }, async ({ page, app, user, bot }) => {
await app.client.createSpace({ await app.client.createSpace({
visibility: "public" as any, visibility: "public" as any,
@@ -337,36 +291,4 @@ test.describe("Spaces", () => {
// Assert we get shown the new room intro, and thus not the soft crash screen // Assert we get shown the new room intro, and thus not the soft crash screen
await expect(page.locator(".mx_NewRoomIntro")).toBeVisible(); await expect(page.locator(".mx_NewRoomIntro")).toBeVisible();
}); });
test("should render spaces view", { tag: "@screenshot" }, async ({ page, app, user, axe }) => {
axe.disableRules([
// Disable this check as it triggers on nested roving tab index elements which are in practice fine
"nested-interactive",
// XXX: We have some known contrast issues here
"color-contrast",
]);
const childSpaceId1 = await app.client.createSpace({
name: "Child Space 1",
initial_state: [],
});
const childSpaceId2 = await app.client.createSpace({
name: "Child Space 2",
initial_state: [],
});
const childSpaceId3 = await app.client.createSpace({
name: "Child Space 3",
initial_state: [],
});
await app.client.createSpace({
name: "Root Space",
initial_state: [
spaceChildInitialState(childSpaceId1, "a"),
spaceChildInitialState(childSpaceId2, "b"),
spaceChildInitialState(childSpaceId3, "c"),
],
});
await app.viewSpaceByName("Root Space");
await expect(page.locator(".mx_SpaceRoomView")).toMatchScreenshot("space-room-view.png");
});
}); });

View File

@@ -28,8 +28,6 @@ const NEW_AVATAR = fs.readFileSync("playwright/sample-files/element.png");
const OLD_NAME = "Alan"; const OLD_NAME = "Alan";
const NEW_NAME = "Alan (away)"; const NEW_NAME = "Alan (away)";
const VIDEO_FILE = fs.readFileSync("playwright/sample-files/5secvid.webm");
const getEventTilesWithBodies = (page: Page): Locator => { const getEventTilesWithBodies = (page: Page): Locator => {
return page.locator(".mx_EventTile").filter({ has: page.locator(".mx_EventTile_body") }); return page.locator(".mx_EventTile").filter({ has: page.locator(".mx_EventTile_body") });
}; };
@@ -918,27 +916,7 @@ test.describe("Timeline", () => {
await page.getByRole("button", { name: "Hide" }).click(); await page.getByRole("button", { name: "Hide" }).click();
// Check that the image is now hidden. // Check that the image is now hidden.
await expect(page.getByRole("button", { name: "Show image" })).toBeVisible(); await expect(page.getByRole("link", { name: "Show image" })).toBeVisible();
});
test("should be able to hide a video", async ({ page, app, room, context }) => {
await app.viewRoomById(room.roomId);
const upload = await app.client.uploadContent(VIDEO_FILE, { name: "bbb.webm", type: "video/webm" });
await app.client.sendEvent(room.roomId, null, "m.room.message" as EventType, {
msgtype: "m.video" as MsgType,
body: "bbb.webm",
url: upload.content_uri,
});
await app.timeline.scrollToBottom();
const imgTile = page.locator(".mx_MVideoBody").first();
await expect(imgTile).toBeVisible();
await imgTile.hover();
await page.getByRole("button", { name: "Hide" }).click();
// Check that the video is now hidden.
await expect(page.getByRole("button", { name: "Show video" })).toBeVisible();
await expect(page.locator("video")).not.toBeVisible();
}); });
}); });

Binary file not shown.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 11 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.2 KiB

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 80 KiB

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 957 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 28 KiB

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 68 KiB

After

Width:  |  Height:  |  Size: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 46 KiB

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers"; import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
const TAG = "develop@sha256:d19854a3dbbb4d5d24d84767d17e1a623181ae5f2bdda3505819c05a8d3c8611"; const TAG = "develop@sha256:2ea87d45fc7ff3327c671b3b4447e6b2032d4f5ca07d62d8aef0d900e105c2f4";
/** /**
* SynapseContainer which freezes the docker digest to stabilise tests, * SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -128,6 +128,7 @@
@import "./views/dialogs/_AddExistingToSpaceDialog.pcss"; @import "./views/dialogs/_AddExistingToSpaceDialog.pcss";
@import "./views/dialogs/_AnalyticsLearnMoreDialog.pcss"; @import "./views/dialogs/_AnalyticsLearnMoreDialog.pcss";
@import "./views/dialogs/_BugReportDialog.pcss"; @import "./views/dialogs/_BugReportDialog.pcss";
@import "./views/dialogs/_BulkRedactDialog.pcss";
@import "./views/dialogs/_ChangelogDialog.pcss"; @import "./views/dialogs/_ChangelogDialog.pcss";
@import "./views/dialogs/_CompoundDialog.pcss"; @import "./views/dialogs/_CompoundDialog.pcss";
@import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss"; @import "./views/dialogs/_ConfirmSpaceUserActionDialog.pcss";
@@ -152,7 +153,6 @@
@import "./views/dialogs/_ModalWidgetDialog.pcss"; @import "./views/dialogs/_ModalWidgetDialog.pcss";
@import "./views/dialogs/_PollCreateDialog.pcss"; @import "./views/dialogs/_PollCreateDialog.pcss";
@import "./views/dialogs/_RegistrationEmailPromptDialog.pcss"; @import "./views/dialogs/_RegistrationEmailPromptDialog.pcss";
@import "./views/dialogs/_ReportRoomDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialog.pcss"; @import "./views/dialogs/_RoomSettingsDialog.pcss";
@import "./views/dialogs/_RoomSettingsDialogBridges.pcss"; @import "./views/dialogs/_RoomSettingsDialogBridges.pcss";
@import "./views/dialogs/_RoomUpgradeDialog.pcss"; @import "./views/dialogs/_RoomUpgradeDialog.pcss";
@@ -212,6 +212,7 @@
@import "./views/elements/_ServerPicker.pcss"; @import "./views/elements/_ServerPicker.pcss";
@import "./views/elements/_SettingsFlag.pcss"; @import "./views/elements/_SettingsFlag.pcss";
@import "./views/elements/_Spinner.pcss"; @import "./views/elements/_Spinner.pcss";
@import "./views/elements/_StyledCheckbox.pcss";
@import "./views/elements/_StyledRadioButton.pcss"; @import "./views/elements/_StyledRadioButton.pcss";
@import "./views/elements/_SyntaxHighlight.pcss"; @import "./views/elements/_SyntaxHighlight.pcss";
@import "./views/elements/_TagComposer.pcss"; @import "./views/elements/_TagComposer.pcss";
@@ -227,7 +228,6 @@
@import "./views/messages/_DisambiguatedProfile.pcss"; @import "./views/messages/_DisambiguatedProfile.pcss";
@import "./views/messages/_EventTileBubble.pcss"; @import "./views/messages/_EventTileBubble.pcss";
@import "./views/messages/_HiddenBody.pcss"; @import "./views/messages/_HiddenBody.pcss";
@import "./views/messages/_HiddenMediaPlaceholder.pcss";
@import "./views/messages/_JumpToDatePicker.pcss"; @import "./views/messages/_JumpToDatePicker.pcss";
@import "./views/messages/_LegacyCallEvent.pcss"; @import "./views/messages/_LegacyCallEvent.pcss";
@import "./views/messages/_MEmoteBody.pcss"; @import "./views/messages/_MEmoteBody.pcss";
@@ -270,7 +270,6 @@
@import "./views/right_panel/_VerificationPanel.pcss"; @import "./views/right_panel/_VerificationPanel.pcss";
@import "./views/right_panel/_WidgetCard.pcss"; @import "./views/right_panel/_WidgetCard.pcss";
@import "./views/room_settings/_AliasSettings.pcss"; @import "./views/room_settings/_AliasSettings.pcss";
@import "./views/rooms/RoomListPanel/_EmptyRoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomList.pcss"; @import "./views/rooms/RoomListPanel/_RoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss"; @import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss";

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -16,9 +16,9 @@ Please see LICENSE files in the repository root for full details.
.mx_SelectableDeviceTile_checkbox { .mx_SelectableDeviceTile_checkbox {
flex: 1 0; flex: 1 0;
> div { .mx_Checkbox_background + div {
margin-top: auto; flex: 1 0;
margin-bottom: auto; /* override more specific selector */
margin-right: var(--cpd-space-1x); margin-left: $spacing-16 !important;
} }
} }

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -70,26 +70,38 @@ Please see LICENSE files in the repository root for full details.
text-transform: uppercase; text-transform: uppercase;
color: var(--cpd-color-text-secondary); color: var(--cpd-color-text-secondary);
margin: 20px 0 12px; margin: 20px 0 12px;
}
.mx_QuickSettingsButton_pinToSidebarHeading {
padding-left: 24px;
position: relative; position: relative;
display: flex; }
.mx_Checkbox {
margin-bottom: 8px;
}
.mx_QuickSettingsButton_favouritesCheckbox,
.mx_QuickSettingsButton_peopleCheckbox {
.mx_Checkbox_background + div {
padding-left: 22px;
position: relative;
margin-left: 6px;
font-size: $font-15px;
line-height: $font-24px;
color: var(--cpd-color-text-primary);
}
} }
.mx_QuickSettingsButton_moreOptionsButton { .mx_QuickSettingsButton_moreOptionsButton {
margin-left: var(--cpd-space-7x); padding-left: 22px;
margin-left: 22px;
font-size: $font-15px; font-size: $font-15px;
line-height: $font-24px; line-height: $font-24px;
color: var(--cpd-color-text-primary); color: var(--cpd-color-text-primary);
position: relative; position: relative;
margin-bottom: 16px; margin-bottom: 16px;
} }
.mx_QuickSettingsButton_option {
margin-bottom: var(--cpd-space-3x);
label {
/* Correctly line up icons and text. */
display: flex;
}
}
} }
.mx_QuickSettingsButton_ContextMenuWrapper_new_room_list { .mx_QuickSettingsButton_ContextMenuWrapper_new_room_list {
@@ -99,10 +111,15 @@ Please see LICENSE files in the repository root for full details.
} }
.mx_QuickSettingsButton_icon { .mx_QuickSettingsButton_icon {
margin-right: var(--cpd-space-1x); // TODO remove when all icons have fill=currentColor
* {
fill: $secondary-content;
}
color: $secondary-content; color: $secondary-content;
width: 18px; width: 16px;
height: 18px; height: 16px;
margin-top: auto; position: absolute;
margin-bottom: auto; left: 0;
top: 50%;
transform: translateY(-50%);
} }

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -247,6 +247,15 @@ Please see LICENSE files in the repository root for full details.
.mx_AccessibleButton_kind_primary_outline { .mx_AccessibleButton_kind_primary_outline {
padding: 3px 16px; /* to account for the 1px border */ padding: 3px 16px; /* to account for the 1px border */
} }
.mx_Checkbox {
display: inline-flex;
label {
width: 16px;
height: 16px;
}
}
} }
&:hover, &:hover,

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -32,11 +32,6 @@ Please see LICENSE files in the repository root for full details.
.mx_AddExistingToSpace_section { .mx_AddExistingToSpace_section {
margin-right: 12px; margin-right: 12px;
ul {
list-style: none;
padding-left: 0;
}
// provides space for scrollbar so that checkbox and scrollbar do not collide // provides space for scrollbar so that checkbox and scrollbar do not collide
&:not(:first-child) { &:not(:first-child) {
@@ -219,12 +214,6 @@ Please see LICENSE files in the repository root for full details.
display: flex; display: flex;
margin-top: 12px; margin-top: 12px;
form {
/* Align checkboxes. */
margin-top: auto;
margin-bottom: auto;
}
.mx_DecoratedRoomAvatar, /* we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling */ { .mx_DecoratedRoomAvatar, /* we can't target .mx_BaseAvatar here as it'll break the decorated avatar styling */ {
margin-right: 12px; margin-right: 12px;
} }
@@ -238,4 +227,8 @@ Please see LICENSE files in the repository root for full details.
text-overflow: ellipsis; text-overflow: ellipsis;
margin-right: 12px; margin-right: 12px;
} }
.mx_Checkbox {
align-items: center;
}
} }

View File

@@ -0,0 +1,19 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2021 Robin Townsend <robin@robin.town>
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.
*/
.mx_BulkRedactDialog {
.mx_Checkbox,
.mx_BulkRedactDialog_checkboxMicrocopy {
line-height: $font-20px;
}
.mx_BulkRedactDialog_checkboxMicrocopy {
margin-left: 26px;
color: $secondary-content;
}
}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -43,6 +43,11 @@ Please see LICENSE files in the repository root for full details.
.mx_Field_valid.mx_Field:focus-within { .mx_Field_valid.mx_Field:focus-within {
border-color: $input-border-color; border-color: $input-border-color;
} }
.mx_Checkbox input[type="checkbox"]:checked + label > .mx_Checkbox_background {
background: $info-plinth-fg-color;
border-color: $info-plinth-fg-color;
}
} }
.mx_ExportDialog_progress { .mx_ExportDialog_progress {

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -74,6 +74,10 @@ Please see LICENSE files in the repository root for full details.
line-height: $font-15px; line-height: $font-15px;
color: $tertiary-content; color: $tertiary-content;
} }
.mx_Checkbox {
align-items: center;
}
} }
} }

View File

@@ -1,16 +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.
*/
.mx_ReportRoomDialog {
textarea {
font: var(--cpd-font-body-md-regular);
border: 1px solid var(--cpd-color-border-interactive-primary);
background: var(--cpd-color-bg-canvas-default);
border-radius: 0.5rem;
padding: var(--cpd-space-3x) var(--cpd-space-4x);
}
}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -19,6 +19,13 @@ Please see LICENSE files in the repository root for full details.
margin-top: 20px; margin-top: 20px;
font-size: $font-15px; font-size: $font-15px;
line-height: $font-15px; line-height: $font-15px;
.mx_WidgetCapabilitiesPromptDialog_byline {
color: $muted-fg-color;
margin-left: 26px;
font-size: $font-12px;
line-height: $font-12px;
}
} }
.mx_Dialog_buttons { .mx_Dialog_buttons {

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -7,5 +7,26 @@ Please see LICENSE files in the repository root for full details.
*/ */
.mx_LabelledCheckbox { .mx_LabelledCheckbox {
margin-top: var(--cpd-space-2x); display: flex;
gap: 8px;
flex-direction: row;
.mx_Checkbox {
margin-top: 3px; /* visually align with label text */
}
.mx_LabelledCheckbox_labels {
flex: 1;
.mx_LabelledCheckbox_label {
vertical-align: middle;
}
.mx_LabelledCheckbox_byline {
display: block;
padding-top: $spacing-4;
color: $muted-fg-color;
font-size: $font-11px;
}
}
} }

View File

@@ -0,0 +1,98 @@
/*
Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C.
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.
*/
.mx_Checkbox {
$size: $font-16px;
$border-radius: 0.27rem;
display: flex;
align-items: flex-start;
input[type="checkbox"] {
appearance: none;
margin: 0;
padding: 0;
& + label {
display: flex;
align-items: center;
flex-grow: 1;
}
& + label > .mx_Checkbox_background {
display: inline-flex;
position: relative;
flex-shrink: 0;
height: $size;
width: $size;
size: 0.5rem;
border: 1px solid var(--cpd-color-border-interactive-primary);
box-sizing: border-box;
border-radius: $border-radius;
.mx_Checkbox_checkmark {
display: none;
height: 100%;
width: 100%;
mask-image: url("@vector-im/compound-design-tokens/icons/check.svg");
mask-position: center;
mask-size: 100%;
mask-repeat: no-repeat;
}
}
&:checked + label > .mx_Checkbox_background .mx_Checkbox_checkmark {
display: block;
}
& + label > *:not(.mx_Checkbox_background) {
margin-left: 10px;
}
&:disabled + label {
cursor: not-allowed;
}
&:focus-visible {
& + label .mx_Checkbox_background {
@mixin unreal-focus;
}
}
}
}
.mx_Checkbox.mx_Checkbox_kind_solid input[type="checkbox"] {
& + label > .mx_Checkbox_background .mx_Checkbox_checkmark {
background: var(--cpd-color-icon-on-solid-primary);
}
&:checked + label > .mx_Checkbox_background {
background: var(--cpd-color-bg-accent-rest);
border-color: var(--cpd-color-bg-accent-rest);
}
&:checked:disabled + label > .mx_Checkbox_background {
background: var(--cpd-color-bg-action-primary-disabled);
border-color: var(--cpd-color-bg-action-primary-disabled);
}
}
.mx_Checkbox.mx_Checkbox_kind_outline input[type="checkbox"] {
& + label > .mx_Checkbox_background .mx_Checkbox_checkmark {
background: var(--cpd-color-bg-accent-rest);
}
&:checked + label > .mx_Checkbox_background {
background: transparent;
border-color: var(--cpd-color-bg-accent-rest);
}
}

View File

@@ -1,29 +0,0 @@
.mx_HiddenMediaPlaceholder {
border: none;
width: 100%;
height: 100%;
inset: 0;
/* To center the text in the middle of the frame */
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
background-color: $header-panel-bg-color;
> div {
color: $accent;
/* Icon alignment */
display: flex;
> svg {
margin-top: auto;
margin-bottom: auto;
}
}
}
.mx_EventTile:hover .mx_HiddenMediaPlaceholder {
background-color: $background;
}

View File

@@ -79,3 +79,39 @@ Please see LICENSE files in the repository root for full details.
color: $imagebody-giflabel-color; color: $imagebody-giflabel-color;
pointer-events: none; pointer-events: none;
} }
.mx_HiddenImagePlaceholder {
position: absolute;
inset: 0;
/* To center the text in the middle of the frame */
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
background-color: $header-panel-bg-color;
.mx_HiddenImagePlaceholder_button {
color: $accent;
span.mx_HiddenImagePlaceholder_eye {
margin-right: 8px;
background-color: $accent;
mask-image: url("$(res)/img/element-icons/eye.svg");
display: inline-block;
width: 18px;
height: 14px;
}
span:not(.mx_HiddenImagePlaceholder_eye) {
vertical-align: text-bottom;
}
}
}
.mx_EventTile:hover .mx_HiddenImagePlaceholder {
background-color: $background;
}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024, 2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -101,6 +101,6 @@ Please see LICENSE files in the repository root for full details.
margin: $spacing-12 0 $spacing-4; margin: $spacing-12 0 $spacing-4;
} }
.mx_RoomSummaryCard_bottomOptions { .mx_RoomSummaryCard_leave {
margin: 0 0 var(--cpd-space-8x); margin: 0 0 var(--cpd-space-8x);
} }

View File

@@ -1,33 +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.
*/
.mx_EmptyRoomList_GenericPlaceholder {
align-self: center;
/** It should take 2/3 of the width **/
width: 66%;
/** It should be positioned at 1/3 of the height **/
padding-top: 33%;
.mx_EmptyRoomList_GenericPlaceholder_title {
font: var(--cpd-font-body-lg-semibold);
text-align: center;
}
.mx_EmptyRoomList_GenericPlaceholder_description {
font: var(--cpd-font-body-sm-regular);
color: var(--cpd-color-text-secondary);
text-align: center;
}
.mx_EmptyRoomList_DefaultPlaceholder {
margin-top: var(--cpd-space-4x);
}
button {
width: 100%;
}
}

View File

@@ -7,4 +7,9 @@
.mx_RoomList { .mx_RoomList {
height: 100%; height: 100%;
.mx_RoomList_List {
/* Avoid when on hover, the background color to be on top of the right border */
padding-right: 1px;
}
} }

View File

@@ -47,7 +47,3 @@
.mx_RoomListItemView_menu_open { .mx_RoomListItemView_menu_open {
background-color: var(--cpd-color-bg-action-secondary-hovered); background-color: var(--cpd-color-bg-action-secondary-hovered);
} }
.mx_RoomListItemView_selected {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2020 The Matrix.org Foundation C.I.C. Copyright 2020 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -393,7 +393,8 @@ Please see LICENSE files in the repository root for full details.
margin-bottom: 4px; margin-bottom: 4px;
} }
.mx_StyledRadioButton { .mx_StyledRadioButton,
.mx_Checkbox {
margin-top: 8px; margin-top: 8px;
} }
} }

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -14,12 +14,17 @@ Please see LICENSE files in the repository root for full details.
} }
} }
.mx_SidebarUserSettingsTab_icon { .mx_SidebarUserSettingsTab_checkbox {
margin-right: var(--cpd-space-2x); margin-bottom: $spacing-8;
margin-top: auto; /* override checkbox styles */
margin-bottom: auto; label {
} align-items: flex-start !important;
}
.mx_SidebarUserSettingsTab_checkbox label { svg {
display: flex; height: 16px;
width: 16px;
margin-right: $spacing-8;
margin-bottom: -1px;
}
} }

View File

@@ -25,7 +25,7 @@ import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
import { sanitizeHtmlParams, transformTags } from "./Linkify"; import { sanitizeHtmlParams, transformTags } from "./Linkify";
import { graphemeSegmenter } from "./utils/strings"; import { graphemeSegmenter } from "./utils/strings";
export { Linkify, linkifyAndSanitizeHtml } from "./Linkify"; export { Linkify, linkifyElement, linkifyAndSanitizeHtml } from "./Linkify";
// Anything outside the basic multilingual plane will be a surrogate pair // Anything outside the basic multilingual plane will be a surrogate pair
const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/; const SURROGATE_PAIR_PATTERN = /([\ud800-\udbff])([\udc00-\udfff])/;
@@ -365,6 +365,53 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
} }
} }
export function bodyToDiv(
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
ref?: React.Ref<HTMLDivElement>,
): ReactNode {
const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts);
return formattedBody ? (
<div
key="body"
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: formattedBody }}
dir="auto"
/>
) : (
<div key="body" ref={ref} className={className} dir="auto">
{emojiBodyElements || strippedBody}
</div>
);
}
export function bodyToSpan(
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
ref?: React.Ref<HTMLSpanElement>,
includeDir = true,
): ReactNode {
const { strippedBody, formattedBody, emojiBodyElements, className } = bodyToNode(content, highlights, opts);
return formattedBody ? (
<span
key="body"
ref={ref}
className={className}
dangerouslySetInnerHTML={{ __html: formattedBody }}
dir={includeDir ? "auto" : undefined}
/>
) : (
<span key="body" ref={ref} className={className} dir={includeDir ? "auto" : undefined}>
{emojiBodyElements || strippedBody}
</span>
);
}
interface BodyToNodeReturn { interface BodyToNodeReturn {
strippedBody: string; strippedBody: string;
formattedBody?: string; formattedBody?: string;
@@ -372,11 +419,7 @@ interface BodyToNodeReturn {
className: string; className: string;
} }
export function bodyToNode( function bodyToNode(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): BodyToNodeReturn {
content: IContent,
highlights: Optional<string[]>,
opts: EventRenderOpts = {},
): BodyToNodeReturn {
const eventInfo = analyseEvent(content, highlights, opts); const eventInfo = analyseEvent(content, highlights, opts);
let emojiBody = false; let emojiBody = false;

View File

@@ -22,6 +22,7 @@ import {
type MatrixCall, type MatrixCall,
} from "matrix-js-sdk/src/webrtc/call"; } from "matrix-js-sdk/src/webrtc/call";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { PushProcessor } from "matrix-js-sdk/src/pushprocessor";
import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler"; import { CallEventHandlerEvent } from "matrix-js-sdk/src/webrtc/callEventHandler";
import { MatrixClientPeg } from "./MatrixClientPeg"; import { MatrixClientPeg } from "./MatrixClientPeg";
@@ -595,7 +596,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
switch (newState) { switch (newState) {
case CallState.Ringing: { case CallState.Ringing: {
const incomingCallPushRule = MatrixClientPeg.safeGet().pushProcessor.getPushRuleById( const incomingCallPushRule = new PushProcessor(MatrixClientPeg.safeGet()).getPushRuleById(
RuleId.IncomingCall, RuleId.IncomingCall,
); );
const pushRuleEnabled = incomingCallPushRule?.enabled; const pushRuleEnabled = incomingCallPushRule?.enabled;

View File

@@ -149,7 +149,6 @@ interface ILoadSessionOpts {
ignoreGuest?: boolean; ignoreGuest?: boolean;
defaultDeviceDisplayName?: string; defaultDeviceDisplayName?: string;
fragmentQueryParams?: QueryDict; fragmentQueryParams?: QueryDict;
abortSignal?: AbortSignal;
} }
/** /**
@@ -197,7 +196,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
if (enableGuest && guestHsUrl && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token) { if (enableGuest && guestHsUrl && fragmentQueryParams.guest_user_id && fragmentQueryParams.guest_access_token) {
logger.log("Using guest access credentials"); logger.log("Using guest access credentials");
await doSetLoggedIn( return doSetLoggedIn(
{ {
userId: fragmentQueryParams.guest_user_id as string, userId: fragmentQueryParams.guest_user_id as string,
accessToken: fragmentQueryParams.guest_access_token as string, accessToken: fragmentQueryParams.guest_access_token as string,
@@ -207,8 +206,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
}, },
true, true,
false, false,
); ).then(() => true);
return true;
} }
const success = await restoreSessionFromStorage({ const success = await restoreSessionFromStorage({
ignoreGuest: Boolean(opts.ignoreGuest), ignoreGuest: Boolean(opts.ignoreGuest),
@@ -227,11 +225,6 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
// fall back to welcome screen // fall back to welcome screen
return false; return false;
} catch (e) { } catch (e) {
// We may be aborted e.g. because our token expired, so don't show an error here
if (opts.abortSignal?.aborted) {
return false;
}
if (e instanceof AbortLoginAndRebuildStorage) { if (e instanceof AbortLoginAndRebuildStorage) {
// If we're aborting login because of a storage inconsistency, we don't // If we're aborting login because of a storage inconsistency, we don't
// need to show the general failure dialog. Instead, just go back to welcome. // need to show the general failure dialog. Instead, just go back to welcome.
@@ -243,7 +236,7 @@ export async function loadSession(opts: ILoadSessionOpts = {}): Promise<boolean>
return false; return false;
} }
return handleLoadSessionFailure(e, opts); return handleLoadSessionFailure(e);
} }
} }
@@ -413,39 +406,6 @@ export function attemptTokenLogin(
}); });
} }
/**
* Load the pickle key inside the credentials or create it if it does not exist for this device.
*
* @param credentials Holds the device to load/store the pickle key
*
* @returns {Promise} promise which resolves to the loaded or generated pickle key or undefined if
* none was loaded nor generated
*/
async function loadOrCreatePickleKey(credentials: IMatrixClientCreds): Promise<string | undefined> {
// Try to load the pickle key
const userId = credentials.userId;
const deviceId = credentials.deviceId;
let pickleKey = (await PlatformPeg.get()?.getPickleKey(userId, deviceId ?? "")) ?? undefined;
if (!pickleKey) {
// Create it if it did not exist
pickleKey =
userId && deviceId
? ((await PlatformPeg.get()?.createPickleKey(userId, deviceId)) ?? undefined)
: undefined;
if (pickleKey) {
logger.log(`Created pickle key for ${credentials.userId}|${credentials.deviceId}`);
} else {
logger.log("Pickle key not created");
}
} else {
logger.log(
`Pickle key already exists for ${credentials.userId}|${credentials.deviceId} do not create a new one`,
);
}
return pickleKey;
}
/** /**
* Called after a successful token login or OIDC authorization. * Called after a successful token login or OIDC authorization.
* Clear storage then save new credentials in storage * Clear storage then save new credentials in storage
@@ -453,8 +413,6 @@ async function loadOrCreatePickleKey(credentials: IMatrixClientCreds): Promise<s
*/ */
async function onSuccessfulDelegatedAuthLogin(credentials: IMatrixClientCreds): Promise<void> { async function onSuccessfulDelegatedAuthLogin(credentials: IMatrixClientCreds): Promise<void> {
await clearStorage(); await clearStorage();
// SSO does not go through setLoggedIn so we need to load/create the pickle key here too
credentials.pickleKey = await loadOrCreatePickleKey(credentials);
await persistCredentials(credentials); await persistCredentials(credentials);
// remember that we just logged in // remember that we just logged in
@@ -663,7 +621,7 @@ export async function restoreSessionFromStorage(opts?: { ignoreGuest?: boolean }
} }
} }
async function handleLoadSessionFailure(e: unknown, loadSessionOpts?: ILoadSessionOpts): Promise<boolean> { async function handleLoadSessionFailure(e: unknown): Promise<boolean> {
logger.error("Unable to load session", e); logger.error("Unable to load session", e);
const modal = Modal.createDialog(SessionRestoreErrorDialog, { const modal = Modal.createDialog(SessionRestoreErrorDialog, {
@@ -678,7 +636,7 @@ async function handleLoadSessionFailure(e: unknown, loadSessionOpts?: ILoadSessi
} }
// try, try again // try, try again
return loadSession(loadSessionOpts); return loadSession();
} }
/** /**
@@ -697,8 +655,18 @@ async function handleLoadSessionFailure(e: unknown, loadSessionOpts?: ILoadSessi
export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<MatrixClient> { export async function setLoggedIn(credentials: IMatrixClientCreds): Promise<MatrixClient> {
credentials.freshLogin = true; credentials.freshLogin = true;
stopMatrixClient(); stopMatrixClient();
credentials.pickleKey = await loadOrCreatePickleKey(credentials); const pickleKey =
return doSetLoggedIn(credentials, true, true); credentials.userId && credentials.deviceId
? await PlatformPeg.get()?.createPickleKey(credentials.userId, credentials.deviceId)
: null;
if (pickleKey) {
logger.log(`Created pickle key for ${credentials.userId}|${credentials.deviceId}`);
} else {
logger.log("Pickle key not created");
}
return doSetLoggedIn({ ...credentials, pickleKey: pickleKey ?? undefined }, true, true);
} }
/** /**
@@ -1156,13 +1124,12 @@ window.mxLoginWithAccessToken = async (hsUrl: string, accessToken: string): Prom
baseUrl: hsUrl, baseUrl: hsUrl,
accessToken, accessToken,
}); });
const { user_id: userId, device_id: deviceId } = await tempClient.whoami(); const { user_id: userId } = await tempClient.whoami();
await doSetLoggedIn( await doSetLoggedIn(
{ {
homeserverUrl: hsUrl, homeserverUrl: hsUrl,
accessToken, accessToken,
userId, userId,
deviceId,
}, },
true, true,
false, false,

View File

@@ -11,7 +11,12 @@ import sanitizeHtml, { type IOptions } from "sanitize-html";
import { merge } from "lodash"; import { merge } from "lodash";
import _Linkify from "linkify-react"; import _Linkify from "linkify-react";
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix"; import {
_linkifyElement,
_linkifyString,
ELEMENT_URL_PATTERN,
options as linkifyMatrixOptions,
} from "./linkify-matrix";
import SettingsStore from "./settings/SettingsStore"; import SettingsStore from "./settings/SettingsStore";
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks"; import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
import { mediaFromMxc } from "./customisations/Media"; import { mediaFromMxc } from "./customisations/Media";
@@ -218,6 +223,17 @@ export function linkifyString(str: string, options = linkifyMatrixOptions): stri
return _linkifyString(str, options); return _linkifyString(str, options);
} }
/**
* Linkifies the given DOM element. This is a wrapper around 'linkifyjs/element'.
*
* @param {object} element DOM element to linkify
* @param {object} [options] Options for linkifyElement. Default: linkifyMatrixOptions
* @returns {object}
*/
export function linkifyElement(element: HTMLElement, options = linkifyMatrixOptions): HTMLElement {
return _linkifyElement(element, options);
}
/** /**
* Linkify the given string and sanitize the HTML afterwards. * Linkify the given string and sanitize the HTML afterwards.
* *

View File

@@ -299,12 +299,6 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.threadSupport = true; opts.threadSupport = true;
if (SettingsStore.getValue("feature_sliding_sync")) { if (SettingsStore.getValue("feature_sliding_sync")) {
throw new UserFriendlyError("sliding_sync_legacy_no_longer_supported");
}
// If the user has enabled the labs feature for sliding sync, set it up
// otherwise check if the feature is supported
if (SettingsStore.getValue("feature_simplified_sliding_sync")) {
opts.slidingSync = await SlidingSyncManager.instance.setup(this.matrixClient); opts.slidingSync = await SlidingSyncManager.instance.setup(this.matrixClient);
} else { } else {
SlidingSyncManager.instance.checkSupport(this.matrixClient); SlidingSyncManager.instance.checkSupport(this.matrixClient);

View File

@@ -238,25 +238,25 @@ export function determineUnreadState(
room?: Room, room?: Room,
threadId?: string, threadId?: string,
includeThreads?: boolean, includeThreads?: boolean,
): { level: NotificationLevel; symbol: string | null; count: number; invited: boolean } { ): { level: NotificationLevel; symbol: string | null; count: number } {
if (!room) { if (!room) {
return { symbol: null, count: 0, level: NotificationLevel.None, invited: false }; return { symbol: null, count: 0, level: NotificationLevel.None };
} }
if (getUnsentMessages(room, threadId).length > 0) { if (getUnsentMessages(room, threadId).length > 0) {
return { symbol: "!", count: 1, level: NotificationLevel.Unsent, invited: false }; return { symbol: "!", count: 1, level: NotificationLevel.Unsent };
} }
if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) { if (getEffectiveMembership(room.getMyMembership()) === EffectiveMembership.Invite) {
return { symbol: "!", count: 1, level: NotificationLevel.Highlight, invited: true }; return { symbol: "!", count: 1, level: NotificationLevel.Highlight };
} }
if (SettingsStore.getValue("feature_ask_to_join") && isKnockDenied(room)) { if (SettingsStore.getValue("feature_ask_to_join") && isKnockDenied(room)) {
return { symbol: "!", count: 1, level: NotificationLevel.Highlight, invited: false }; return { symbol: "!", count: 1, level: NotificationLevel.Highlight };
} }
if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) { if (getRoomNotifsState(room.client, room.roomId) === RoomNotifState.Mute) {
return { symbol: null, count: 0, level: NotificationLevel.None, invited: false }; return { symbol: null, count: 0, level: NotificationLevel.None };
} }
const redNotifs = getUnreadNotificationCount( const redNotifs = getUnreadNotificationCount(
@@ -269,12 +269,12 @@ export function determineUnreadState(
const trueCount = greyNotifs || redNotifs; const trueCount = greyNotifs || redNotifs;
if (redNotifs > 0) { if (redNotifs > 0) {
return { symbol: null, count: trueCount, level: NotificationLevel.Highlight, invited: false }; return { symbol: null, count: trueCount, level: NotificationLevel.Highlight };
} }
const markedUnreadState = getMarkedUnreadState(room); const markedUnreadState = getMarkedUnreadState(room);
if (greyNotifs > 0 || markedUnreadState) { if (greyNotifs > 0 || markedUnreadState) {
return { symbol: null, count: trueCount, level: NotificationLevel.Notification, invited: false }; return { symbol: null, count: trueCount, level: NotificationLevel.Notification };
} }
// We don't have any notified messages, but we might have unread messages. Let's find out. // We don't have any notified messages, but we might have unread messages. Let's find out.
@@ -293,6 +293,5 @@ export function determineUnreadState(
symbol: null, symbol: null,
count: trueCount, count: trueCount,
level: hasUnread ? NotificationLevel.Activity : NotificationLevel.None, level: hasUnread ? NotificationLevel.Activity : NotificationLevel.None,
invited: false,
}; };
} }

View File

@@ -9,7 +9,7 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import * as React from "react";
import { import {
ContentHelpers, ContentHelpers,
Direction, Direction,

View File

@@ -36,51 +36,45 @@ Please see LICENSE files in the repository root for full details.
* list ops) * list ops)
*/ */
import { type MatrixClient, ClientEvent, EventType, type Room } from "matrix-js-sdk/src/matrix"; import { type MatrixClient, EventType, AutoDiscovery, Method, timeoutSignal } from "matrix-js-sdk/src/matrix";
import { import {
type MSC3575Filter, type MSC3575Filter,
type MSC3575List, type MSC3575List,
type MSC3575SlidingSyncResponse,
MSC3575_STATE_KEY_LAZY, MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME, MSC3575_STATE_KEY_ME,
MSC3575_WILDCARD, MSC3575_WILDCARD,
SlidingSync, SlidingSync,
SlidingSyncEvent,
SlidingSyncState,
} from "matrix-js-sdk/src/sliding-sync"; } from "matrix-js-sdk/src/sliding-sync";
import { logger } from "matrix-js-sdk/src/logger"; import { logger } from "matrix-js-sdk/src/logger";
import { defer, sleep } from "matrix-js-sdk/src/utils"; import { defer, sleep } from "matrix-js-sdk/src/utils";
import SettingsStore from "./settings/SettingsStore";
import SlidingSyncController from "./settings/controllers/SlidingSyncController";
// how long to long poll for // how long to long poll for
const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000; const SLIDING_SYNC_TIMEOUT_MS = 20 * 1000;
// The state events we will get for every single room/space/old room/etc
// This list is only augmented when a direct room subscription is made. (e.g you view a room)
const REQUIRED_STATE_LIST = [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomCanonicalAlias, ""], // for room name calculations
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
];
// the things to fetch when a user clicks on a room // the things to fetch when a user clicks on a room
const DEFAULT_ROOM_SUBSCRIPTION_INFO = { const DEFAULT_ROOM_SUBSCRIPTION_INFO = {
timeline_limit: 50, timeline_limit: 50,
// missing required_state which will change depending on the kind of room // missing required_state which will change depending on the kind of room
include_old_rooms: { include_old_rooms: {
timeline_limit: 0, timeline_limit: 0,
required_state: REQUIRED_STATE_LIST, required_state: [
// state needed to handle space navigation and tombstone chains
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""],
[EventType.SpaceChild, MSC3575_WILDCARD],
[EventType.SpaceParent, MSC3575_WILDCARD],
[EventType.RoomMember, MSC3575_STATE_KEY_ME],
],
}, },
}; };
// lazy load room members so rooms like Matrix HQ don't take forever to load // lazy load room members so rooms like Matrix HQ don't take forever to load
const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted"; const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted";
const UNENCRYPTED_SUBSCRIPTION = { const UNENCRYPTED_SUBSCRIPTION = {
required_state: [ required_state: [
[MSC3575_WILDCARD, MSC3575_WILDCARD], // all events
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // except for m.room.members, get our own membership [EventType.RoomMember, MSC3575_STATE_KEY_ME], // except for m.room.members, get our own membership
[EventType.RoomMember, MSC3575_STATE_KEY_LAZY], // ...and lazy load the rest. [EventType.RoomMember, MSC3575_STATE_KEY_LAZY], // ...and lazy load the rest.
], ],
@@ -96,72 +90,6 @@ const ENCRYPTED_SUBSCRIPTION = {
...DEFAULT_ROOM_SUBSCRIPTION_INFO, ...DEFAULT_ROOM_SUBSCRIPTION_INFO,
}; };
// the complete set of lists made in SSS. The manager will spider all of these lists depending
// on the count for each one.
const sssLists: Record<string, MSC3575List> = {
spaces: {
ranges: [[0, 10]],
timeline_limit: 0, // we don't care about the most recent message for spaces
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
room_types: ["m.space"],
},
},
invites: {
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
is_invite: true,
},
},
favourites: {
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
tags: ["m.favourite"],
},
},
dms: {
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
filters: {
is_dm: true,
is_invite: false,
// If a DM has a Favourite & Low Prio tag then it'll be shown in those lists instead
not_tags: ["m.favourite", "m.lowpriority"],
},
},
untagged: {
// SSS will dupe suppress invites/dms from here, so we don't need "not dms, not invites"
ranges: [[0, 10]],
timeline_limit: 1, // most recent message display
required_state: REQUIRED_STATE_LIST,
include_old_rooms: {
timeline_limit: 0,
required_state: REQUIRED_STATE_LIST,
},
},
};
export type PartialSlidingSyncRequest = { export type PartialSlidingSyncRequest = {
filters?: MSC3575Filter; filters?: MSC3575Filter;
sort?: string[]; sort?: string[];
@@ -175,8 +103,6 @@ export type PartialSlidingSyncRequest = {
* sync options and code. * sync options and code.
*/ */
export class SlidingSyncManager { export class SlidingSyncManager {
public static serverSupportsSlidingSync: boolean;
public static readonly ListSpaces = "space_list"; public static readonly ListSpaces = "space_list";
public static readonly ListSearch = "search_list"; public static readonly ListSearch = "search_list";
private static readonly internalInstance = new SlidingSyncManager(); private static readonly internalInstance = new SlidingSyncManager();
@@ -190,17 +116,48 @@ export class SlidingSyncManager {
return SlidingSyncManager.internalInstance; return SlidingSyncManager.internalInstance;
} }
private configure(client: MatrixClient, proxyUrl: string): SlidingSync { public configure(client: MatrixClient, proxyUrl: string): SlidingSync {
this.client = client; this.client = client;
// create the set of lists we will use.
const lists = new Map();
for (const listName in sssLists) {
lists.set(listName, sssLists[listName]);
}
// by default use the encrypted subscription as that gets everything, which is a safer // by default use the encrypted subscription as that gets everything, which is a safer
// default than potentially missing member events. // default than potentially missing member events.
this.slidingSync = new SlidingSync(proxyUrl, lists, ENCRYPTED_SUBSCRIPTION, client, SLIDING_SYNC_TIMEOUT_MS); this.slidingSync = new SlidingSync(
proxyUrl,
new Map(),
ENCRYPTED_SUBSCRIPTION,
client,
SLIDING_SYNC_TIMEOUT_MS,
);
this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION); this.slidingSync.addCustomSubscription(UNENCRYPTED_SUBSCRIPTION_NAME, UNENCRYPTED_SUBSCRIPTION);
// set the space list
this.slidingSync.setList(SlidingSyncManager.ListSpaces, {
ranges: [[0, 20]],
sort: ["by_name"],
slow_get_all_rooms: true,
timeline_limit: 0,
required_state: [
[EventType.RoomJoinRules, ""], // the public icon on the room list
[EventType.RoomAvatar, ""], // any room avatar
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
[EventType.RoomCreate, ""], // for isSpaceRoom checks
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
include_old_rooms: {
timeline_limit: 0,
required_state: [
[EventType.RoomCreate, ""],
[EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
[EventType.SpaceChild, MSC3575_WILDCARD], // all space children
[EventType.SpaceParent, MSC3575_WILDCARD], // all space parents
[EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
},
filters: {
room_types: ["m.space"],
},
});
this.configureDefer.resolve(); this.configureDefer.resolve();
return this.slidingSync; return this.slidingSync;
} }
@@ -263,113 +220,99 @@ export class SlidingSyncManager {
return this.slidingSync!.getListParams(listKey)!; return this.slidingSync!.getListParams(listKey)!;
} }
/** public async setRoomVisible(roomId: string, visible: boolean): Promise<string> {
* Announces that the user has chosen to view the given room and that room will now
* be displayed, so it should have more state loaded.
* @param roomId The room to set visible
*/
public async setRoomVisible(roomId: string): Promise<void> {
await this.configureDefer.promise; await this.configureDefer.promise;
const subscriptions = this.slidingSync!.getRoomSubscriptions(); const subscriptions = this.slidingSync!.getRoomSubscriptions();
if (subscriptions.has(roomId)) return; if (visible) {
subscriptions.add(roomId);
subscriptions.add(roomId); } else {
subscriptions.delete(roomId);
const room = this.client?.getRoom(roomId);
// default to safety: request all state if we can't work it out. This can happen if you
// refresh the app whilst viewing a room: we call setRoomVisible before we know anything
// about the room.
let shouldLazyLoad = false;
if (room) {
// do not lazy load encrypted rooms as we need the entire member list.
shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId));
} }
logger.log("SlidingSync setRoomVisible:", roomId, "shouldLazyLoad:", shouldLazyLoad); const room = this.client?.getRoom(roomId);
let shouldLazyLoad = !(await this.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId));
if (!room) {
// default to safety: request all state if we can't work it out. This can happen if you
// refresh the app whilst viewing a room: we call setRoomVisible before we know anything
// about the room.
shouldLazyLoad = false;
}
logger.log("SlidingSync setRoomVisible:", roomId, visible, "shouldLazyLoad:", shouldLazyLoad);
if (shouldLazyLoad) { if (shouldLazyLoad) {
// lazy load this room // lazy load this room
this.slidingSync!.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_NAME); this.slidingSync!.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_NAME);
} }
this.slidingSync!.modifyRoomSubscriptions(subscriptions); const p = this.slidingSync!.modifyRoomSubscriptions(subscriptions);
if (room) { if (room) {
return; // we have data already for this room, show immediately e.g it's in a list return roomId; // we have data already for this room, show immediately e.g it's in a list
} }
// wait until we know about this room. This may take a little while. try {
return new Promise((resolve) => { // wait until the next sync before returning as RoomView may need to know the current state
logger.log(`SlidingSync setRoomVisible room ${roomId} not found, waiting for ClientEvent.Room`); await p;
const waitForRoom = (r: Room): void => { } catch {
if (r.roomId === roomId) { logger.warn("SlidingSync setRoomVisible:", roomId, visible, "failed to confirm transaction");
this.client?.off(ClientEvent.Room, waitForRoom); }
logger.log(`SlidingSync room ${roomId} found, resolving setRoomVisible`); return roomId;
resolve();
}
};
this.client?.on(ClientEvent.Room, waitForRoom);
});
} }
/** /**
* Retrieve all rooms on the user's account. Retrieval is gradual over time. * Retrieve all rooms on the user's account. Used for pre-populating the local search cache.
* This function MUST be called BEFORE the first sync request goes out. * Retrieval is gradual over time.
* @param batchSize The number of rooms to return in each request. * @param batchSize The number of rooms to return in each request.
* @param gapBetweenRequestsMs The number of milliseconds to wait between requests. * @param gapBetweenRequestsMs The number of milliseconds to wait between requests.
*/ */
private async startSpidering( public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise<void> {
slidingSync: SlidingSync, await sleep(gapBetweenRequestsMs); // wait a bit as this is called on first render so let's let things load
batchSize: number, let startIndex = batchSize;
gapBetweenRequestsMs: number, let hasMore = true;
): Promise<void> { let firstTime = true;
// The manager has created several lists (see `sssLists` in this file), all of which will be spidered simultaneously. while (hasMore) {
// There are multiple lists to ensure that we can populate invites/favourites/DMs sections immediately, rather than const endIndex = startIndex + batchSize - 1;
// potentially waiting minutes if they are all very old rooms (and hence are returned last by the server). In this try {
// way, the lists are effectively priority requests. We don't actually care which room goes into which list at this const ranges = [
// point, as the RoomListStore will calculate this based on the returned data. [0, batchSize - 1],
[startIndex, endIndex],
// copy the initial set of list names and ranges, we'll keep this map updated. ];
const listToUpperBound = new Map( if (firstTime) {
Object.keys(sssLists).map((listName) => { await this.slidingSync!.setList(SlidingSyncManager.ListSearch, {
return [listName, sssLists[listName].ranges[0][1]]; // e.g [0,19] [20,39] then [0,19] [40,59]. We keep [0,20] constantly to ensure
}), // any changes to the list whilst spidering are caught.
); ranges: ranges,
console.log("startSpidering:", listToUpperBound); sort: [
"by_recency", // this list isn't shown on the UI so just sorting by timestamp is enough
// listen for a response from the server. ANY 200 OK will do here, as we assume that it is ACKing ],
// the request change we have sent out. TODO: this may not be true if you concurrently subscribe to a room :/ timeline_limit: 0, // we only care about the room details, not messages in the room
// but in that case, for spidering at least, it isn't the end of the world as request N+1 includes all indexes required_state: [
// from request N. [EventType.RoomJoinRules, ""], // the public icon on the room list
const lifecycle = async ( [EventType.RoomAvatar, ""], // any room avatar
state: SlidingSyncState, [EventType.RoomTombstone, ""], // lets JS SDK hide rooms which are dead
_: MSC3575SlidingSyncResponse | null, [EventType.RoomEncryption, ""], // lets rooms be configured for E2EE correctly
err?: Error, [EventType.RoomCreate, ""], // for isSpaceRoom checks
): Promise<void> => { [EventType.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
if (state !== SlidingSyncState.Complete) { ],
return; // we don't include_old_rooms here in an effort to reduce the impact of spidering all rooms
} // on the user's account. This means some data in the search dialog results may be inaccurate
await sleep(gapBetweenRequestsMs); // don't tightloop; even on errors // e.g membership of space, but this will be corrected when the user clicks on the room
if (err) { // as the direct room subscription does include old room iterations.
return; filters: {
} // we get spaces via a different list, so filter them out
not_room_types: ["m.space"],
// for all lists with total counts > range => increase the range },
let hasSetRanges = false; });
listToUpperBound.forEach((currentUpperBound, listName) => { } else {
const totalCount = slidingSync.getListData(listName)?.joinedCount || 0; await this.slidingSync!.setListRanges(SlidingSyncManager.ListSearch, ranges);
if (currentUpperBound < totalCount) {
// increment the upper bound
const newUpperBound = currentUpperBound + batchSize;
console.log(`startSpidering: ${listName} ${currentUpperBound} => ${newUpperBound}`);
listToUpperBound.set(listName, newUpperBound);
// make the next request. This will only send the request when this callback has finished, so if
// we set all the list ranges at once we will only send 1 new request.
slidingSync.setListRanges(listName, [[0, newUpperBound]]);
hasSetRanges = true;
} }
}); } catch {
if (!hasSetRanges) { // do nothing, as we reject only when we get interrupted but that's fine as the next
// finish spidering // request will include our data
slidingSync.off(SlidingSyncEvent.Lifecycle, lifecycle); } finally {
// gradually request more over time, even on errors.
await sleep(gapBetweenRequestsMs);
} }
}; const listData = this.slidingSync!.getListData(SlidingSyncManager.ListSearch)!;
slidingSync.on(SlidingSyncEvent.Lifecycle, lifecycle); hasMore = endIndex + 1 < listData.joinedCount;
startIndex += batchSize;
firstTime = false;
}
} }
/** /**
@@ -382,10 +325,42 @@ export class SlidingSyncManager {
* @returns A working Sliding Sync or undefined * @returns A working Sliding Sync or undefined
*/ */
public async setup(client: MatrixClient): Promise<SlidingSync | undefined> { public async setup(client: MatrixClient): Promise<SlidingSync | undefined> {
const slidingSync = this.configure(client, client.baseUrl); const baseUrl = client.baseUrl;
logger.info("Simplified Sliding Sync activated at", client.baseUrl); const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url");
this.startSpidering(slidingSync, 50, 50); // 50 rooms at a time, 50ms apart const wellKnownProxyUrl = await this.getProxyFromWellKnown(client);
return slidingSync;
const slidingSyncEndpoint = proxyUrl || wellKnownProxyUrl || baseUrl;
this.configure(client, slidingSyncEndpoint);
logger.info("Sliding sync activated at", slidingSyncEndpoint);
this.startSpidering(100, 50); // 100 rooms at a time, 50ms apart
return this.slidingSync;
}
/**
* Get the sliding sync proxy URL from the client well known
* @param client The MatrixClient to use
* @return The proxy url
*/
public async getProxyFromWellKnown(client: MatrixClient): Promise<string | undefined> {
let proxyUrl: string | undefined;
try {
const clientDomain = await client.getDomain();
if (clientDomain === null) {
throw new RangeError("Homeserver domain is null");
}
const clientWellKnown = await AutoDiscovery.findClientConfig(clientDomain);
proxyUrl = clientWellKnown?.["org.matrix.msc3575.proxy"]?.url;
} catch {
// Either client.getDomain() is null so we've shorted out, or is invalid so `AutoDiscovery.findClientConfig` has thrown
}
if (proxyUrl != undefined) {
logger.log("getProxyFromWellKnown: client well-known declares sliding sync proxy at", proxyUrl);
}
return proxyUrl;
} }
/** /**
@@ -396,9 +371,9 @@ export class SlidingSyncManager {
public async nativeSlidingSyncSupport(client: MatrixClient): Promise<boolean> { public async nativeSlidingSyncSupport(client: MatrixClient): Promise<boolean> {
// Per https://github.com/matrix-org/matrix-spec-proposals/pull/3575/files#r1589542561 // Per https://github.com/matrix-org/matrix-spec-proposals/pull/3575/files#r1589542561
// `client` can be undefined/null in tests for some reason. // `client` can be undefined/null in tests for some reason.
const support = await client?.doesServerSupportUnstableFeature("org.matrix.simplified_msc3575"); const support = await client?.doesServerSupportUnstableFeature("org.matrix.msc3575");
if (support) { if (support) {
logger.log("nativeSlidingSyncSupport: org.matrix.simplified_msc3575 sliding sync advertised as unstable"); logger.log("nativeSlidingSyncSupport: sliding sync advertised as unstable");
} }
return support; return support;
} }
@@ -412,9 +387,20 @@ export class SlidingSyncManager {
*/ */
public async checkSupport(client: MatrixClient): Promise<void> { public async checkSupport(client: MatrixClient): Promise<void> {
if (await this.nativeSlidingSyncSupport(client)) { if (await this.nativeSlidingSyncSupport(client)) {
SlidingSyncManager.serverSupportsSlidingSync = true; SlidingSyncController.serverSupportsSlidingSync = true;
return; return;
} }
SlidingSyncManager.serverSupportsSlidingSync = false;
const proxyUrl = await this.getProxyFromWellKnown(client);
if (proxyUrl != undefined) {
const response = await fetch(new URL("/client/server.json", proxyUrl), {
method: Method.Get,
signal: timeoutSignal(10 * 1000), // 10s
});
if (response.status === 200) {
logger.log("checkSupport: well-known sliding sync proxy is up at", proxyUrl);
SlidingSyncController.serverSupportsSlidingSync = true;
}
}
} }
} }

View File

@@ -11,6 +11,7 @@ import { logger } from "matrix-js-sdk/src/logger";
import shouldHideEvent from "./shouldHideEvent"; import shouldHideEvent from "./shouldHideEvent";
import { haveRendererForEvent } from "./events/EventTileFactory"; import { haveRendererForEvent } from "./events/EventTileFactory";
import SettingsStore from "./settings/SettingsStore";
import { RoomNotifState, getRoomNotifsState } from "./RoomNotifs"; import { RoomNotifState, getRoomNotifsState } from "./RoomNotifs";
/** /**
@@ -43,6 +44,12 @@ export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent):
} }
export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean { export function doesRoomHaveUnreadMessages(room: Room, includeThreads: boolean): boolean {
if (SettingsStore.getValue("feature_sliding_sync")) {
// TODO: https://github.com/vector-im/element-web/issues/23207
// Sliding Sync doesn't support unread indicator dots (yet...)
return false;
}
const toCheck: Array<Room | Thread> = [room]; const toCheck: Array<Room | Thread> = [room];
if (includeThreads) { if (includeThreads) {
toCheck.push(...room.getThreads()); toCheck.push(...room.getThreads());

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2019 The Matrix.org Foundation C.I.C. Copyright 2019 The Matrix.org Foundation C.I.C.
Copyright 2018 New Vector Ltd Copyright 2018 New Vector Ltd
Copyright 2015, 2016 OpenMarket Ltd Copyright 2015, 2016 OpenMarket Ltd
@@ -16,12 +16,13 @@ import { KeyBindingAction } from "../KeyboardShortcuts";
import { getKeyBindingsManager } from "../../KeyBindingsManager"; import { getKeyBindingsManager } from "../../KeyBindingsManager";
interface IProps extends React.ComponentProps<typeof StyledCheckbox> { interface IProps extends React.ComponentProps<typeof StyledCheckbox> {
label?: string;
onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent onChange(): void; // we handle keyup/down ourselves so lose the ChangeEvent
onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton onClose(): void; // gets called after onChange on KeyBindingAction.ActivateSelectedButton
} }
// Semantic component for representing a styled role=menuitemcheckbox // Semantic component for representing a styled role=menuitemcheckbox
export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, onChange, onClose, ...props }) => { export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, label, onChange, onClose, ...props }) => {
const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>(); const [onFocus, isActive, ref] = useRovingTabIndex<HTMLInputElement>();
const onKeyDown = (e: React.KeyboardEvent): void => { const onKeyDown = (e: React.KeyboardEvent): void => {
@@ -62,6 +63,7 @@ export const StyledMenuItemCheckbox: React.FC<IProps> = ({ children, onChange, o
<StyledCheckbox <StyledCheckbox
{...props} {...props}
role="menuitemcheckbox" role="menuitemcheckbox"
aria-label={label}
onChange={onChange} onChange={onChange}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
onKeyUp={onKeyUp} onKeyUp={onKeyUp}

View File

@@ -40,8 +40,8 @@ export default class EmbeddedPage extends React.PureComponent<IProps, IState> {
private unmounted = false; private unmounted = false;
private dispatcherRef?: string; private dispatcherRef?: string;
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props); super(props, context);
this.state = { this.state = {
page: "", page: "",

View File

@@ -235,7 +235,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
private themeWatcher?: ThemeWatcher; private themeWatcher?: ThemeWatcher;
private fontWatcher?: FontWatcher; private fontWatcher?: FontWatcher;
private readonly stores: SdkContextClass; private readonly stores: SdkContextClass;
private loadSessionAbortController = new AbortController();
public constructor(props: IProps) { public constructor(props: IProps) {
super(props); super(props);
@@ -328,7 +327,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// When the session loads it'll be detected as soft logged out and a dispatch // When the session loads it'll be detected as soft logged out and a dispatch
// will be sent out to say that, triggering this MatrixChat to show the soft // will be sent out to say that, triggering this MatrixChat to show the soft
// logout page. // logout page.
Lifecycle.loadSession({ abortSignal: this.loadSessionAbortController.signal }); Lifecycle.loadSession();
return; return;
} }
@@ -553,7 +552,6 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
guestHsUrl: this.getServerProperties().serverConfig.hsUrl, guestHsUrl: this.getServerProperties().serverConfig.hsUrl,
guestIsUrl: this.getServerProperties().serverConfig.isUrl, guestIsUrl: this.getServerProperties().serverConfig.isUrl,
defaultDeviceDisplayName: this.props.defaultDeviceDisplayName, defaultDeviceDisplayName: this.props.defaultDeviceDisplayName,
abortSignal: this.loadSessionAbortController.signal,
}); });
}) })
.then((loadedSession) => { .then((loadedSession) => {
@@ -1390,7 +1388,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
// so show the homepage. // so show the homepage.
dis.dispatch<ViewHomePagePayload>({ action: Action.ViewHomePage, justRegistered: true }); dis.dispatch<ViewHomePagePayload>({ action: Action.ViewHomePage, justRegistered: true });
} }
} else if (!(await this.shouldForceVerification())) { } else {
this.showScreenAfterLogin(); this.showScreenAfterLogin();
} }
@@ -1567,33 +1565,26 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
dis.fire(Action.FocusSendMessageComposer); dis.fire(Action.FocusSendMessageComposer);
}); });
cli.on(HttpApiEvent.SessionLoggedOut, (errObj) => { cli.on(HttpApiEvent.SessionLoggedOut, function (errObj) {
this.loadSessionAbortController.abort(errObj);
this.loadSessionAbortController = new AbortController();
if (Lifecycle.isLoggingOut()) return; if (Lifecycle.isLoggingOut()) return;
// A modal might have been open when we were logged out by the server // A modal might have been open when we were logged out by the server
Modal.forceCloseAllModals(); Modal.forceCloseAllModals();
if (errObj.httpStatus === 401 && errObj.data?.["soft_logout"]) { if (errObj.httpStatus === 401 && errObj.data && errObj.data["soft_logout"]) {
logger.warn("Soft logout issued by server - avoiding data deletion"); logger.warn("Soft logout issued by server - avoiding data deletion");
Lifecycle.softLogout(); Lifecycle.softLogout();
return; return;
} }
dis.dispatch(
{
action: "logout",
},
true,
);
// The above dispatch closes all modals, so open the modal after calling it synchronously
Modal.createDialog(ErrorDialog, { Modal.createDialog(ErrorDialog, {
title: _t("auth|session_logged_out_title"), title: _t("auth|session_logged_out_title"),
description: _t("auth|session_logged_out_description"), description: _t("auth|session_logged_out_description"),
}); });
dis.dispatch({
action: "logout",
});
}); });
cli.on(HttpApiEvent.NoConsent, function (message, consentUri) { cli.on(HttpApiEvent.NoConsent, function (message, consentUri) {
Modal.createDialog( Modal.createDialog(
@@ -2012,17 +2003,9 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
}; };
// complete security / e2e setup has finished // complete security / e2e setup has finished
private onCompleteSecurityE2eSetupFinished = async (): Promise<void> => { private onCompleteSecurityE2eSetupFinished = (): void => {
const forceVerify = await this.shouldForceVerification(); // This is async but we making this function async to wait for it isn't useful
if (forceVerify) { this.onShowPostLoginScreen().catch((e) => {
const isVerified = await MatrixClientPeg.safeGet().getCrypto()?.isCrossSigningReady();
if (!isVerified) {
// We must verify but we haven't yet verified - don't continue logging in
return;
}
}
await this.onShowPostLoginScreen().catch((e) => {
logger.error("Exception showing post-login screen", e); logger.error("Exception showing post-login screen", e);
}); });
}; };

View File

@@ -259,8 +259,8 @@ export default class MessagePanel extends React.Component<IProps, IState> {
// A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination. // A map to allow groupers to maintain consistent keys even if their first event is uprooted due to back-pagination.
public grouperKeyMap = new WeakMap<MatrixEvent, string>(); public grouperKeyMap = new WeakMap<MatrixEvent, string>();
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props); super(props, context);
this.state = { this.state = {
// previous positions the read marker has been in, so we can // previous positions the read marker has been in, so we can

View File

@@ -6,7 +6,7 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import * as React from "react";
import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import { type EmptyObject } from "matrix-js-sdk/src/matrix";
import { type ComponentClass } from "../../@types/common"; import { type ComponentClass } from "../../@types/common";

View File

@@ -38,8 +38,8 @@ export default class NotificationPanel extends React.PureComponent<IProps, IStat
private card = React.createRef<HTMLDivElement>(); private card = React.createRef<HTMLDivElement>();
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props); super(props, context);
this.state = { this.state = {
narrow: false, narrow: false,

View File

@@ -64,10 +64,8 @@ export default class RightPanel extends React.Component<Props, IState> {
public static contextType = MatrixClientContext; public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>; declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: Props) { public constructor(props: Props, context: React.ContextType<typeof MatrixClientContext>) {
super(props); super(props, context);
this.state = RightPanel.getDerivedStateFromProps(props);
} }
private readonly delayedUpdate = throttle( private readonly delayedUpdate = throttle(

View File

@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
*/ */
import classNames from "classnames"; import classNames from "classnames";
import React from "react"; import * as React from "react";
import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts"; import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts";
import defaultDispatcher from "../../dispatcher/dispatcher"; import defaultDispatcher from "../../dispatcher/dispatcher";

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021-2023 The Matrix.org Foundation C.I.C. Copyright 2021-2023 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -16,7 +16,6 @@ import React, {
useCallback, useCallback,
useContext, useContext,
useEffect, useEffect,
useId,
useMemo, useMemo,
useRef, useRef,
useState, useState,
@@ -117,7 +116,6 @@ const Tile: React.FC<ITileProps> = ({
const [showChildren, toggleShowChildren] = useStateToggle(true); const [showChildren, toggleShowChildren] = useStateToggle(true);
const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex(); const [onFocus, isActive, ref, nodeRef] = useRovingTabIndex();
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const checkboxLabelId = useId();
const onPreviewClick = (ev: ButtonEvent): void => { const onPreviewClick = (ev: ButtonEvent): void => {
ev.preventDefault(); ev.preventDefault();
@@ -174,14 +172,7 @@ const Tile: React.FC<ITileProps> = ({
let checkbox: ReactElement | undefined; let checkbox: ReactElement | undefined;
if (onToggleClick) { if (onToggleClick) {
if (hasPermissions) { if (hasPermissions) {
checkbox = ( checkbox = <StyledCheckbox checked={!!selected} onChange={onToggleClick} tabIndex={isActive ? 0 : -1} />;
<StyledCheckbox
role="presentation"
aria-labelledby={checkboxLabelId}
checked={!!selected}
tabIndex={-1}
/>
);
} else { } else {
checkbox = ( checkbox = (
<TextWithTooltip <TextWithTooltip
@@ -190,12 +181,7 @@ const Tile: React.FC<ITileProps> = ({
ev.stopPropagation(); ev.stopPropagation();
}} }}
> >
<StyledCheckbox <StyledCheckbox disabled={true} tabIndex={isActive ? 0 : -1} />
role="presentation"
aria-labelledby={checkboxLabelId}
disabled={true}
tabIndex={-1}
/>
</TextWithTooltip> </TextWithTooltip>
); );
} }
@@ -262,7 +248,7 @@ const Tile: React.FC<ITileProps> = ({
<div className="mx_SpaceHierarchy_roomTile_item"> <div className="mx_SpaceHierarchy_roomTile_item">
<div className="mx_SpaceHierarchy_roomTile_avatar">{avatar}</div> <div className="mx_SpaceHierarchy_roomTile_avatar">{avatar}</div>
<div className="mx_SpaceHierarchy_roomTile_name"> <div className="mx_SpaceHierarchy_roomTile_name">
<span id={checkboxLabelId}>{name}</span> {name}
{joinedSection} {joinedSection}
{suggestedSection} {suggestedSection}
</div> </div>
@@ -344,14 +330,11 @@ const Tile: React.FC<ITileProps> = ({
}; };
} }
const shouldToggle = hasPermissions && onToggleClick;
return ( return (
<li <li
className="mx_SpaceHierarchy_roomTileWrapper" className="mx_SpaceHierarchy_roomTileWrapper"
role="treeitem" role="treeitem"
aria-selected={selected} aria-selected={selected}
aria-labelledby={checkboxLabelId}
aria-expanded={children ? showChildren : undefined} aria-expanded={children ? showChildren : undefined}
> >
<AccessibleButton <AccessibleButton
@@ -359,7 +342,7 @@ const Tile: React.FC<ITileProps> = ({
mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space, mx_SpaceHierarchy_subspace: room.room_type === RoomType.Space,
mx_SpaceHierarchy_joining: busy, mx_SpaceHierarchy_joining: busy,
})} })}
onClick={shouldToggle ? onToggleClick : onPreviewClick} onClick={hasPermissions && onToggleClick ? onToggleClick : onPreviewClick}
onKeyDown={onKeyDown} onKeyDown={onKeyDown}
ref={ref} ref={ref}
onFocus={onFocus} onFocus={onFocus}

View File

@@ -86,8 +86,8 @@ export default class ThreadView extends React.Component<IProps, IState> {
// Set by setEventId in ctor. // Set by setEventId in ctor.
private eventId!: string; private eventId!: string;
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props); super(props, context);
this.setEventId(this.props.mxEvent); this.setEventId(this.props.mxEvent);
const thread = this.props.room.getThread(this.eventId) ?? undefined; const thread = this.props.room.getThread(this.eventId) ?? undefined;

View File

@@ -6,7 +6,7 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import * as React from "react";
import classNames from "classnames"; import classNames from "classnames";
import { Text } from "@vector-im/compound-web"; import { Text } from "@vector-im/compound-web";
import { type EmptyObject } from "matrix-js-sdk/src/matrix"; import { type EmptyObject } from "matrix-js-sdk/src/matrix";

View File

@@ -83,8 +83,8 @@ export default class UserMenu extends React.Component<IProps, IState> {
private readonly dndWatcherRef?: string; private readonly dndWatcherRef?: string;
private buttonRef: React.RefObject<HTMLButtonElement> = createRef(); private buttonRef: React.RefObject<HTMLButtonElement> = createRef();
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props); super(props, context);
this.state = { this.state = {
contextMenuPosition: null, contextMenuPosition: null,
@@ -370,13 +370,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
? toRightOf(this.state.contextMenuPosition) ? toRightOf(this.state.contextMenuPosition)
: below(this.state.contextMenuPosition); : below(this.state.contextMenuPosition);
const userIdentifierString = UserIdentifierCustomisations.getDisplayUserIdentifier(
MatrixClientPeg.safeGet().getSafeUserId(),
{
withDisplayName: true,
},
);
return ( return (
<IconizedContextMenu {...position} onFinished={this.onCloseMenu} className="mx_UserMenu_contextMenu"> <IconizedContextMenu {...position} onFinished={this.onCloseMenu} className="mx_UserMenu_contextMenu">
<div className="mx_UserMenu_contextMenu_header"> <div className="mx_UserMenu_contextMenu_header">
@@ -384,8 +377,13 @@ export default class UserMenu extends React.Component<IProps, IState> {
<span className="mx_UserMenu_contextMenu_displayName"> <span className="mx_UserMenu_contextMenu_displayName">
{OwnProfileStore.instance.displayName} {OwnProfileStore.instance.displayName}
</span> </span>
<span className="mx_UserMenu_contextMenu_userId" title={userIdentifierString || ""}> <span className="mx_UserMenu_contextMenu_userId">
{userIdentifierString} {UserIdentifierCustomisations.getDisplayUserIdentifier(
MatrixClientPeg.safeGet().getSafeUserId(),
{
withDisplayName: true,
},
)}
</span> </span>
</div> </div>

View File

@@ -34,8 +34,8 @@ export default class UserView extends React.Component<IProps, IState> {
public static contextType = MatrixClientContext; public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>; declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props); super(props, context);
this.state = { this.state = {
loading: true, loading: true,
}; };

View File

@@ -66,8 +66,8 @@ export default class SoftLogout extends React.Component<IProps, IState> {
public static contextType = SDKContext; public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>; declare public context: React.ContextType<typeof SDKContext>;
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props); super(props, context);
this.state = { this.state = {
loginView: LoginView.Loading, loginView: LoginView.Loading,

View File

@@ -1,57 +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 { useCallback, useEffect, useState } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { type MessagePreview, MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
import { useEventEmitter } from "../../../hooks/useEventEmitter";
interface MessagePreviewViewState {
/**
* A string representation of the message preview if available.
*/
message?: string;
}
/**
* View model for rendering a message preview for a given room list item.
* @param room The room for which we're rendering the message preview.
* @see {@link MessagePreviewViewState} for what this view model returns.
*/
export function useMessagePreviewViewModel(room: Room): MessagePreviewViewState {
const [messagePreview, setMessagePreview] = useState<MessagePreview | null>(null);
const updatePreview = useCallback(async (): Promise<void> => {
/**
* The second argument to getPreviewForRoom is a tag id which doesn't really make
* much sense within the context of the new room list. We can pass an empty string
* to match all tags for now but we should remember to actually change the implementation
* in the store once we remove the legacy room list.
*/
const newPreview = await MessagePreviewStore.instance.getPreviewForRoom(room, "");
setMessagePreview(newPreview);
}, [room]);
/**
* Update when the message preview has changed for this room.
*/
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
updatePreview();
});
/**
* Do an initial fetch of the message preview.
*/
useEffect(() => {
updatePreview();
}, [updatePreview]);
return {
message: messagePreview?.text,
};
}

View File

@@ -6,8 +6,10 @@
*/ */
import { useCallback } from "react"; import { useCallback } from "react";
import { JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix"; import { EventTimeline, EventType, JoinRule, type Room, RoomEvent, RoomType } from "matrix-js-sdk/src/matrix";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { useFeatureEnabled } from "../../../hooks/useSettings"; import { useFeatureEnabled } from "../../../hooks/useSettings";
import defaultDispatcher from "../../../dispatcher/dispatcher"; import defaultDispatcher from "../../../dispatcher/dispatcher";
import PosthogTrackers from "../../../PosthogTrackers"; import PosthogTrackers from "../../../PosthogTrackers";
@@ -30,7 +32,6 @@ import {
} from "../../../utils/space"; } from "../../../utils/space";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext"; import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload"; import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { createRoom, hasCreateRoomRights } from "./utils";
/** /**
* Hook to get the active space and its title. * Hook to get the active space and its title.
@@ -127,7 +128,14 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const { activeSpace, title } = useSpace(); const { activeSpace, title } = useSpace();
const isSpaceRoom = Boolean(activeSpace); const isSpaceRoom = Boolean(activeSpace);
const canCreateRoom = hasCreateRoomRights(matrixClient, activeSpace); const canCreateRoomInSpace = Boolean(
activeSpace
?.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.maySendStateEvent(EventType.RoomAvatar, matrixClient.getSafeUserId()),
);
// If we are in a space, we check canCreateRoomInSpace
const canCreateRoom = shouldShowComponent(UIComponent.CreateRooms) && (!isSpaceRoom || canCreateRoomInSpace);
const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms"); const canCreateVideoRoom = useFeatureEnabled("feature_video_rooms");
const displayComposeMenu = canCreateRoom || canCreateVideoRoom; const displayComposeMenu = canCreateRoom || canCreateVideoRoom;
const displaySpaceMenu = isSpaceRoom; const displaySpaceMenu = isSpaceRoom;
@@ -143,9 +151,13 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e); PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
}, []); }, []);
const createRoomMemoized = useCallback( const createRoom = useCallback(
(e: Event) => { (e: Event) => {
createRoom(activeSpace); if (activeSpace) {
showCreateNewRoom(activeSpace);
} else {
defaultDispatcher.fire(Action.CreateRoom);
}
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e); PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
}, },
[activeSpace], [activeSpace],
@@ -201,7 +213,7 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
canInviteInSpace, canInviteInSpace,
canAccessSpaceSettings, canAccessSpaceSettings,
createChatRoom, createChatRoom,
createRoom: createRoomMemoized, createRoom,
createVideoRoom, createVideoRoom,
openSpaceHome, openSpaceHome,
inviteInSpace, inviteInSpace,

View File

@@ -5,56 +5,21 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import { useCallback } from "react";
import type { Room } from "matrix-js-sdk/src/matrix"; import type { Room } from "matrix-js-sdk/src/matrix";
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms"; import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
import { type SortOption, useSorter } from "./useSorter"; import { type SortOption, useSorter } from "./useSorter";
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
import { createRoom as createRoomFunc, hasCreateRoomRights } from "./utils";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import { UPDATE_SELECTED_SPACE } from "../../../stores/spaces";
import SpaceStore from "../../../stores/spaces/SpaceStore";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import { useStickyRoomList } from "./useStickyRoomList";
export interface RoomListViewState { export interface RoomListViewState {
/** /**
* A list of rooms to be displayed in the left panel. * A list of rooms to be displayed in the left panel.
*/ */
rooms: Room[]; rooms: Room[];
/**
* Create a chat room
* @param e - The click event
*/
createChatRoom: () => void;
/**
* Whether the user can create a room in the current space
*/
canCreateRoom: boolean;
/**
* Create a room
* @param e - The click event
*/
createRoom: () => void;
/** /**
* A list of objects that provide the view enough information * A list of objects that provide the view enough information
* to render primary room filters. * to render primary room filters.
*/ */
primaryFilters: PrimaryFilter[]; primaryFilters: PrimaryFilter[];
/**
* The currently active primary filter.
* If no primary filter is active, this will be undefined.
*/
activePrimaryFilter?: PrimaryFilter;
/** /**
* A function to activate a given secondary filter. * A function to activate a given secondary filter.
*/ */
@@ -74,21 +39,6 @@ export interface RoomListViewState {
* The currently active sort option. * The currently active sort option.
*/ */
activeSortOption: SortOption; activeSortOption: SortOption;
/**
* Whether message previews must be shown or not.
*/
shouldShowMessagePreview: boolean;
/**
* A function to turn on/off message previews.
*/
toggleMessagePreview: () => void;
/**
* The index of the active room in the room list.
*/
activeIndex: number | undefined;
} }
/** /**
@@ -96,42 +46,15 @@ export interface RoomListViewState {
* @see {@link RoomListViewState} for more information about what this view model returns. * @see {@link RoomListViewState} for more information about what this view model returns.
*/ */
export function useRoomListViewModel(): RoomListViewState { export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext(); const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
const {
primaryFilters,
activePrimaryFilter,
rooms: filteredRooms,
activateSecondaryFilter,
activeSecondaryFilter,
} = useFilteredRooms();
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => SpaceStore.instance.activeSpaceRoom,
);
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
const { activeSortOption, sort } = useSorter(); const { activeSortOption, sort } = useSorter();
const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle();
const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []);
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
return { return {
rooms, rooms,
canCreateRoom,
createRoom,
createChatRoom,
primaryFilters, primaryFilters,
activePrimaryFilter,
activateSecondaryFilter, activateSecondaryFilter,
activeSecondaryFilter, activeSecondaryFilter,
activeSortOption, activeSortOption,
sort, sort,
shouldShowMessagePreview,
toggleMessagePreview,
activeIndex,
}; };
} }

View File

@@ -27,8 +27,6 @@ export interface PrimaryFilter {
active: boolean; active: boolean;
// Text that can be used in the UI to represent this filter. // Text that can be used in the UI to represent this filter.
name: string; name: string;
// The key of the filter
key: FilterKey;
} }
interface FilteredRooms { interface FilteredRooms {
@@ -36,11 +34,6 @@ interface FilteredRooms {
rooms: Room[]; rooms: Room[];
activateSecondaryFilter: (filter: SecondaryFilters) => void; activateSecondaryFilter: (filter: SecondaryFilters) => void;
activeSecondaryFilter: SecondaryFilters; activeSecondaryFilter: SecondaryFilters;
/**
* The currently active primary filter.
* If no primary filter is active, this will be undefined.
*/
activePrimaryFilter?: PrimaryFilter;
} }
const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([ const filterKeyToNameMap: Map<FilterKey, TranslationKey> = new Map([
@@ -145,14 +138,22 @@ export function useFilteredRooms(): FilteredRooms {
// SecondaryFilter is an enum for the UI, let's convert it to something // SecondaryFilter is an enum for the UI, let's convert it to something
// that the store will understand. // that the store will understand.
const secondary = secondaryFiltersToFilterKeyMap.get(filter); const secondary = secondaryFiltersToFilterKeyMap.get(filter);
// Active primary filter may need to be toggled off when applying this secondary filer.
let primary = primaryFilter;
if (
primaryFilter !== undefined &&
secondary !== undefined &&
!isPrimaryFilterCompatible(primaryFilter, secondary)
) {
primary = undefined;
}
setActiveSecondaryFilter(filter); setActiveSecondaryFilter(filter);
setPrimaryFilter(primary);
// Reset any active primary filters. updateRoomsFromStore(filterUndefined([primary, secondary]));
setPrimaryFilter(undefined);
updateRoomsFromStore(filterUndefined([secondary]));
}, },
[activeSecondaryFilter, updateRoomsFromStore], [activeSecondaryFilter, primaryFilter, updateRoomsFromStore],
); );
/** /**
@@ -171,7 +172,6 @@ export function useFilteredRooms(): FilteredRooms {
}, },
active: primaryFilter === key, active: primaryFilter === key,
name, name,
key,
}; };
}; };
const filters: PrimaryFilter[] = []; const filters: PrimaryFilter[] = [];
@@ -184,7 +184,5 @@ export function useFilteredRooms(): FilteredRooms {
return filters; return filters;
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]); }, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]); return { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter };
return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
} }

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 { useCallback, useState } from "react";
import SettingsStore from "../../../settings/SettingsStore";
import { SettingLevel } from "../../../settings/SettingLevel";
interface MessagePreviewToggleState {
shouldShowMessagePreview: boolean;
toggleMessagePreview: () => void;
}
/**
* This hook:
* - Provides a state that tracks whether message previews are turned on or off.
* - Provides a function to toggle message previews.
*/
export function useMessagePreviewToggle(): MessagePreviewToggleState {
const [shouldShowMessagePreview, setShouldShowMessagePreview] = useState(() =>
SettingsStore.getValue("RoomList.showMessagePreview"),
);
const toggleMessagePreview = useCallback((): void => {
setShouldShowMessagePreview((current) => {
const toggled = !current;
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
return toggled;
});
}, []);
return { toggleMessagePreview, shouldShowMessagePreview };
}

View File

@@ -1,117 +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 { useCallback, useEffect, useState } from "react";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { useDispatcher } from "../../../hooks/useDispatcher";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
import type { Room } from "matrix-js-sdk/src/matrix";
import type { Optional } from "matrix-events-sdk";
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
const index = rooms.findIndex((room) => room.roomId === roomId);
return index === -1 ? undefined : index;
}
function getRoomsWithStickyRoom(
rooms: Room[],
oldIndex: number | undefined,
newIndex: number | undefined,
isRoomChange: boolean,
): { newRooms: Room[]; newIndex: number | undefined } {
const updated = { newIndex, newRooms: rooms };
if (isRoomChange) {
/*
* When opening another room, the index should obviously change.
*/
return updated;
}
if (newIndex === undefined || oldIndex === undefined) {
/*
* If oldIndex is undefined, then there was no active room before.
* So nothing to do in regards to sticky room.
* Similarly, if newIndex is undefined, there's no active room anymore.
*/
return updated;
}
if (newIndex === oldIndex) {
/*
* If the index hasn't changed, we have nothing to do.
*/
return updated;
}
if (oldIndex > rooms.length - 1) {
/*
* If the old index falls out of the bounds of the rooms array
* (usually because rooms were removed), we can no longer place
* the active room in the same old index.
*/
return updated;
}
/*
* Making the active room sticky is as simple as removing it from
* its new index and placing it in the old index.
*/
const newRooms = [...rooms];
const [newRoom] = newRooms.splice(newIndex, 1);
newRooms.splice(oldIndex, 0, newRoom);
return { newIndex: oldIndex, newRooms };
}
interface StickyRoomListResult {
/**
* List of rooms with sticky active room.
*/
rooms: Room[];
/**
* Index of the active room in the room list.
*/
activeIndex: number | undefined;
}
/**
* - Provides a list of rooms such that the active room is sticky i.e the active room is kept
* in the same index even when the order of rooms in the list changes.
* - Provides the index of the active room.
* @param rooms list of rooms
* @see {@link StickyRoomListResult} details what this hook returns..
*/
export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
const [listState, setListState] = useState<{ index: number | undefined; roomsWithStickyRoom: Room[] }>({
index: undefined,
roomsWithStickyRoom: rooms,
});
const updateRoomsAndIndex = useCallback(
(newRoomId?: string, isRoomChange: boolean = false) => {
setListState((current) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId);
const oldIndex = current.index;
const { newIndex, newRooms } = getRoomsWithStickyRoom(rooms, oldIndex, newActiveIndex, isRoomChange);
return { index: newIndex, roomsWithStickyRoom: newRooms };
});
},
[rooms],
);
// Re-calculate the index when the active room has changed.
useDispatcher(dispatcher, (payload) => {
if (payload.action === Action.ActiveRoomChanged) updateRoomsAndIndex(payload.newRoomId, true);
});
// Re-calculate the index when the list of rooms has changed.
useEffect(() => {
updateRoomsAndIndex();
}, [rooms, updateRoomsAndIndex]);
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom };
}

View File

@@ -5,14 +5,11 @@
* Please see LICENSE files in the repository root for full details. * Please see LICENSE files in the repository root for full details.
*/ */
import { type Room, KnownMembership, EventTimeline, EventType, type MatrixClient } from "matrix-js-sdk/src/matrix"; import { type Room, KnownMembership } from "matrix-js-sdk/src/matrix";
import { isKnockDenied } from "../../../utils/membership"; import { isKnockDenied } from "../../../utils/membership";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature"; import { UIComponent } from "../../../settings/UIFeature";
import { showCreateNewRoom } from "../../../utils/space";
import dispatcher from "../../../dispatcher/dispatcher";
import { Action } from "../../../dispatcher/actions";
/** /**
* Check if the user has access to the options menu. * Check if the user has access to the options menu.
@@ -26,33 +23,3 @@ export function hasAccessToOptionsMenu(room: Room): boolean {
shouldShowComponent(UIComponent.RoomOptionsMenu)) shouldShowComponent(UIComponent.RoomOptionsMenu))
); );
} }
/**
* Create a room
* @param space - The space to create the room in
*/
export async function createRoom(space?: Room | null): Promise<void> {
if (space) {
await showCreateNewRoom(space);
} else {
dispatcher.fire(Action.CreateRoom);
}
}
/**
* Check if the user has the rights to create a room in the given space
* If the space is not provided, it will check if the user has the rights to create a room in general
* @param matrixClient
* @param space
*/
export function hasCreateRoomRights(matrixClient: MatrixClient, space?: Room | null): boolean {
const hasUIRight = shouldShowComponent(UIComponent.CreateRooms);
if (!space || !hasUIRight) return hasUIRight;
return Boolean(
space
?.getLiveTimeline()
.getState(EventTimeline.FORWARDS)
?.maySendStateEvent(EventType.RoomAvatar, matrixClient.getSafeUserId()),
);
}

View File

@@ -6,7 +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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { createRef } from "react"; import * as React from "react";
import { createRef } from "react";
import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call"; import { type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton"; import AccessibleButton, { type ButtonEvent } from "../elements/AccessibleButton";

View File

@@ -130,8 +130,8 @@ export default class MessageContextMenu extends React.Component<IProps, IState>
private reactButtonRef = createRef<any>(); // XXX Ref to a functional component private reactButtonRef = createRef<any>(); // XXX Ref to a functional component
public constructor(props: IProps) { public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props); super(props, context);
this.state = { this.state = {
canRedact: false, canRedact: false,

View File

@@ -1,12 +1,12 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial 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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ReactElement, type ReactNode, useContext, useId, useMemo, useRef, useState } from "react"; import React, { type ReactElement, type ReactNode, useContext, useMemo, useRef, useState } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { type Room, EventType } from "matrix-js-sdk/src/matrix"; import { type Room, EventType } from "matrix-js-sdk/src/matrix";
import { KnownMembership } from "matrix-js-sdk/src/types"; import { KnownMembership } from "matrix-js-sdk/src/types";
@@ -53,9 +53,8 @@ export const Entry: React.FC<{
checked: boolean; checked: boolean;
onChange?(value: boolean): void; onChange?(value: boolean): void;
}> = ({ room, checked, onChange }) => { }> = ({ room, checked, onChange }) => {
const id = useId();
return ( return (
<li id={id} className="mx_AddExistingToSpace_entry" aria-label={room.name}> <label className="mx_AddExistingToSpace_entry">
{room?.isSpaceRoom() ? ( {room?.isSpaceRoom() ? (
<RoomAvatar room={room} size="32px" /> <RoomAvatar room={room} size="32px" />
) : ( ) : (
@@ -63,12 +62,11 @@ export const Entry: React.FC<{
)} )}
<span className="mx_AddExistingToSpace_entry_name">{room.name}</span> <span className="mx_AddExistingToSpace_entry_name">{room.name}</span>
<StyledCheckbox <StyledCheckbox
aria-labelledby={id}
onChange={onChange ? (e) => onChange(e.currentTarget.checked) : undefined} onChange={onChange ? (e) => onChange(e.currentTarget.checked) : undefined}
checked={checked} checked={checked}
disabled={!onChange} disabled={!onChange}
/> />
</li> </label>
); );
}; };
@@ -359,7 +357,6 @@ const defaultRendererFactory =
<div className="mx_AddExistingToSpace_section"> <div className="mx_AddExistingToSpace_section">
<h3>{_t(title)}</h3> <h3>{_t(title)}</h3>
<LazyRenderList <LazyRenderList
element="ul"
itemHeight={ROW_HEIGHT} itemHeight={ROW_HEIGHT}
items={rooms} items={rooms}
scrollTop={scrollTop} scrollTop={scrollTop}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -113,13 +113,12 @@ const BulkRedactDialog: React.FC<Props> = (props) => {
<div className="mx_Dialog_content" id="mx_Dialog_content"> <div className="mx_Dialog_content" id="mx_Dialog_content">
<p>{_t("user_info|redact|confirm_description_1", { count, user })}</p> <p>{_t("user_info|redact|confirm_description_1", { count, user })}</p>
<p>{_t("user_info|redact|confirm_description_2")}</p> <p>{_t("user_info|redact|confirm_description_2")}</p>
<StyledCheckbox <StyledCheckbox checked={keepStateEvents} onChange={(e) => setKeepStateEvents(e.target.checked)}>
description={_t("user_info|redact|confirm_keep_state_explainer")}
checked={keepStateEvents}
onChange={(e) => setKeepStateEvents(e.target.checked)}
>
{_t("user_info|redact|confirm_keep_state_label")} {_t("user_info|redact|confirm_keep_state_label")}
</StyledCheckbox> </StyledCheckbox>
<div className="mx_BulkRedactDialog_checkboxMicrocopy">
{_t("user_info|redact|confirm_keep_state_explainer")}
</div>
</div> </div>
<DialogButtons <DialogButtons
primaryButton={_t("user_info|redact|confirm_button", { count })} primaryButton={_t("user_info|redact|confirm_button", { count })}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -78,6 +78,7 @@ const GenericFeatureFeedbackDialog: React.FC<IProps> = ({
}} }}
autoFocus={true} autoFocus={true}
/> />
<StyledCheckbox <StyledCheckbox
checked={canContact} checked={canContact}
onChange={(e) => setCanContact((e.target as HTMLInputElement).checked)} onChange={(e) => setCanContact((e.target as HTMLInputElement).checked)}

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -44,23 +44,22 @@ const Entry: React.FC<{
} }
return ( return (
<div className="mx_ManageRestrictedJoinRuleDialog_entry"> <label className="mx_ManageRestrictedJoinRuleDialog_entry">
<div>
<div>
{localRoom ? <RoomAvatar room={room} size="20px" /> : <RoomAvatar oobData={room} size="20px" />}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{room.name}</span>
</div>
{description && (
<div className="mx_ManageRestrictedJoinRuleDialog_entry_description">{description}</div>
)}
</div>
<StyledCheckbox <StyledCheckbox
onChange={onChange ? (e) => onChange(e.target.checked) : undefined} onChange={onChange ? (e) => onChange(e.target.checked) : undefined}
checked={checked} checked={checked}
disabled={!onChange} disabled={!onChange}
description={description} />
> </label>
<div>
{localRoom ? (
<RoomAvatar role="none" room={room} size="20px" />
) : (
<RoomAvatar oobData={room} size="20px" />
)}
<span className="mx_ManageRestrictedJoinRuleDialog_entry_name">{room.name}</span>
</div>
</StyledCheckbox>
</div>
); );
}; };

View File

@@ -6,7 +6,7 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import * as React from "react";
import { import {
ClientWidgetApi, ClientWidgetApi,
type IModalWidgetCloseRequest, type IModalWidgetCloseRequest,

View File

@@ -6,7 +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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type SyntheticEvent, useRef, useState } from "react"; import * as React from "react";
import { type SyntheticEvent, useRef, useState } from "react";
import { _t, _td } from "../../../languageHandler"; import { _t, _td } from "../../../languageHandler";
import type Field from "../elements/Field"; import type Field from "../elements/Field";

View File

@@ -1,95 +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 ChangeEventHandler, useCallback, useState } from "react";
import { Root, Field, Label, InlineSpinner, ErrorMessage } from "@vector-im/compound-web";
import { _t } from "../../../languageHandler";
import SdkConfig from "../../../SdkConfig";
import Markdown from "../../../Markdown";
import BaseDialog from "./BaseDialog";
import DialogButtons from "../elements/DialogButtons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
interface IProps {
roomId: string;
onFinished(complete: boolean): void;
}
/*
* A dialog for reporting a room.
*/
export const ReportRoomDialog: React.FC<IProps> = function ({ roomId, onFinished }) {
const [error, setErr] = useState<string>();
const [busy, setBusy] = useState(false);
const [sent, setSent] = useState(false);
const [reason, setReason] = useState("");
const client = MatrixClientPeg.safeGet();
const onReasonChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>((e) => setReason(e.target.value), []);
const onCancel = useCallback(() => onFinished(sent), [sent, onFinished]);
const onSubmit = useCallback(async () => {
setBusy(true);
try {
await client.reportRoom(roomId, reason);
setSent(true);
} catch (ex) {
if (ex instanceof Error) {
setErr(ex.message);
} else {
setErr("Unknown error");
}
} finally {
setBusy(false);
}
}, [roomId, reason, client]);
const adminMessageMD = SdkConfig.getObject("report_event")?.get("admin_message_md", "adminMessageMD");
let adminMessage: JSX.Element | undefined;
if (adminMessageMD) {
const html = new Markdown(adminMessageMD).toHTML({ externalLinks: true });
adminMessage = <p dangerouslySetInnerHTML={{ __html: html }} />;
}
return (
<BaseDialog
className="mx_ReportRoomDialog"
onFinished={() => onFinished(sent)}
title={_t("report_room|title")}
contentId="mx_ReportEventDialog"
>
{sent && <p>{_t("report_room|sent")}</p>}
{!sent && (
<Root id="mx_ReportEventDialog" onSubmit={onSubmit}>
<p>{_t("report_room|description")}</p>
{adminMessage}
<Field name="reason">
<Label htmlFor="mx_ReportRoomDialog_reason">{_t("room_settings|permissions|ban_reason")}</Label>
<textarea
id="mx_ReportRoomDialog_reason"
placeholder={_t("report_room|reason_placeholder")}
rows={5}
onChange={onReasonChange}
value={reason}
disabled={busy}
/>
{error ? <ErrorMessage>{error}</ErrorMessage> : null}
</Field>
{busy ? <InlineSpinner /> : null}
<DialogButtons
primaryButton={_t("action|send_report")}
onPrimaryButtonClick={onSubmit}
focus={true}
onCancel={onCancel}
disabled={busy}
/>
</Root>
)}
</BaseDialog>
);
};

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2021 The Matrix.org Foundation C.I.C. Copyright 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -45,13 +45,14 @@ const SpacePreferencesAppearanceTab: React.FC<Pick<IProps, "space">> = ({ space
!showPeople, !showPeople,
); );
}} }}
description={_t("space|preferences|show_people_in_space", {
spaceName: space.name,
})}
> >
{_t("common|people")} {_t("common|people")}
</StyledCheckbox> </StyledCheckbox>
<SettingsSubsectionText /> <SettingsSubsectionText>
{_t("space|preferences|show_people_in_space", {
spaceName: space.name,
})}
</SettingsSubsectionText>
</SettingsSubsection> </SettingsSubsection>
</SettingsSection> </SettingsSection>
</SettingsTab> </SettingsTab>

View File

@@ -50,11 +50,6 @@ import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserS
interface IProps { interface IProps {
initialTabId?: UserTab; initialTabId?: UserTab;
showMsc4108QrCode?: boolean; showMsc4108QrCode?: boolean;
/**
* If `true`, the flow for a user to reset their encryption will be shown. In this case, `initialTabId` must be `UserTab.Encryption`.
*
* If false or undefined, show the tab as normal.
*/
showResetIdentity?: boolean; showResetIdentity?: boolean;
sdkContext: SdkContextClass; sdkContext: SdkContextClass;
onFinished(): void; onFinished(): void;
@@ -97,7 +92,7 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
export default function UserSettingsDialog(props: IProps): JSX.Element { export default function UserSettingsDialog(props: IProps): JSX.Element {
const voipEnabled = useSettingValue(UIFeature.Voip); const voipEnabled = useSettingValue(UIFeature.Voip);
const mjolnirEnabled = useSettingValue("feature_mjolnir"); const mjolnirEnabled = useSettingValue("feature_mjolnir");
// store these props in state as changing tabs back and forth should clear them // store these props in state as changing tabs back and forth should clear it
const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode); const [showMsc4108QrCode, setShowMsc4108QrCode] = useState(props.showMsc4108QrCode);
const [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity); const [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity);

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2020, 2021 The Matrix.org Foundation C.I.C. Copyright 2020, 2021 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -100,12 +100,16 @@ export default class WidgetCapabilitiesPromptDialog extends React.PureComponent<
}); });
const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => { const checkboxRows = orderedCapabilities.map(([cap, isChecked], i) => {
const text = CapabilityText.for(cap, this.props.widgetKind); const text = CapabilityText.for(cap, this.props.widgetKind);
const byline = text.byline ? (
<span className="mx_WidgetCapabilitiesPromptDialog_byline">{text.byline}</span>
) : null;
return ( return (
<div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}> <div className="mx_WidgetCapabilitiesPromptDialog_cap" key={cap + i}>
<StyledCheckbox checked={isChecked} onChange={() => this.onToggle(cap)} description={text.byline}> <StyledCheckbox checked={isChecked} onChange={() => this.onToggle(cap)}>
{text.primary} {text.primary}
</StyledCheckbox> </StyledCheckbox>
{byline}
</div> </div>
); );
}); });

View File

@@ -6,7 +6,7 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import React from "react"; import * as React from "react";
import { _t } from "../../../languageHandler"; import { _t } from "../../../languageHandler";
import AccessibleButton, { type ButtonEvent } from "./AccessibleButton"; import AccessibleButton, { type ButtonEvent } from "./AccessibleButton";

View File

@@ -1,5 +1,5 @@
/* /*
Copyright 2024,2025 New Vector Ltd. Copyright 2024 New Vector Ltd.
Copyright 2022 The Matrix.org Foundation C.I.C. Copyright 2022 The Matrix.org Foundation C.I.C.
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
@@ -28,16 +28,13 @@ interface IProps {
const LabelledCheckbox: React.FC<IProps> = ({ value, label, byline, disabled, onChange, className }) => { const LabelledCheckbox: React.FC<IProps> = ({ value, label, byline, disabled, onChange, className }) => {
return ( return (
<div className={classnames("mx_LabelledCheckbox", className)}> <label className={classnames("mx_LabelledCheckbox", className)}>
<StyledCheckbox <StyledCheckbox disabled={disabled} checked={value} onChange={(e) => onChange(e.target.checked)} />
description={byline} <div className="mx_LabelledCheckbox_labels">
disabled={disabled}
checked={value}
onChange={(e) => onChange(e.target.checked)}
>
<span className="mx_LabelledCheckbox_label">{label}</span> <span className="mx_LabelledCheckbox_label">{label}</span>
</StyledCheckbox> {byline ? <span className="mx_LabelledCheckbox_byline">{byline}</span> : null}
</div> </div>
</label>
); );
}; };

View File

@@ -6,12 +6,13 @@ 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. Please see LICENSE files in the repository root for full details.
*/ */
import React, { type ReactElement, useContext } from "react"; import React, { type ReactElement } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix"; import { type Room, type RoomMember } from "matrix-js-sdk/src/matrix";
import { Tooltip } from "@vector-im/compound-web"; import { Tooltip } from "@vector-im/compound-web";
import { LinkIcon, UserSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { LinkIcon, UserSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { MatrixClientPeg } from "../../../MatrixClientPeg";
import MatrixClientContext from "../../../contexts/MatrixClientContext"; import MatrixClientContext from "../../../contexts/MatrixClientContext";
import { usePermalink } from "../../../hooks/usePermalink"; import { usePermalink } from "../../../hooks/usePermalink";
import RoomAvatar from "../avatars/RoomAvatar"; import RoomAvatar from "../avatars/RoomAvatar";
@@ -27,6 +28,14 @@ export enum PillType {
Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule Keyword = "TYPE_KEYWORD", // Used to highlight keywords that triggered a notification rule
} }
export const pillRoomNotifPos = (text: string | null): number => {
return text?.indexOf("@room") ?? -1;
};
export const pillRoomNotifLen = (): number => {
return "@room".length;
};
const linkIcon = <LinkIcon className="mx_Pill_LinkIcon mx_BaseAvatar" />; const linkIcon = <LinkIcon className="mx_Pill_LinkIcon mx_BaseAvatar" />;
const PillRoomAvatar: React.FC<{ const PillRoomAvatar: React.FC<{
@@ -80,7 +89,6 @@ export const Pill: React.FC<PillProps> = ({
shouldShowPillAvatar = true, shouldShowPillAvatar = true,
text: customPillText, text: customPillText,
}) => { }) => {
const cli = useContext(MatrixClientContext);
const { const {
event, event,
member, member,
@@ -105,7 +113,7 @@ export const Pill: React.FC<PillProps> = ({
mx_RoomPill: type === PillType.RoomMention, mx_RoomPill: type === PillType.RoomMention,
mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(), mx_SpacePill: type === "space" || targetRoom?.isSpaceRoom(),
mx_UserPill: type === PillType.UserMention, mx_UserPill: type === PillType.UserMention,
mx_UserPill_me: resourceId === cli.getUserId(), mx_UserPill_me: resourceId === MatrixClientPeg.safeGet().getUserId(),
mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom, mx_EventPill: type === PillType.EventInOtherRoom || type === PillType.EventInSameRoom,
mx_KeywordPill: type === PillType.Keyword, mx_KeywordPill: type === PillType.Keyword,
}); });
@@ -152,24 +160,26 @@ export const Pill: React.FC<PillProps> = ({
const isAnchor = !!inMessage && !!url; const isAnchor = !!inMessage && !!url;
return ( return (
<bdi> <bdi>
<Tooltip <MatrixClientContext.Provider value={MatrixClientPeg.safeGet()}>
description={resourceId ?? ""} <Tooltip
open={resourceId ? undefined : false} description={resourceId ?? ""}
placement="right" open={resourceId ? undefined : false}
isTriggerInteractive={isAnchor} placement="right"
> isTriggerInteractive={isAnchor}
{isAnchor ? ( >
<a className={classes} href={url} onClick={onClick}> {isAnchor ? (
{avatar} <a className={classes} href={url} onClick={onClick}>
<span className="mx_Pill_text">{pillText}</span> {avatar}
</a> <span className="mx_Pill_text">{pillText}</span>
) : ( </a>
<span className={classes}> ) : (
{avatar} <span className={classes}>
<span className="mx_Pill_text">{pillText}</span> {avatar}
</span> <span className="mx_Pill_text">{pillText}</span>
)} </span>
</Tooltip> )}
</Tooltip>
</MatrixClientContext.Provider>
</bdi> </bdi>
); );
}; };

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