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
124 changed files with 1993 additions and 2331 deletions

View File

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

View File

@@ -26,6 +26,12 @@ jobs:
R2_URL: ${{ vars.CF_R2_S3_API }}
R2_PUBLIC_URL: "https://element-web-develop.element.io"
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/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
# 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.
# Checksum algorithm specified as per https://developers.cloudflare.com/r2/examples/aws/aws-cli/
- name: Deploy to R2
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 _deploy/ s3://$R2_BUCKET/ --recursive --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
env:
AWS_ACCESS_KEY_ID: ${{ secrets.CF_R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.CF_R2_TOKEN }}

View File

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

View File

@@ -104,7 +104,7 @@ jobs:
- name: Skip SonarCloud in merge queue
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:
authToken: ${{ secrets.GITHUB_TOKEN }}
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"
},
"resolutions": {
"@playwright/test": "1.51.1",
"@playwright/test": "1.50.1",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"oidc-client-ts": "3.2.0",
"oidc-client-ts": "3.1.0",
"jwt-decode": "4.0.0",
"caniuse-lite": "1.0.30001704",
"testcontainers": "10.21.0",
"caniuse-lite": "1.0.30001701",
"testcontainers": "10.20.0",
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
},
@@ -274,7 +274,7 @@
"postcss-preset-env": "^10.0.0",
"postcss-scss": "^4.0.4",
"postcss-simple-vars": "^7.0.1",
"prettier": "3.5.3",
"prettier": "3.5.2",
"process": "^0.11.10",
"raw-loader": "^4.0.2",
"rimraf": "^6.0.0",

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 }) => {
// We need to wait for there to be two toasts as the wait below won't work in isolation:
// 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.
// Need to wait for 2 to appear since playwright only evaluates 'first()' initially, so the waiting won't work
await expect(page.getByRole("alert")).toHaveCount(2);
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();
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()) {
await useSecurityKey.click();
}

View File

@@ -18,6 +18,14 @@ test.describe("Room list filters and sort", () => {
labsFlags: ["feature_new_room_list"],
});
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
}
function getPrimaryFilters(page: Page) {
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 }) => {
// The notification toast is displayed above the search section
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", () => {
/**
* Get the room list
* @param page
*/
function getRoomList(page: Page) {
return page.getByTestId("room-list");
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");
test.beforeEach(async ({ page, app, bot, user }) => {
await app.client.createRoom({ name: "empty room" });
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");
const unReadDmId = await bot.createRoom({
name: "unread dm",
invite: [user.userId],
is_direct: true,
});
await bot.sendMessage(unReadDmId, "I am a robot. Beep.");
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);
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.");
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);
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("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`);
},
);
});
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);
});
});

View File

@@ -77,20 +77,4 @@ test.describe("Room list", () => {
await page.getByRole("menuitem", { name: "leave room" }).click();
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();
});
});

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 { GenericContainer, type StartedTestContainer, Wait } from "testcontainers";
import { test as base, expect } from "../../element-web-test";
import type { ElementAppPage } from "../../pages/ElementAppPage";
import type { Bot } from "../../pages/bot";
const test = base.extend<{
slidingSyncProxy: StartedTestContainer;
testRoom: { roomId: string; name: string };
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) => {
const name = "Test Room";
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
test.beforeEach(({ user }) => {});
@@ -164,7 +188,15 @@ test.describe("Sliding Sync", () => {
).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
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
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 }) => {
@@ -187,37 +221,6 @@ test.describe("Sliding Sync", () => {
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 ({
page,
app,
@@ -358,4 +361,52 @@ test.describe("Sliding Sync", () => {
// ensure the reply-to does not disappear
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
});
});

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

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";
const TAG = "develop@sha256:4285f51332a658ba6d4871b04d33f49261e6118e751d70fd2894aca97bd587c3";
const TAG = "develop@sha256:2ea87d45fc7ff3327c671b3b4447e6b2032d4f5ca07d62d8aef0d900e105c2f4";
/**
* SynapseContainer which freezes the docker digest to stabilise tests,

View File

@@ -270,7 +270,6 @@
@import "./views/right_panel/_VerificationPanel.pcss";
@import "./views/right_panel/_WidgetCard.pcss";
@import "./views/room_settings/_AliasSettings.pcss";
@import "./views/rooms/RoomListPanel/_EmptyRoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomList.pcss";
@import "./views/rooms/RoomListPanel/_RoomListHeaderView.pcss";
@import "./views/rooms/RoomListPanel/_RoomListItemMenuView.pcss";

View File

@@ -119,9 +119,6 @@ Please see LICENSE files in the repository root for full details.
word-break: break-all;
text-overflow: ellipsis;
/* Don't spill over the container */
width: 90%;
/* E2E icon wrapper */
.mx_Flex > span {
display: inline-block;
@@ -130,15 +127,11 @@ Please see LICENSE files in the repository root for full details.
.mx_UserInfo_profile_name {
height: 30px;
text-wrap: nowrap;
}
.mx_UserInfo_profile_mxid {
color: var(--cpd-color-text-secondary);
height: 28px;
max-width: 100%;
/* MXIDs are one long "word" */
word-break: break-all;
}
.mx_UserInfo_profileStatus {

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 {
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 {
background-color: var(--cpd-color-bg-action-secondary-hovered);
}
.mx_RoomListItemView_selected {
background-color: var(--cpd-color-bg-action-secondary-pressed);
}

View File

@@ -299,12 +299,6 @@ class MatrixClientPegClass implements IMatrixClientPeg {
opts.threadSupport = true;
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);
} else {
SlidingSyncManager.instance.checkSupport(this.matrixClient);

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.
*/
import React from "react";
import * as React from "react";
import {
ContentHelpers,
Direction,

View File

@@ -36,51 +36,45 @@ Please see LICENSE files in the repository root for full details.
* 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 {
type MSC3575Filter,
type MSC3575List,
type MSC3575SlidingSyncResponse,
MSC3575_STATE_KEY_LAZY,
MSC3575_STATE_KEY_ME,
MSC3575_WILDCARD,
SlidingSync,
SlidingSyncEvent,
SlidingSyncState,
} from "matrix-js-sdk/src/sliding-sync";
import { logger } from "matrix-js-sdk/src/logger";
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
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
const DEFAULT_ROOM_SUBSCRIPTION_INFO = {
timeline_limit: 50,
// missing required_state which will change depending on the kind of room
include_old_rooms: {
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
const UNENCRYPTED_SUBSCRIPTION_NAME = "unencrypted";
const UNENCRYPTED_SUBSCRIPTION = {
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_LAZY], // ...and lazy load the rest.
],
@@ -96,72 +90,6 @@ const ENCRYPTED_SUBSCRIPTION = {
...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 = {
filters?: MSC3575Filter;
sort?: string[];
@@ -175,8 +103,6 @@ export type PartialSlidingSyncRequest = {
* sync options and code.
*/
export class SlidingSyncManager {
public static serverSupportsSlidingSync: boolean;
public static readonly ListSpaces = "space_list";
public static readonly ListSearch = "search_list";
private static readonly internalInstance = new SlidingSyncManager();
@@ -190,17 +116,48 @@ export class SlidingSyncManager {
return SlidingSyncManager.internalInstance;
}
private configure(client: MatrixClient, proxyUrl: string): SlidingSync {
public configure(client: MatrixClient, proxyUrl: string): SlidingSync {
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
// 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);
// 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();
return this.slidingSync;
}
@@ -263,113 +220,99 @@ export class SlidingSyncManager {
return this.slidingSync!.getListParams(listKey)!;
}
/**
* 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> {
public async setRoomVisible(roomId: string, visible: boolean): Promise<string> {
await this.configureDefer.promise;
const subscriptions = this.slidingSync!.getRoomSubscriptions();
if (subscriptions.has(roomId)) return;
subscriptions.add(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));
if (visible) {
subscriptions.add(roomId);
} else {
subscriptions.delete(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) {
// lazy load this room
this.slidingSync!.useCustomSubscription(roomId, UNENCRYPTED_SUBSCRIPTION_NAME);
}
this.slidingSync!.modifyRoomSubscriptions(subscriptions);
const p = this.slidingSync!.modifyRoomSubscriptions(subscriptions);
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.
return new Promise((resolve) => {
logger.log(`SlidingSync setRoomVisible room ${roomId} not found, waiting for ClientEvent.Room`);
const waitForRoom = (r: Room): void => {
if (r.roomId === roomId) {
this.client?.off(ClientEvent.Room, waitForRoom);
logger.log(`SlidingSync room ${roomId} found, resolving setRoomVisible`);
resolve();
}
};
this.client?.on(ClientEvent.Room, waitForRoom);
});
try {
// wait until the next sync before returning as RoomView may need to know the current state
await p;
} catch {
logger.warn("SlidingSync setRoomVisible:", roomId, visible, "failed to confirm transaction");
}
return roomId;
}
/**
* Retrieve all rooms on the user's account. Retrieval is gradual over time.
* This function MUST be called BEFORE the first sync request goes out.
* Retrieve all rooms on the user's account. Used for pre-populating the local search cache.
* Retrieval is gradual over time.
* @param batchSize The number of rooms to return in each request.
* @param gapBetweenRequestsMs The number of milliseconds to wait between requests.
*/
private async startSpidering(
slidingSync: SlidingSync,
batchSize: number,
gapBetweenRequestsMs: number,
): Promise<void> {
// The manager has created several lists (see `sssLists` in this file), all of which will be spidered simultaneously.
// There are multiple lists to ensure that we can populate invites/favourites/DMs sections immediately, rather than
// potentially waiting minutes if they are all very old rooms (and hence are returned last by the server). In this
// way, the lists are effectively priority requests. We don't actually care which room goes into which list at this
// point, as the RoomListStore will calculate this based on the returned data.
// copy the initial set of list names and ranges, we'll keep this map updated.
const listToUpperBound = new Map(
Object.keys(sssLists).map((listName) => {
return [listName, sssLists[listName].ranges[0][1]];
}),
);
console.log("startSpidering:", listToUpperBound);
// 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 :/
// but in that case, for spidering at least, it isn't the end of the world as request N+1 includes all indexes
// from request N.
const lifecycle = async (
state: SlidingSyncState,
_: MSC3575SlidingSyncResponse | null,
err?: Error,
): Promise<void> => {
if (state !== SlidingSyncState.Complete) {
return;
}
await sleep(gapBetweenRequestsMs); // don't tightloop; even on errors
if (err) {
return;
}
// for all lists with total counts > range => increase the range
let hasSetRanges = false;
listToUpperBound.forEach((currentUpperBound, listName) => {
const totalCount = slidingSync.getListData(listName)?.joinedCount || 0;
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;
public async startSpidering(batchSize: number, gapBetweenRequestsMs: number): Promise<void> {
await sleep(gapBetweenRequestsMs); // wait a bit as this is called on first render so let's let things load
let startIndex = batchSize;
let hasMore = true;
let firstTime = true;
while (hasMore) {
const endIndex = startIndex + batchSize - 1;
try {
const ranges = [
[0, batchSize - 1],
[startIndex, endIndex],
];
if (firstTime) {
await this.slidingSync!.setList(SlidingSyncManager.ListSearch, {
// 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,
sort: [
"by_recency", // this list isn't shown on the UI so just sorting by timestamp is enough
],
timeline_limit: 0, // we only care about the room details, not messages in the room
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.RoomMember, MSC3575_STATE_KEY_ME], // lets the client calculate that we are in fact in the room
],
// 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
// e.g membership of space, but this will be corrected when the user clicks on the room
// as the direct room subscription does include old room iterations.
filters: {
// we get spaces via a different list, so filter them out
not_room_types: ["m.space"],
},
});
} else {
await this.slidingSync!.setListRanges(SlidingSyncManager.ListSearch, ranges);
}
});
if (!hasSetRanges) {
// finish spidering
slidingSync.off(SlidingSyncEvent.Lifecycle, lifecycle);
} catch {
// do nothing, as we reject only when we get interrupted but that's fine as the next
// request will include our data
} finally {
// gradually request more over time, even on errors.
await sleep(gapBetweenRequestsMs);
}
};
slidingSync.on(SlidingSyncEvent.Lifecycle, lifecycle);
const listData = this.slidingSync!.getListData(SlidingSyncManager.ListSearch)!;
hasMore = endIndex + 1 < listData.joinedCount;
startIndex += batchSize;
firstTime = false;
}
}
/**
@@ -382,10 +325,42 @@ export class SlidingSyncManager {
* @returns A working Sliding Sync or undefined
*/
public async setup(client: MatrixClient): Promise<SlidingSync | undefined> {
const slidingSync = this.configure(client, client.baseUrl);
logger.info("Simplified Sliding Sync activated at", client.baseUrl);
this.startSpidering(slidingSync, 50, 50); // 50 rooms at a time, 50ms apart
return slidingSync;
const baseUrl = client.baseUrl;
const proxyUrl = SettingsStore.getValue("feature_sliding_sync_proxy_url");
const wellKnownProxyUrl = await this.getProxyFromWellKnown(client);
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> {
// Per https://github.com/matrix-org/matrix-spec-proposals/pull/3575/files#r1589542561
// `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) {
logger.log("nativeSlidingSyncSupport: org.matrix.simplified_msc3575 sliding sync advertised as unstable");
logger.log("nativeSlidingSyncSupport: sliding sync advertised as unstable");
}
return support;
}
@@ -412,9 +387,20 @@ export class SlidingSyncManager {
*/
public async checkSupport(client: MatrixClient): Promise<void> {
if (await this.nativeSlidingSyncSupport(client)) {
SlidingSyncManager.serverSupportsSlidingSync = true;
SlidingSyncController.serverSupportsSlidingSync = true;
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 { haveRendererForEvent } from "./events/EventTileFactory";
import SettingsStore from "./settings/SettingsStore";
import { RoomNotifState, getRoomNotifsState } from "./RoomNotifs";
/**
@@ -43,6 +44,12 @@ export function eventTriggersUnreadCount(client: MatrixClient, ev: MatrixEvent):
}
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];
if (includeThreads) {
toCheck.push(...room.getThreads());

View File

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

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.
public grouperKeyMap = new WeakMap<MatrixEvent, string>();
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
// 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.
*/
import React from "react";
import * as React from "react";
import { type EmptyObject } from "matrix-js-sdk/src/matrix";
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>();
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
narrow: false,

View File

@@ -64,10 +64,8 @@ export default class RightPanel extends React.Component<Props, IState> {
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: Props) {
super(props);
this.state = RightPanel.getDerivedStateFromProps(props);
public constructor(props: Props, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
}
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 React from "react";
import * as React from "react";
import { ALTERNATE_KEY_NAME } from "../../accessibility/KeyboardShortcuts";
import defaultDispatcher from "../../dispatcher/dispatcher";

View File

@@ -86,8 +86,8 @@ export default class ThreadView extends React.Component<IProps, IState> {
// Set by setEventId in ctor.
private eventId!: string;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.setEventId(this.props.mxEvent);
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.
*/
import React from "react";
import * as React from "react";
import classNames from "classnames";
import { Text } from "@vector-im/compound-web";
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 buttonRef: React.RefObject<HTMLButtonElement> = createRef();
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
this.state = {
contextMenuPosition: null,
@@ -370,13 +370,6 @@ export default class UserMenu extends React.Component<IProps, IState> {
? toRightOf(this.state.contextMenuPosition)
: below(this.state.contextMenuPosition);
const userIdentifierString = UserIdentifierCustomisations.getDisplayUserIdentifier(
MatrixClientPeg.safeGet().getSafeUserId(),
{
withDisplayName: true,
},
);
return (
<IconizedContextMenu {...position} onFinished={this.onCloseMenu} className="mx_UserMenu_contextMenu">
<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">
{OwnProfileStore.instance.displayName}
</span>
<span className="mx_UserMenu_contextMenu_userId" title={userIdentifierString || ""}>
{userIdentifierString}
<span className="mx_UserMenu_contextMenu_userId">
{UserIdentifierCustomisations.getDisplayUserIdentifier(
MatrixClientPeg.safeGet().getSafeUserId(),
{
withDisplayName: true,
},
)}
</span>
</div>

View File

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

View File

@@ -66,8 +66,8 @@ export default class SoftLogout extends React.Component<IProps, IState> {
public static contextType = SDKContext;
declare public context: React.ContextType<typeof SDKContext>;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof SDKContext>) {
super(props, context);
this.state = {
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 { 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 defaultDispatcher from "../../../dispatcher/dispatcher";
import PosthogTrackers from "../../../PosthogTrackers";
@@ -30,7 +32,6 @@ import {
} from "../../../utils/space";
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
import { createRoom, hasCreateRoomRights } from "./utils";
/**
* Hook to get the active space and its title.
@@ -127,7 +128,14 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
const { activeSpace, title } = useSpace();
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 displayComposeMenu = canCreateRoom || canCreateVideoRoom;
const displaySpaceMenu = isSpaceRoom;
@@ -143,9 +151,13 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateChatItem", e);
}, []);
const createRoomMemoized = useCallback(
const createRoom = useCallback(
(e: Event) => {
createRoom(activeSpace);
if (activeSpace) {
showCreateNewRoom(activeSpace);
} else {
defaultDispatcher.fire(Action.CreateRoom);
}
PosthogTrackers.trackInteraction("WebRoomListHeaderPlusMenuCreateRoomItem", e);
},
[activeSpace],
@@ -201,7 +213,7 @@ export function useRoomListHeaderViewModel(): RoomListHeaderViewState {
canInviteInSpace,
canAccessSpaceSettings,
createChatRoom,
createRoom: createRoomMemoized,
createRoom,
createVideoRoom,
openSpaceHome,
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.
*/
import { useCallback } from "react";
import type { Room } from "matrix-js-sdk/src/matrix";
import { type PrimaryFilter, type SecondaryFilters, useFilteredRooms } from "./useFilteredRooms";
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 { useIndexForActiveRoom } from "./useIndexForActiveRoom";
export interface RoomListViewState {
/**
* A list of rooms to be displayed in the left panel.
*/
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
* to render primary room filters.
*/
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.
*/
@@ -74,21 +39,6 @@ export interface RoomListViewState {
* The currently active sort option.
*/
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,37 +46,15 @@ export interface RoomListViewState {
* @see {@link RoomListViewState} for more information about what this view model returns.
*/
export function useRoomListViewModel(): RoomListViewState {
const matrixClient = useMatrixClientContext();
const { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter } =
useFilteredRooms();
const currentSpace = useEventEmitterState<Room | null>(
SpaceStore.instance,
UPDATE_SELECTED_SPACE,
() => SpaceStore.instance.activeSpaceRoom,
);
const canCreateRoom = hasCreateRoomRights(matrixClient, currentSpace);
const activeIndex = useIndexForActiveRoom(rooms);
const { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter } = useFilteredRooms();
const { activeSortOption, sort } = useSorter();
const { shouldShowMessagePreview, toggleMessagePreview } = useMessagePreviewToggle();
const createChatRoom = useCallback(() => dispatcher.fire(Action.CreateChat), []);
const createRoom = useCallback(() => createRoomFunc(currentSpace), [currentSpace]);
return {
rooms,
canCreateRoom,
createRoom,
createChatRoom,
primaryFilters,
activePrimaryFilter,
activateSecondaryFilter,
activeSecondaryFilter,
activeSortOption,
sort,
shouldShowMessagePreview,
toggleMessagePreview,
activeIndex,
};
}

View File

@@ -27,8 +27,6 @@ export interface PrimaryFilter {
active: boolean;
// Text that can be used in the UI to represent this filter.
name: string;
// The key of the filter
key: FilterKey;
}
interface FilteredRooms {
@@ -36,11 +34,6 @@ interface FilteredRooms {
rooms: Room[];
activateSecondaryFilter: (filter: SecondaryFilters) => void;
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([
@@ -179,7 +172,6 @@ export function useFilteredRooms(): FilteredRooms {
},
active: primaryFilter === key,
name,
key,
};
};
const filters: PrimaryFilter[] = [];
@@ -192,7 +184,5 @@ export function useFilteredRooms(): FilteredRooms {
return filters;
}, [primaryFilter, updateRoomsFromStore, secondaryFilter]);
const activePrimaryFilter = useMemo(() => primaryFilters.find((filter) => filter.active), [primaryFilters]);
return { primaryFilters, activePrimaryFilter, rooms, activateSecondaryFilter, activeSecondaryFilter };
return { primaryFilters, rooms, activateSecondaryFilter, activeSecondaryFilter };
}

View File

@@ -1,44 +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";
/**
* Tracks the index of the active room in the given array of rooms.
* @param rooms list of rooms
* @returns index of the active room or undefined otherwise.
*/
export function useIndexForActiveRoom(rooms: Room[]): number | undefined {
const [index, setIndex] = useState<number | undefined>(undefined);
const calculateIndex = useCallback(
(newRoomId?: string) => {
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
const index = rooms.findIndex((room) => room.roomId === activeRoomId);
setIndex(index === -1 ? undefined : index);
},
[rooms],
);
// Re-calculate the index when the active room has changed.
useDispatcher(dispatcher, (payload) => {
if (payload.action === Action.ActiveRoomChanged) calculateIndex(payload.newRoomId);
});
// Re-calculate the index when the list of rooms has changed.
useEffect(() => {
calculateIndex();
}, [calculateIndex, rooms]);
return index;
}

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

@@ -5,14 +5,11 @@
* 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 { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
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.
@@ -26,33 +23,3 @@ export function hasAccessToOptionsMenu(room: Room): boolean {
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.
*/
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 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
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
canRedact: false,

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.
*/
import React from "react";
import * as React from "react";
import {
ClientWidgetApi,
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.
*/
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 type Field from "../elements/Field";

View File

@@ -50,11 +50,6 @@ import { EncryptionUserSettingsTab } from "../settings/tabs/user/EncryptionUserS
interface IProps {
initialTabId?: UserTab;
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;
sdkContext: SdkContextClass;
onFinished(): void;
@@ -97,7 +92,7 @@ function titleForTabID(tabId: UserTab): React.ReactNode {
export default function UserSettingsDialog(props: IProps): JSX.Element {
const voipEnabled = useSettingValue(UIFeature.Voip);
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 [showResetIdentity, setShowResetIdentity] = useState(props.showResetIdentity);

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.
*/
import React from "react";
import * as React from "react";
import { _t } from "../../../languageHandler";
import AccessibleButton, { type ButtonEvent } from "./AccessibleButton";

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.
*/
import React from "react";
import * as React from "react";
import { toDataURL, type QRCodeSegment, type QRCodeToDataURLOptions, type QRCodeRenderersOptions } from "qrcode";
import classNames from "classnames";

View File

@@ -71,8 +71,8 @@ export default class ReplyChain extends React.Component<IProps, IState> {
private room: Room;
private blockquoteRef = React.createRef<HTMLQuoteElement>();
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
events: [],

View File

@@ -37,8 +37,8 @@ export default class RoomAliasField extends React.PureComponent<IProps, IState>
private fieldRef = createRef<Field>();
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
isValid: true,

View File

@@ -31,8 +31,8 @@ class ReactionPicker extends React.Component<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
selectedEmojis: new Set(Object.keys(this.getReactions())),

View File

@@ -47,8 +47,8 @@ class LocationPicker extends React.Component<ILocationPickerProps, IState> {
private geolocate?: maplibregl.GeolocateControl;
private marker?: maplibregl.Marker;
public constructor(props: ILocationPickerProps) {
super(props);
public constructor(props: ILocationPickerProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
position: undefined,

View File

@@ -36,8 +36,8 @@ export default class MLocationBody extends React.Component<IBodyProps, IState> {
private mapId: string;
private reconnectedListener: ClientEventHandlerMap[ClientEvent.Sync];
public constructor(props: IBodyProps) {
super(props);
public constructor(props: IBodyProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
// multiple instances of same map might be in document
// eg thread and main timeline, reply

View File

@@ -142,8 +142,8 @@ export default class MPollBody extends React.Component<IBodyProps, IState> {
declare public context: React.ContextType<typeof MatrixClientContext>;
private seenEventIds: string[] = []; // Events we have already seen
public constructor(props: IBodyProps) {
super(props);
public constructor(props: IBodyProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
selected: null,

View File

@@ -27,6 +27,7 @@ import UnknownBody from "./UnknownBody";
import { type IMediaBody } from "./IMediaBody";
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
import { type IBodyProps } from "./IBodyProps";
import MatrixClientContext from "../../../contexts/MatrixClientContext";
import TextualBody from "./TextualBody";
import MImageBody from "./MImageBody";
import MFileBody from "./MFileBody";
@@ -82,8 +83,11 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
private bodyTypes = new Map<string, React.ComponentType<IBodyProps>>(baseBodyTypes.entries());
private evTypes = new Map<string, React.ComponentType<IBodyProps>>(baseEvTypes.entries());
public constructor(props: IProps) {
super(props);
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
if (MediaEventHelper.isEligible(this.props.mxEvent)) {
this.mediaHelper = new MediaEventHelper(this.props.mxEvent);

View File

@@ -77,8 +77,8 @@ export default class TimelineCard extends React.Component<IProps, IState> {
private card = React.createRef<HTMLDivElement>();
private readReceiptsSettingWatcher: string | undefined;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
showReadReceipts: SettingsStore.getValue("showReadReceipts", props.room.roomId),
layout: SettingsStore.getValue("layout"),

View File

@@ -1409,30 +1409,24 @@ export const UserInfoHeader: React.FC<{
<Flex direction="column" align="center" className="mx_UserInfo_profile">
<Heading size="sm" weight="semibold" as="h1" dir="auto">
<Flex className="mx_UserInfo_profile_name" direction="row-reverse" align="center">
<Tooltip isTriggerInteractive={true} placement="left" label={displayName}>
<span>
{displayName}
</span>
</Tooltip>
{displayName}
</Flex>
</Heading>
{presenceLabel}
{timezoneInfo && (
<Flex align="center" className="mx_UserInfo_timezone">
<Text size="sm" weight="regular">
<Tooltip label={timezoneInfo?.timezone ?? ""}>
<span>{timezoneInfo?.friendly ?? ""}</span>
</Tooltip>
</Text>
</Flex>
)}
{userIdentifier && (
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => userIdentifier} border={false}>
{userIdentifier}
</CopyableText>
</Text>
<Tooltip label={timezoneInfo?.timezone ?? ""}>
<Flex align="center" className="mx_UserInfo_timezone">
<Text size="sm" weight="regular">
{timezoneInfo?.friendly ?? ""}
</Text>
</Flex>
</Tooltip>
)}
<Text size="sm" weight="semibold" className="mx_UserInfo_profile_mxid">
<CopyableText getTextToCopy={() => userIdentifier} border={false}>
{userIdentifier}
</CopyableText>
</Text>
</Flex>
{!hideVerificationSection && <VerificationSection member={member} devices={devices} />}
</Container>

View File

@@ -101,8 +101,8 @@ export default class AliasSettings extends React.Component<IProps, IState> {
canSetCanonicalAlias: false,
};
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
super(props, context);
const state: IState = {
altAliases: [],

View File

@@ -55,8 +55,8 @@ export default class Autocomplete extends React.PureComponent<IProps, IState> {
public static contextType = RoomContext;
declare public context: React.ContextType<typeof RoomContext>;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {
// list of completionResults, each containing completions

View File

@@ -437,8 +437,8 @@ export default class LegacyRoomList extends React.PureComponent<IProps, IState>
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
sublists: {},

View File

@@ -124,8 +124,9 @@ export class MessageComposer extends React.Component<IProps, IState> {
isRichTextEnabled: true,
};
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.context = context; // otherwise React will only set it prior to render due to type def above
const isWysiwygLabEnabled = SettingsStore.getValue("feature_wysiwyg_composer");
let isRichTextEnabled = true;

View File

@@ -1,149 +0,0 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React, { type JSX, type PropsWithChildren } from "react";
import { Button } from "@vector-im/compound-web";
import UserAddIcon from "@vector-im/compound-design-tokens/assets/web/icons/user-add";
import RoomIcon from "@vector-im/compound-design-tokens/assets/web/icons/room";
import type { RoomListViewState } from "../../../viewmodels/roomlist/RoomListViewModel";
import { Flex } from "../../../utils/Flex";
import { _t } from "../../../../languageHandler";
import { FilterKey } from "../../../../stores/room-list-v3/skip-list/filters";
import { type PrimaryFilter } from "../../../viewmodels/roomlist/useFilteredRooms";
interface EmptyRoomListProps {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The empty state for the room list
*/
export function EmptyRoomList({ vm }: EmptyRoomListProps): JSX.Element | undefined {
// If there is no active primary filter, show the default empty state
if (!vm.activePrimaryFilter) return <DefaultPlaceholder vm={vm} />;
switch (vm.activePrimaryFilter.key) {
case FilterKey.FavouriteFilter:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_favourites")}
description={_t("room_list|empty|no_favourites_description")}
/>
);
case FilterKey.PeopleFilter:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_people")}
description={_t("room_list|empty|no_people_description")}
/>
);
case FilterKey.RoomsFilter:
return (
<GenericPlaceholder
title={_t("room_list|empty|no_rooms")}
description={_t("room_list|empty|no_rooms_description")}
/>
);
case FilterKey.UnreadFilter:
return <UnreadPlaceholder filter={vm.activePrimaryFilter} />;
default:
return undefined;
}
}
interface GenericPlaceholderProps {
/**
* The title of the placeholder
*/
title: string;
/**
* The description of the placeholder
*/
description?: string;
}
/**
* A generic placeholder for the room list
*/
function GenericPlaceholder({ title, description, children }: PropsWithChildren<GenericPlaceholderProps>): JSX.Element {
return (
<Flex
data-testid="empty-room-list"
className="mx_EmptyRoomList_GenericPlaceholder"
direction="column"
align="stretch"
justify="center"
gap="var(--cpd-space-2x)"
>
<span className="mx_EmptyRoomList_GenericPlaceholder_title">{title}</span>
{description && <span className="mx_EmptyRoomList_GenericPlaceholder_description">{description}</span>}
{children}
</Flex>
);
}
interface DefaultPlaceholderProps {
/**
* The view model for the room list
*/
vm: RoomListViewState;
}
/**
* The default empty state for the room list when no primary filter is active
* The user can create chat or room (if they have the permission)
*/
function DefaultPlaceholder({ vm }: DefaultPlaceholderProps): JSX.Element {
return (
<GenericPlaceholder
title={_t("room_list|empty|no_chats")}
description={
vm.canCreateRoom
? _t("room_list|empty|no_chats_description")
: _t("room_list|empty|no_chats_description_no_room_rights")
}
>
<Flex
className="mx_EmptyRoomList_DefaultPlaceholder"
align="center"
justify="center"
direction="column"
gap="var(--cpd-space-4x)"
>
<Button size="sm" kind="secondary" Icon={UserAddIcon} onClick={vm.createChatRoom}>
{_t("action|new_message")}
</Button>
{vm.canCreateRoom && (
<Button size="sm" kind="secondary" Icon={RoomIcon} onClick={vm.createRoom}>
{_t("action|new_room")}
</Button>
)}
</Flex>
</GenericPlaceholder>
);
}
interface UnreadPlaceholderProps {
filter: PrimaryFilter;
}
/**
* The empty state for the room list when the unread filter is active
*/
function UnreadPlaceholder({ filter }: UnreadPlaceholderProps): JSX.Element {
return (
<GenericPlaceholder title={_t("room_list|empty|no_unread")}>
<Button kind="tertiary" onClick={filter.toggle}>
{_t("room_list|empty|show_chats")}
</Button>
</GenericPlaceholder>
);
}

View File

@@ -22,12 +22,10 @@ interface RoomListProps {
/**
* A virtualized list of rooms.
*/
export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Element {
export function RoomList({ vm: { rooms } }: RoomListProps): JSX.Element {
const roomRendererMemoized = useCallback(
({ key, index, style }: ListRowProps) => (
<RoomListItemView room={rooms[index]} key={key} style={style} isSelected={activeIndex === index} />
),
[rooms, activeIndex],
({ key, index, style }: ListRowProps) => <RoomListItemView room={rooms[index]} key={key} style={style} />,
[rooms],
);
// The first div is needed to make the virtualized list take all the remaining space and scroll correctly
@@ -43,7 +41,6 @@ export function RoomList({ vm: { rooms, activeIndex } }: RoomListProps): JSX.Ele
rowHeight={48}
height={height}
width={width}
scrollToIndex={activeIndex}
/>
)}
</AutoSizer>

View File

@@ -20,16 +20,12 @@ interface RoomListItemViewPropsProps extends React.HTMLAttributes<HTMLButtonElem
* The room to display
*/
room: Room;
/**
* Whether the room is selected
*/
isSelected: boolean;
}
/**
* An item in the room list
*/
export function RoomListItemView({ room, isSelected, ...props }: RoomListItemViewPropsProps): JSX.Element {
export function RoomListItemView({ room, ...props }: RoomListItemViewPropsProps): JSX.Element {
const vm = useRoomListItemViewModel(room);
const [isHover, setIsHover] = useState(false);
@@ -42,10 +38,8 @@ export function RoomListItemView({ room, isSelected, ...props }: RoomListItemVie
<button
className={classNames("mx_RoomListItemView", {
mx_RoomListItemView_menu_open: showHoverDecoration,
mx_RoomListItemView_selected: isSelected,
})}
type="button"
aria-selected={isSelected}
aria-label={_t("room_list|room|open_room", { roomName: room.name })}
onClick={() => vm.openRoom()}
onMouseOver={() => setIsHover(true)}

View File

@@ -9,7 +9,6 @@ import React, { type JSX } from "react";
import { useRoomListViewModel } from "../../../viewmodels/roomlist/RoomListViewModel";
import { RoomList } from "./RoomList";
import { EmptyRoomList } from "./EmptyRoomList";
import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
/**
@@ -17,12 +16,10 @@ import { RoomListPrimaryFilters } from "./RoomListPrimaryFilters";
*/
export function RoomListView(): JSX.Element {
const vm = useRoomListViewModel();
const isRoomListEmpty = vm.rooms.length === 0;
return (
<>
<RoomListPrimaryFilters vm={vm} />
{isRoomListEmpty ? <EmptyRoomList vm={vm} /> : <RoomList vm={vm} />}
<RoomList vm={vm} />
</>
);
}

View File

@@ -42,6 +42,8 @@ import ContextMenu, {
} from "../../structures/ContextMenu";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import type ExtraTile from "./ExtraTile";
import SettingsStore from "../../../settings/SettingsStore";
import { SlidingSyncManager } from "../../../SlidingSyncManager";
import NotificationBadge from "./NotificationBadge";
import RoomTile from "./RoomTile";
@@ -104,8 +106,12 @@ export default class RoomSublist extends React.Component<IProps, IState> {
private heightAtStart: number;
private notificationState: ListNotificationState;
private slidingSyncMode: boolean;
public constructor(props: IProps) {
super(props);
// when this setting is toggled it restarts the app so it's safe to not watch this.
this.slidingSyncMode = SettingsStore.getValue("feature_sliding_sync");
this.layout = RoomListLayoutStore.instance.getLayoutFor(this.props.tagId);
this.heightAtStart = 0;
@@ -159,6 +165,9 @@ export default class RoomSublist extends React.Component<IProps, IState> {
}
private get numVisibleTiles(): number {
if (this.slidingSyncMode) {
return this.state.rooms.length;
}
const nVisible = Math.ceil(this.layout.visibleTiles);
return Math.min(nVisible, this.numTiles);
}
@@ -320,6 +329,12 @@ export default class RoomSublist extends React.Component<IProps, IState> {
};
private onShowAllClick = async (): Promise<void> => {
if (this.slidingSyncMode) {
const count = RoomListStore.instance.getCount(this.props.tagId);
await SlidingSyncManager.instance.ensureListRegistered(this.props.tagId, {
ranges: [[0, count]],
});
}
// read number of visible tiles before we mutate it
const numVisibleTiles = this.numVisibleTiles;
const newHeight = this.layout.tilesToPixelsWithPadding(this.numTiles, this.padding);
@@ -539,8 +554,13 @@ export default class RoomSublist extends React.Component<IProps, IState> {
let contextMenu: JSX.Element | undefined;
if (this.state.contextMenuPosition) {
const isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
const isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
let isAlphabetical = RoomListStore.instance.getTagSorting(this.props.tagId) === SortAlgorithm.Alphabetic;
let isUnreadFirst = RoomListStore.instance.getListOrder(this.props.tagId) === ListAlgorithm.Importance;
if (this.slidingSyncMode) {
const slidingList = SlidingSyncManager.instance.slidingSync?.getListParams(this.props.tagId);
isAlphabetical = (slidingList?.sort || [])[0] === "by_name";
isUnreadFirst = (slidingList?.sort || [])[0] === "by_notification_level";
}
// Invites don't get some nonsense options, so only add them if we have to.
let otherSections: JSX.Element | undefined;
@@ -743,12 +763,17 @@ export default class RoomSublist extends React.Component<IProps, IState> {
// floats above the resize handle, if we have one present. If the user has all
// tiles visible, it becomes 'show less'.
let showNButton: JSX.Element | undefined;
const hasMoreSlidingSync =
this.slidingSyncMode && RoomListStore.instance.getCount(this.props.tagId) > this.state.rooms.length;
if (maxTilesPx > this.state.height) {
if (maxTilesPx > this.state.height || hasMoreSlidingSync) {
// the height of all the tiles is greater than the section height: we need a 'show more' button
const nonPaddedHeight = this.state.height - RESIZE_HANDLE_HEIGHT - SHOW_N_BUTTON_HEIGHT;
const amountFullyShown = Math.floor(nonPaddedHeight / this.layout.tileHeight);
const numMissing = this.numTiles - amountFullyShown;
let numMissing = this.numTiles - amountFullyShown;
if (this.slidingSyncMode) {
numMissing = RoomListStore.instance.getCount(this.props.tagId) - amountFullyShown;
}
const label = _t("room_list|show_n_more", { count: numMissing });
let showMoreText: ReactNode = <span className="mx_RoomSublist_showNButtonText">{label}</span>;
if (this.props.isMinimized) showMoreText = null;

View File

@@ -27,8 +27,8 @@ export default class RoomUpgradeWarningBar extends React.PureComponent<IProps, I
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
const tombstone = this.props.room.currentState.getStateEvents("m.room.tombstone", "");
this.state = {

View File

@@ -42,8 +42,8 @@ export default class SearchResultTile extends React.Component<IProps> {
// A map of <callId, LegacyCallEventGrouper>
private callEventGroupers = new Map<string, LegacyCallEventGrouper>();
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.buildLegacyCallEventGroupers(this.props.timeline);
}

View File

@@ -56,8 +56,8 @@ export default class VoiceRecordComposerTile extends React.PureComponent<IProps,
declare public context: React.ContextType<typeof RoomContext>;
private voiceRecordingId: string;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof RoomContext>) {
super(props, context);
this.state = {};

View File

@@ -31,10 +31,8 @@ interface ResetIdentityPanelProps {
/**
* The variant of the panel to show. We show more warnings in the 'compromised' variant (no use in showing a user this
* warning if they have to reset because they no longer have their key)
*
* "compromised" is shown when the user chooses 'reset' explicitly in settings, usually because they believe their
* identity has been compromised.
*
* "forgot" is shown when the user has just forgotten their passphrase.
*/
variant: "compromised" | "forgot";

View File

@@ -35,8 +35,8 @@ export default class GeneralRoomSettingsTab extends React.Component<IProps, ISta
public static contextType = MatrixClientContext;
declare public context: ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
isRoomPublished: false, // loaded async

View File

@@ -62,8 +62,8 @@ export default class SecurityRoomSettingsTab extends React.Component<IProps, ISt
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: IProps) {
super(props);
public constructor(props: IProps, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
const state = this.props.room.currentState;

View File

@@ -37,7 +37,7 @@ import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel";
* This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel.
* - "set_recovery_key": The panel to show when the user is setting up their recovery key.
* This happens when the user doesn't have a key a recovery key and the user clicks on "Set up recovery key" button of the RecoveryPanel.
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in the case where their key is compromised.
* - "reset_identity_compromised": The panel to show when the user is resetting their identity, in te case where their key is compromised.
* - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key.
* - "secrets_not_cached": The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets.
* If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails.
@@ -55,9 +55,9 @@ export type State =
| "secrets_not_cached"
| "key_storage_delete";
interface Props {
interface EncryptionUserSettingsTabProps {
/**
* If the tab should start in a state other than the default
* If the tab should start in a state other than the deasult
*/
initialState?: State;
}
@@ -65,7 +65,7 @@ interface Props {
/**
* The encryption settings tab.
*/
export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): JSX.Element {
export function EncryptionUserSettingsTab({ initialState = "loading" }: EncryptionUserSettingsTabProps): JSX.Element {
const [state, setState] = useState<State>(initialState);
const checkEncryptionState = useCheckEncryptionState(state, setState);

View File

@@ -33,8 +33,8 @@ export default class HelpUserSettingsTab extends React.Component<EmptyObject, IS
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: EmptyObject) {
super(props);
public constructor(props: EmptyObject, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
appVersion: null,

View File

@@ -54,8 +54,8 @@ export default class VoiceUserSettingsTab extends React.Component<EmptyObject, I
public static contextType = MatrixClientContext;
declare public context: React.ContextType<typeof MatrixClientContext>;
public constructor(props: EmptyObject) {
super(props);
public constructor(props: EmptyObject, context: React.ContextType<typeof MatrixClientContext>) {
super(props, context);
this.state = {
mediaDevices: null,

View File

@@ -0,0 +1,82 @@
/*
Copyright 2024 New Vector Ltd.
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
Please see LICENSE files in the repository root for full details.
*/
import { useCallback, useState } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import { MatrixClientPeg } from "../MatrixClientPeg";
import { useLatestResult } from "./useLatestResult";
import { SlidingSyncManager } from "../SlidingSyncManager";
export interface SlidingSyncRoomSearchOpts {
limit: number;
query: string;
}
export const useSlidingSyncRoomSearch = (): {
loading: boolean;
rooms: Room[];
search(opts: SlidingSyncRoomSearchOpts): Promise<boolean>;
} => {
const [rooms, setRooms] = useState<Room[]>([]);
const [loading, setLoading] = useState(false);
const [updateQuery, updateResult] = useLatestResult<{ term: string; limit?: number }, Room[]>(setRooms);
const search = useCallback(
async ({ limit = 100, query: term }: SlidingSyncRoomSearchOpts): Promise<boolean> => {
const opts = { limit, term };
updateQuery(opts);
if (!term?.length) {
setRooms([]);
return true;
}
try {
setLoading(true);
await SlidingSyncManager.instance.ensureListRegistered(SlidingSyncManager.ListSearch, {
ranges: [[0, limit]],
filters: {
room_name_like: term,
},
});
const rooms: Room[] = [];
const { roomIndexToRoomId } = SlidingSyncManager.instance.slidingSync!.getListData(
SlidingSyncManager.ListSearch,
)!;
let i = 0;
while (roomIndexToRoomId[i]) {
const roomId = roomIndexToRoomId[i];
const room = MatrixClientPeg.safeGet().getRoom(roomId);
if (room) {
rooms.push(room);
}
i++;
}
updateResult(opts, rooms);
return true;
} catch (e) {
console.error("Could not fetch sliding sync rooms for params", { limit, term }, e);
updateResult(opts, []);
return false;
} finally {
setLoading(false);
// TODO: delete the list?
}
},
[updateQuery, updateResult],
);
return {
loading,
rooms,
search,
} as const;
};

View File

@@ -447,7 +447,6 @@
"access_token": "Tocyn Mynediad",
"accessibility": "Hygyrchedd",
"advanced": "Uwch",
"all_chats": "Pob Sgwrs",
"analytics": "Dadansoddi Gwe",
"appearance": "Gwedd",
"application": "Rhaglen",
@@ -1984,19 +1983,6 @@
"add_space_label": "Ychwanegu gofod",
"breadcrumbs_empty": "Dim ystafelloedd yr ymwelwyd â nhw yn ddiweddar",
"breadcrumbs_label": "Ymwelwyd ag ystafelloedd yn ddiweddar",
"empty": {
"no_chats": "Dim sgyrsiau eto",
"no_chats_description": "Dechreuwch drwy anfon neges at rywun neu drwy greu ystafell",
"no_chats_description_no_room_rights": "Dechreuwch trwy anfon neges at rywun",
"no_favourites": "Nid oes gennych hoff sgwrs eto",
"no_favourites_description": "Gallwch ychwanegu sgwrs at eich ffefrynnau yn y gosodiadau sgwrsio",
"no_people": "Nid oes gennych chi sgyrsiau uniongyrchol gydag unrhyw un eto",
"no_people_description": "Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill",
"no_rooms": "Nid ydych mewn unrhyw ystafell eto",
"no_rooms_description": "Gallwch ddad-ddewis hidlwyr er mwyn gweld eich sgyrsiau eraill",
"no_unread": "Llongyfarchiadau! Nid oes gennych unrhyw negeseuon heb eu darllen",
"show_chats": "Dangos pob sgwrs"
},
"failed_add_tag": "Wedi methu ag ychwanegu tag %(tagName)s i'r ystafell",
"failed_remove_tag": "Wedi methu â thynnu'r tag %(tagName)s o'r ystafell",
"failed_set_dm_tag": "Wedi methu gosod tag neges uniongyrchol",
@@ -2009,19 +1995,10 @@
"home_menu_label": "Dewisiadau cartref",
"join_public_room_label": "Ymuno â'r ystafell gyhoeddus",
"list_title": "Rhestr ystafelloedd",
"more_options": {
"copy_link": "Copïo dolen ystafell",
"favourited": "Ffafrio",
"leave_room": "Gadael yr ystafell",
"low_priority": "Blaenoriaeth isel",
"mark_read": "Marcio fel wedi'i ddarllen",
"mark_unread": "Marcio fel heb ei ddarllen"
},
"notification_options": "Dewisiadau hysbysu",
"open_space_menu": "Agor dewislen gofod",
"primary_filters": "Hidlau rhestr ystafelloedd",
"room": {
"more_options": "Rhagor o Ddewisiadau",
"open_room": "Agor ystafell %(roomName)s"
},
"show_less": "Dangos llai",
@@ -2525,7 +2502,6 @@
"inline_url_previews_room": "Galluogi rhagolygon URL fel rhagosodiad ar gyfer cyfranogwyr yn yr ystafell hon",
"inline_url_previews_room_account": "Galluogi rhagolygon URL ar gyfer yr ystafell hon (yn effeithio arnoch chi yn unig)",
"insert_trailing_colon_mentions": "Mewnosod colon sy'n llusgo ar ôl i'r defnyddiwr sôn amdano ar ddechrau neges",
"invite_avatars": "Dangos afatarau ystafelloedd y cawsoch eich gwahodd iddynn nhw",
"jump_to_bottom_on_send": "Neidio i waelod y llinell amser pan fyddwch chi'n anfon neges",
"key_backup": {
"backup_in_progress": "Mae copi wrth gefn o'ch allweddi (gallai'r copi wrth gefn cyntaf gymryd ychydig funudau).",

View File

@@ -2097,19 +2097,6 @@
"add_space_label": "Add space",
"breadcrumbs_empty": "No recently visited rooms",
"breadcrumbs_label": "Recently visited rooms",
"empty": {
"no_chats": "No chats yet",
"no_chats_description": "Get started by messaging someone or by creating a room",
"no_chats_description_no_room_rights": "Get started by messaging someone",
"no_favourites": "You don't have favourite chat yet",
"no_favourites_description": "You can add a chat to your favourites in the chat settings",
"no_people": "You dont have direct chats with anyone yet",
"no_people_description": "You can deselect filters in order to see your other chats",
"no_rooms": "Youre not in any room yet",
"no_rooms_description": "You can deselect filters in order to see your other chats",
"no_unread": "Congrats! You dont have any unread messages",
"show_chats": "Show all chats"
},
"failed_add_tag": "Failed to add tag %(tagName)s to room",
"failed_remove_tag": "Failed to remove tag %(tagName)s from room",
"failed_set_dm_tag": "Failed to set direct message tag",
@@ -3148,7 +3135,6 @@
"view": "Views room with given address",
"whois": "Displays information about a user"
},
"sliding_sync_legacy_no_longer_supported": "Legacy sliding sync is no longer supported: please log out and back in to enable the new sliding sync flag",
"space": {
"add_existing_room_space": {
"create": "Want to add a new room instead?",

View File

@@ -64,7 +64,6 @@
"go": "Cest parti",
"go_back": "Revenir en arrière",
"got_it": "Compris",
"hide": "Masquer",
"hide_advanced": "Masquer les paramètres avancés",
"hold": "Mettre en pause",
"ignore": "Ignorer",
@@ -2096,19 +2095,6 @@
"add_space_label": "Ajouter un espace",
"breadcrumbs_empty": "Aucun salon visité récemment",
"breadcrumbs_label": "Salons visités récemment",
"empty": {
"no_chats": "Pas encore de discussions",
"no_chats_description": "Commencez par envoyer un message à quelqu'un ou en créant un salon",
"no_chats_description_no_room_rights": "Commencez par envoyer un message à quelqu'un",
"no_favourites": "Vous n'avez pas encore de discussion favorite",
"no_favourites_description": "Vous pouvez ajouter une discussion à vos favoris dans les paramètres de discussion",
"no_people": "Vous n'avez encore de discussions",
"no_people_description": "Veuillez désélectionner des filtres pour voir vos discussions",
"no_rooms": "Vous nêtes membre daucun salon",
"no_rooms_description": "Veuillez désélectionner des filtres pour voir vos discussions",
"no_unread": "Félicitations ! Vous n'avez aucun message non lu",
"show_chats": "Afficher toutes les discussions"
},
"failed_add_tag": "Échec de lajout de létiquette %(tagName)s au salon",
"failed_remove_tag": "Échec de la suppression de létiquette %(tagName)s du salon",
"failed_set_dm_tag": "Échec de lajout de létiquette de conversation privée",
@@ -2125,14 +2111,6 @@
"other": "Vous êtes en train de rejoindre %(count)s salons"
},
"list_title": "Liste de salons",
"more_options": {
"copy_link": "Copier le lien du salon",
"favourited": "Favorisé",
"leave_room": "Quitter le salon",
"low_priority": "Priorité basse",
"mark_read": "Marquer comme lu",
"mark_unread": "Marquer comme non lu"
},
"notification_options": "Paramètres de notifications",
"open_space_menu": "Ouvrir le menu de lespace",
"primary_filters": "Filtre de la liste des salons",
@@ -2141,7 +2119,6 @@
"other": "Actuellement en train de supprimer les messages dans %(count)s salons"
},
"room": {
"more_options": "Plus doptions",
"open_room": "Ouvrir salon %(roomName)s"
},
"show_less": "En voir moins",

View File

@@ -64,7 +64,6 @@
"go": "Gå",
"go_back": "Gå tilbake",
"got_it": "Jeg forstår",
"hide": "Skjul",
"hide_advanced": "Skjul avansert",
"hold": "Hold",
"ignore": "Ignorer",
@@ -456,7 +455,6 @@
"access_token": "Tilgangstoken",
"accessibility": "Tilgjengelighet",
"advanced": "Avansert",
"all_chats": "Alle chatter",
"analytics": "Statistikk",
"and_n_others": {
"og %(count)s andre …": "other",
@@ -2097,16 +2095,6 @@
"add_space_label": "Legg til område",
"breadcrumbs_empty": "Ingen nylig besøkte rom",
"breadcrumbs_label": "Nylig besøkte rom",
"empty": {
"no_chats": "Ingen chatter ennå",
"no_chats_description": "Kom i gang ved å sende meldinger til noen eller ved å opprette et rom",
"no_chats_description_no_room_rights": "Kom i gang med å sende meldinger til noen",
"no_favourites_description": "Du kan legge til en chat til dine favoritter i chat-innstillingene",
"no_people": "Du har ikke direkte chatter med noen ennå",
"no_rooms": "Du er ikke med i noen rom ennå",
"no_unread": "Gratulerer! Du har ingen uleste meldinger",
"show_chats": "Vis alle chatter"
},
"failed_add_tag": "Kunne ikke legge til tagg %(tagName)s til rom",
"failed_remove_tag": "Kunne ikke fjerne tagg %(tagName)s fra rommet",
"failed_set_dm_tag": "Kan ikke sette kode på direktemeldingen",
@@ -2123,14 +2111,6 @@
"other": "Blir for øyeblikket med i %(count)s rom"
},
"list_title": "Romliste",
"more_options": {
"copy_link": "Kopier romlenke",
"favourited": "Favorittmerket",
"leave_room": "Forlat rommet",
"low_priority": "Lav prioritet",
"mark_read": "Marker som lest",
"mark_unread": "Marker som ulest"
},
"notification_options": "Varselsinnstillinger",
"open_space_menu": "Åpne Område-meny",
"primary_filters": "Filtre for romliste",
@@ -2139,7 +2119,6 @@
"other": "Fjerner for øyeblikket meldinger i %(count)s rom"
},
"room": {
"more_options": "Flere alternativer",
"open_room": "Åpne rom %(roomName)s"
},
"show_less": "Vis mindre",

View File

@@ -64,7 +64,6 @@
"go": "Przejdź",
"go_back": "Wróć",
"got_it": "Rozumiem",
"hide": "Ukryj",
"hide_advanced": "Ukryj zaawansowane",
"hold": "Wstrzymaj",
"ignore": "Ignoruj",
@@ -408,15 +407,6 @@
"download_logs": "Pobierz dzienniki",
"downloading_logs": "Pobieranie logów",
"error_empty": "Powiedz nam, co poszło nie tak, lub nawet lepiej - utwórz zgłoszenie na platformie GitHub, które opisuje problem.",
"failed_download_logs": "Nie udało się pobrać dzienników debugowania: ",
"failed_send_logs_causes": {
"disallowed_app": "Twój raport o błędzie został odrzucony. Serwer gniewnego potrząśnięcia nie obsługuje tej aplikacji.",
"rejected_generic": "Twój raport o błędzie został odrzucony. Serwer gniewnego potrząśnięcia odrzucił zawartość zgłoszenia ze względu na jej politykę.",
"rejected_recovery_key": "Twój raport o błędzie został odrzucony ze względów bezpieczeństwa, ponieważ zawierał on klucz przywracania.",
"rejected_version": "Twój raport o błędzie został odrzucony, ponieważ wersja aplikacji jest zbyt stara.",
"server_unknown_error": "Serwer gniewnego potrząśnięcia napotkał nieznany błąd i nie mógł obsłużyć raportu.",
"unknown_error": "Nie udało się wysłać logów."
},
"github_issue": "Zgłoszenie GitHub",
"introduction": "Jeśli zgłosiłeś błąd za pomocą serwisu GitHub, dzienniki debugowania mogą pomóc nam w namierzeniu problemu. ",
"log_request": "Aby uniknąć tego problemu w przyszłości, <a>wyślij nam dzienniki</a>.",
@@ -456,7 +446,6 @@
"access_token": "Token dostępu",
"accessibility": "Ułatwienia dostępu",
"advanced": "Zaawansowane",
"all_chats": "Wszystkie czaty",
"analytics": "Analityka",
"and_n_others": {
"one": "i jeden inny...",
@@ -1316,7 +1305,6 @@
"error_connecting_heading": "Nie udało się połączyć z menedżerem integracji",
"explainer": "Zarządcy integracji otrzymują dane konfiguracji, mogą modyfikować widżety, wysyłać zaproszenia do pokojów i ustawiać poziom uprawnień w Twoim imieniu.",
"manage_title": "Zarządzaj integracjami",
"toggle_label": "Włącz menedżer integracji",
"use_im": "Użyj zarządcy integracji aby zarządzać botami, widżetami i pakietami naklejek.",
"use_im_default": "Użyj zarządcy Integracji <b>%(serverName)s</b> aby zarządzać botami, widżetami i pakietami naklejek."
},
@@ -2110,54 +2098,20 @@
"add_space_label": "Dodaj przestrzeń",
"breadcrumbs_empty": "Brak ostatnio odwiedzonych pokojów",
"breadcrumbs_label": "Ostatnio odwiedzane pokoje",
"empty": {
"no_chats": "Nie ma jeszcze czatów",
"no_chats_description": "Zacznij od wysłania wiadomości lub utworzenia pokoju",
"no_chats_description_no_room_rights": "Wyślij komuś wiadomość, aby rozpocząć.",
"no_favourites": "Nie masz jeszcze ulubionego czatu",
"no_favourites_description": "Dodaj czat do ulubionych w ustawieniach czatu",
"no_people": "Nie prowadzisz jeszcze z nikim czatów prywatnych",
"no_people_description": "Wyczyść filtry, aby zobaczyć pozostałe czaty",
"no_rooms": "Nie jesteś jeszcze w żadnym pokoju",
"no_rooms_description": "Wyczyść filtry, aby zobaczyć pozostałe czaty",
"no_unread": "Brawo! Nie masz żadnych nieprzeczytanych wiadomości",
"show_chats": "Pokaż wszystkie czaty"
},
"failed_add_tag": "Nie można dodać tagu %(tagName)s do pokoju",
"failed_remove_tag": "Nie udało się usunąć tagu %(tagName)s z pokoju",
"failed_set_dm_tag": "Nie udało się ustawić tagu wiadomości prywatnych",
"filters": {
"favourite": "Ulubione",
"people": "Osoby",
"rooms": "Pokoje",
"unread": "Nieprzeczytane"
},
"home_menu_label": "Opcje głównej",
"join_public_room_label": "Dołącz do publicznego pokoju",
"joining_rooms_status": {
"one": "Aktualnie dołączanie do %(count)s pokoju",
"other": "Aktualnie dołączanie do %(count)s pokoi"
},
"list_title": "Lista pokoi",
"more_options": {
"copy_link": "Kopiuj link pokoju",
"favourited": "Ulubiony",
"leave_room": "Opuść pokój",
"low_priority": "Niski priorytet",
"mark_read": "Oznacz jako przeczytane",
"mark_unread": "Oznacz jako nieprzeczytane"
},
"notification_options": "Opcje powiadomień",
"open_space_menu": "Otwórz menu przestrzeni",
"primary_filters": "Filtry listy pokoi",
"redacting_messages_status": {
"one": "Aktualnie usuwanie wiadomości z %(count)s pokoju",
"other": "Aktualnie usuwanie wiadomości z %(count)s pokoi"
},
"room": {
"more_options": "Więcej opcji",
"open_room": "Pokój otwarty %(roomName)s"
},
"show_less": "Pokaż mniej",
"show_n_more": {
"one": "Pokaż %(count)s więcej",
@@ -2169,10 +2123,6 @@
"sort_by_activity": "Aktywności",
"sort_by_alphabet": "A-Z",
"sort_unread_first": "Pokazuj najpierw pokoje z nieprzeczytanymi wiadomościami",
"space_menu": {
"home": "Główna przestrzeni",
"space_settings": "Ustawienia przestrzeni"
},
"space_menu_label": "menu %(spaceName)s",
"sublist_options": "Ustawienia listy",
"suggested_rooms_heading": "Sugerowane pokoje"
@@ -2535,35 +2485,20 @@
"breadcrumb_title_forgot": "Nie pamiętasz klucza przywracania? Musisz zresetować swoją tożsamość.",
"breadcrumb_warning": "Zrób to tylko wtedy, gdy uważasz, że Twoje konto zostało naruszone.",
"details_title": "Szczegóły szyfrowania",
"do_not_close_warning": "Nie zamykaj tego okna, dopóki reset nie zostanie zakończony",
"export_keys": "Eksportuj klucze",
"import_keys": "Importuj klucze",
"other_people_device_description": "Domyślnie w pokojach szyfrowanych nie będziesz mógł wysyłać wiadomości, jeśli nie zweryfikujesz członków w pokoju",
"other_people_device_label": "Nigdy nie wysyłaj wiadomości szyfrowanych do niezweryfikowanych urządzeń",
"other_people_device_title": "Urządzenia innych osób",
"reset_identity": "Zresetuj tożsamość kryptograficzną",
"reset_in_progress": "Resetowanie w toku...",
"session_id": "ID sesji:",
"session_key": "Klucz sesji:",
"title": "Zaawansowane"
},
"delete_key_storage": {
"breadcrumb_page": "Usuń magazyn kluczy",
"confirm": "Usuń magazyn kluczy",
"description": "Usunięcie magazynu kluczy usunie Twoją tożsamość kryptograficzną, klucze wiadomości z serwera i wyłączy następujące funkcje bezpieczeństwa:",
"list_first": "Nowe urządzenia nie będą posiadały Twojej historii wiadomości szyfrowanych",
"list_second": "Utracisz dostęp do swoich wiadomości szyfrowanych, jeśli wylogujesz się wszędzie z %(brand)s",
"title": "Czy na pewno chcesz wyłączyć magazyn kluczy i go wyłączyć?"
},
"device_not_verified_button": "Zweryfikuj to urządzenie",
"device_not_verified_description": "Aby wyświetlić ustawienia szyfrowania, musisz zweryfikować to urządzenie.",
"device_not_verified_title": "Urządzenie niezweryfikowane",
"dialog_title": "<strong>Ustawienia:</strong> Szyfrowanie",
"key_storage": {
"allow_key_storage": "Zezwól na magazynowanie kluczy",
"description": "Przechowuj swoją tożsamość kryptograficzną i klucze wiadomości bezpiecznie na serwerze. Umożliwi Ci to przeglądanie historii wiadomości na wszystkich nowych urządzeniach.<a>Dowiedz się więcej</a>",
"title": "Magazyn kluczy"
},
"recovery": {
"change_recovery_confirm_button": "Potwierdź nowy klucz przywracania",
"change_recovery_confirm_description": "Wprowadź poniżej nowy klucz przywracania, aby zakończyć. Stary klucz przestanie działać.",
@@ -2680,7 +2615,6 @@
"inline_url_previews_room": "Włącz domyślny podgląd URL dla uczestników w tym pokoju",
"inline_url_previews_room_account": "Włącz podgląd URL dla tego pokoju (dotyczy tylko Ciebie)",
"insert_trailing_colon_mentions": "Wstawiaj dwukropek po wzmiance użytkownika na początku wiadomości",
"invite_avatars": "Pokaż awatary pokoi, do których zostałeś zaproszony",
"jump_to_bottom_on_send": "Przejdź na dół osi czasu po wysłaniu wiadomości",
"key_backup": {
"backup_in_progress": "Tworzy się kopia zapasowa Twoich kluczy (pierwsza kopia może potrwać kilka minut).",
@@ -3162,7 +3096,6 @@
"view": "Przegląda pokój z podanym adresem",
"whois": "Pokazuje informacje na temat użytkownika"
},
"sliding_sync_legacy_no_longer_supported": "Starsza metoda synchronizacji sliding sync nie jest już obsługiwana: zaloguj się ponownie, aby włączyć nową flagę synchronizacji sliding sync",
"space": {
"add_existing_room_space": {
"create": "Chcesz zamiast tego dodać nowy pokój?",
@@ -4136,7 +4069,7 @@
"error_need_to_be_logged_in": "Musisz być zalogowany.",
"error_unable_start_audio_stream_description": "Nie można rozpocząć przesyłania strumienia audio.",
"error_unable_start_audio_stream_title": "Nie udało się rozpocząć transmisji na żywo",
"modal_data_warning": "Poniższe dane są współdzielone z %(widgetDomain)s",
"modal_data_warning": "Dane na tym ekranie są współdzielone z %(widgetDomain)s",
"modal_title_default": "Widżet modalny",
"no_name": "Nieznana aplikacja",
"open_id_permissions_dialog": {

View File

@@ -455,7 +455,6 @@
"access_token": "Токен доступу",
"accessibility": "Доступність",
"advanced": "Подробиці",
"all_chats": "Усі бесіди",
"analytics": "Аналітика",
"and_n_others": {
"one": "і інше...",
@@ -2116,23 +2115,13 @@
"other": "Приєднання до %(count)s кімнат"
},
"list_title": "Список кімнат",
"more_options": {
"copy_link": "Копіювати посилання на кімнату",
"favourited": "Обране",
"leave_room": "Вийти з кімнати",
"low_priority": "Неважливі",
"mark_read": "Позначити прочитаним",
"mark_unread": "Позначити непрочитаним"
},
"notification_options": "Параметри сповіщень",
"open_space_menu": "Відкрити меню простору",
"primary_filters": "Фільтри списку кімнат",
"redacting_messages_status": {
"one": "Триває видалення повідомлень в %(count)s кімнаті",
"other": "Триває видалення повідомлень у %(count)s кімнатах"
},
"room": {
"more_options": "Інші опції",
"open_room": "Відкрити кімнату %(roomName)s"
},
"show_less": "Згорнути",
@@ -2523,23 +2512,10 @@
"session_key": "Ключ сеансу:",
"title": "Додатково"
},
"delete_key_storage": {
"breadcrumb_page": "Видалити сховище ключів",
"confirm": "Видалити сховище ключів",
"description": "Видалення сховища ключів вилучить вашу криптографічну ідентичність і ключі повідомлень з сервера, а також вимкне такі функції безпеки:",
"list_first": "Ви не матимете історії зашифрованих повідомлень на нових пристроях",
"list_second": "Ви втратите доступ до своїх зашифрованих повідомлень, якщо ви вийдете з %(brand)s на всіх пристроях",
"title": "Ви впевнені, що хочете вимкнути зберігання ключів і видалити їх?"
},
"device_not_verified_button": "Верифікувати цей пристрій",
"device_not_verified_description": "Верифікуйте цей пристрій, щоб переглянути налаштування шифрування.",
"device_not_verified_title": "Пристрій не верифіковано",
"dialog_title": "<strong>Налаштування:</strong> Шифрування",
"key_storage": {
"allow_key_storage": "Дозволити зберігання ключів",
"description": "Надійно зберігайте свою криптографічну ідентичність і ключі повідомлень на сервері. Це дозволить вам переглядати історію повідомлень на будь-яких нових пристроях. <a>Докладніше</a>",
"title": "Сховище ключів"
},
"recovery": {
"change_recovery_confirm_button": "Підтвердити новий ключ відновлення",
"change_recovery_confirm_description": "Введіть новий ключ відновлення нижче, щоб завершити. Ваш старий ключ більше не працюватиме.",
@@ -2656,7 +2632,6 @@
"inline_url_previews_room": "Увімкнути попередній перегляд гіперпосилань за умовчанням для учасників цієї кімнати",
"inline_url_previews_room_account": "Увімкнути попередній перегляд гіперпосилань в цій кімнаті (стосується тільки вас)",
"insert_trailing_colon_mentions": "Додавати двокрапку після згадки користувача на початку повідомлення",
"invite_avatars": "Показувати аватари кімнат, до яких вас запросили",
"jump_to_bottom_on_send": "Переходити вниз стрічки під час надсилання повідомлення",
"key_backup": {
"backup_in_progress": "Створюється резервна копія ваших ключів (перше копіювання може тривати кілька хвилин).",
@@ -4109,7 +4084,7 @@
"error_need_to_be_logged_in": "Вам потрібно увійти.",
"error_unable_start_audio_stream_description": "Не вдалося почати аудіотрансляцію.",
"error_unable_start_audio_stream_title": "Не вдалося почати живу трансляцію",
"modal_data_warning": "Наведені далі дані надсилаються на %(widgetDomain)s",
"modal_data_warning": "Дані на цьому екрані надсилаються до %(widgetDomain)s",
"modal_title_default": "Модальний віджет",
"no_name": "Невідомий додаток",
"open_id_permissions_dialog": {

View File

@@ -198,8 +198,7 @@ export interface Settings {
"feature_html_topic": IFeature;
"feature_bridge_state": IFeature;
"feature_jump_to_date": IFeature;
"feature_sliding_sync": IBaseSetting<boolean>;
"feature_simplified_sliding_sync": IFeature;
"feature_sliding_sync": IFeature;
"feature_element_call_video_rooms": IFeature;
"feature_group_calls": IFeature;
"feature_disable_call_per_sender_encryption": IFeature;
@@ -211,6 +210,7 @@ export interface Settings {
"feature_ask_to_join": IFeature;
"feature_notifications": IFeature;
// These are in the feature namespace but aren't actually features
"feature_sliding_sync_proxy_url": IBaseSetting<string>;
"feature_hidebold": IBaseSetting<boolean>;
"useOnlyCurrentProfiles": IBaseSetting<boolean>;
@@ -315,7 +315,6 @@ export interface Settings {
"showImages": IBaseSetting<boolean>;
"showAvatarsOnInvites": IBaseSetting<boolean>;
"RoomList.preferredSorting": IBaseSetting<SortingAlgorithm>;
"RoomList.showMessagePreview": IBaseSetting<boolean>;
"RightPanel.phasesGlobal": IBaseSetting<IRightPanelForRoomStored | null>;
"RightPanel.phases": IBaseSetting<IRightPanelForRoomStored | null>;
"enableEventIndexing": IBaseSetting<boolean>;
@@ -539,14 +538,7 @@ export const SETTINGS: Settings = {
true,
),
},
// legacy sliding sync flag: no longer works, will error for anyone who's still using it
"feature_sliding_sync": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
supportedLevelsAreOrdered: true,
shouldWarn: true,
default: false,
},
"feature_simplified_sliding_sync": {
isFeature: true,
labsGroup: LabGroup.Developer,
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG_PRIORITISED,
@@ -557,6 +549,11 @@ export const SETTINGS: Settings = {
default: false,
controller: new SlidingSyncController(),
},
"feature_sliding_sync_proxy_url": {
// This is not a distinct feature, it is a legacy setting for feature_sliding_sync above
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: "",
},
"feature_element_call_video_rooms": {
isFeature: true,
labsGroup: LabGroup.VoiceAndVideo,
@@ -1135,10 +1132,6 @@ export const SETTINGS: Settings = {
supportedLevels: [SettingLevel.DEVICE],
default: SortingAlgorithm.Recency,
},
"RoomList.showMessagePreview": {
supportedLevels: [SettingLevel.DEVICE],
default: false,
},
"RightPanel.phasesGlobal": {
supportedLevels: [SettingLevel.DEVICE],
default: null,

View File

@@ -11,19 +11,20 @@ import SettingController from "./SettingController";
import PlatformPeg from "../../PlatformPeg";
import SettingsStore from "../SettingsStore";
import { _t } from "../../languageHandler";
import { SlidingSyncManager } from "../../SlidingSyncManager";
export default class SlidingSyncController extends SettingController {
public static serverSupportsSlidingSync: boolean;
public async onChange(): Promise<void> {
PlatformPeg.get()?.reload();
}
public get settingDisabled(): boolean | string {
// Cannot be disabled once enabled, user has been warned and must log out and back in.
if (SettingsStore.getValue("feature_simplified_sliding_sync")) {
if (SettingsStore.getValue("feature_sliding_sync")) {
return _t("labs|sliding_sync_disabled_notice");
}
if (!SlidingSyncManager.serverSupportsSlidingSync) {
if (!SlidingSyncController.serverSupportsSlidingSync) {
return _t("labs|sliding_sync_server_no_support");
}

View File

@@ -122,7 +122,7 @@ export class MemberListStore {
* @returns True if enabled
*/
private async isLazyLoadingEnabled(roomId: string): Promise<boolean> {
if (SettingsStore.getValue("feature_simplified_sliding_sync")) {
if (SettingsStore.getValue("feature_sliding_sync")) {
// only unencrypted rooms use lazy loading
return !(await this.stores.client?.getCrypto()?.isEncryptionEnabledInRoom(roomId));
}
@@ -134,7 +134,7 @@ export class MemberListStore {
* @returns True if there is storage for lazy loading members
*/
private isLazyMemberStorageEnabled(): boolean {
if (SettingsStore.getValue("feature_simplified_sliding_sync")) {
if (SettingsStore.getValue("feature_sliding_sync")) {
return false;
}
return this.stores.client!.hasLazyLoadMembersEnabled();

View File

@@ -372,7 +372,11 @@ export class RoomViewStore extends EventEmitter {
if (prevRoomCall !== null && (!payload.view_call || payload.room_id !== this.state.roomId))
prevRoomCall.presented = false;
if (SettingsStore.getValue("feature_simplified_sliding_sync") && this.state.roomId !== payload.room_id) {
if (SettingsStore.getValue("feature_sliding_sync") && this.state.roomId !== payload.room_id) {
if (this.state.subscribingRoomId && this.state.subscribingRoomId !== payload.room_id) {
// unsubscribe from this room, but don't await it as we don't care when this gets done.
this.stores.slidingSyncManager.setRoomVisible(this.state.subscribingRoomId, false);
}
this.setState({
subscribingRoomId: payload.room_id,
roomId: payload.room_id,
@@ -388,8 +392,13 @@ export class RoomViewStore extends EventEmitter {
});
// set this room as the room subscription. We need to await for it as this will fetch
// all room state for this room, which is required before we get the state below.
await this.stores.slidingSyncManager.setRoomVisible(payload.room_id);
await this.stores.slidingSyncManager.setRoomVisible(payload.room_id, true);
// Whilst we were subscribing another room was viewed, so stop what we're doing and
// unsubscribe
if (this.state.subscribingRoomId !== payload.room_id) {
this.stores.slidingSyncManager.setRoomVisible(payload.room_id, false);
return;
}
// Re-fire the payload: we won't re-process it because the prev room ID == payload room ID now
this.dis?.dispatch({
...payload,

View File

@@ -33,6 +33,7 @@ import { VisibilityProvider } from "./filters/VisibilityProvider";
import { SpaceWatcher } from "./SpaceWatcher";
import { type IRoomTimelineActionPayload } from "../../actions/MatrixActionCreators";
import { type RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { SlidingRoomListStoreClass } from "./SlidingRoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
import { SdkContextClass } from "../../contexts/SDKContext";
import { getChangedOverrideRoomMutePushRules } from "./utils/roomMute";
@@ -405,9 +406,6 @@ export class RoomListStoreClass extends AsyncStoreWithClient<EmptyObject> implem
public setTagSorting(tagId: TagID, sort: SortAlgorithm): void {
this.setAndPersistTagSorting(tagId, sort);
// We'll always need an update after changing the sort order, so mark for update and trigger
// immediately.
this.updateFn.mark();
this.updateFn.trigger();
}
@@ -644,9 +642,16 @@ export default class RoomListStore {
public static get instance(): Interface {
if (!RoomListStore.internalInstance) {
const instance = new RoomListStoreClass(defaultDispatcher);
instance.start();
RoomListStore.internalInstance = instance;
if (SettingsStore.getValue("feature_sliding_sync")) {
logger.info("using SlidingRoomListStoreClass");
const instance = new SlidingRoomListStoreClass(defaultDispatcher, SdkContextClass.instance);
instance.start();
RoomListStore.internalInstance = instance;
} else {
const instance = new RoomListStoreClass(defaultDispatcher);
instance.start();
RoomListStore.internalInstance = instance;
}
}
return this.internalInstance;

View File

@@ -0,0 +1,391 @@
/*
Copyright 2024 New Vector Ltd.
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
Please see LICENSE files in the repository root for full details.
*/
import { type EmptyObject, type Room } from "matrix-js-sdk/src/matrix";
import { logger } from "matrix-js-sdk/src/logger";
import { type MSC3575Filter, SlidingSyncEvent } from "matrix-js-sdk/src/sliding-sync";
import { type Optional } from "matrix-events-sdk";
import { type RoomUpdateCause, type TagID, OrderedDefaultTagIDs, DefaultTagID } from "./models";
import { type ITagMap, ListAlgorithm, SortAlgorithm } from "./algorithms/models";
import { type ActionPayload } from "../../dispatcher/payloads";
import { type MatrixDispatcher } from "../../dispatcher/dispatcher";
import { type IFilterCondition } from "./filters/IFilterCondition";
import { AsyncStoreWithClient } from "../AsyncStoreWithClient";
import { type RoomListStore as Interface, RoomListStoreEvent } from "./Interface";
import { MetaSpace, type SpaceKey, UPDATE_SELECTED_SPACE } from "../spaces";
import { LISTS_LOADING_EVENT } from "./RoomListStore";
import { UPDATE_EVENT } from "../AsyncStore";
import { type SdkContextClass } from "../../contexts/SDKContext";
export const SlidingSyncSortToFilter: Record<SortAlgorithm, string[]> = {
[SortAlgorithm.Alphabetic]: ["by_name", "by_recency"],
[SortAlgorithm.Recent]: ["by_notification_level", "by_recency"],
[SortAlgorithm.Manual]: ["by_recency"],
};
const filterConditions: Record<TagID, MSC3575Filter> = {
[DefaultTagID.Invite]: {
is_invite: true,
},
[DefaultTagID.Favourite]: {
tags: ["m.favourite"],
},
[DefaultTagID.DM]: {
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"],
},
[DefaultTagID.Untagged]: {
is_dm: false,
is_invite: false,
not_room_types: ["m.space"],
not_tags: ["m.favourite", "m.lowpriority"],
// spaces filter added dynamically
},
[DefaultTagID.LowPriority]: {
tags: ["m.lowpriority"],
// If a room has both Favourite & Low Prio tags then it'll be shown under Favourites
not_tags: ["m.favourite"],
},
// TODO https://github.com/vector-im/element-web/issues/23207
// DefaultTagID.ServerNotice,
// DefaultTagID.Suggested,
// DefaultTagID.Archived,
};
export const LISTS_UPDATE_EVENT = RoomListStoreEvent.ListsUpdate;
export class SlidingRoomListStoreClass extends AsyncStoreWithClient<EmptyObject> implements Interface {
private tagIdToSortAlgo: Record<TagID, SortAlgorithm> = {};
private tagMap: ITagMap = {};
private counts: Record<TagID, number> = {};
private stickyRoomId: Optional<string>;
public constructor(
dis: MatrixDispatcher,
private readonly context: SdkContextClass,
) {
super(dis);
this.setMaxListeners(20); // RoomList + LeftPanel + 8xRoomSubList + spares
}
public async setTagSorting(tagId: TagID, sort: SortAlgorithm): Promise<void> {
logger.info("SlidingRoomListStore.setTagSorting ", tagId, sort);
this.tagIdToSortAlgo[tagId] = sort;
switch (sort) {
case SortAlgorithm.Alphabetic:
await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Alphabetic],
});
break;
case SortAlgorithm.Recent:
await this.context.slidingSyncManager.ensureListRegistered(tagId, {
sort: SlidingSyncSortToFilter[SortAlgorithm.Recent],
});
break;
case SortAlgorithm.Manual:
logger.error("cannot enable manual sort in sliding sync mode");
break;
default:
logger.error("unknown sort mode: ", sort);
}
}
public getTagSorting(tagId: TagID): SortAlgorithm {
let algo = this.tagIdToSortAlgo[tagId];
if (!algo) {
logger.warn("SlidingRoomListStore.getTagSorting: no sort algorithm for tag ", tagId);
algo = SortAlgorithm.Recent; // why not, we have to do something..
}
return algo;
}
public getCount(tagId: TagID): number {
return this.counts[tagId] || 0;
}
public setListOrder(tagId: TagID, order: ListAlgorithm): void {
// TODO: https://github.com/vector-im/element-web/issues/23207
}
public getListOrder(tagId: TagID): ListAlgorithm {
// TODO: handle unread msgs first? https://github.com/vector-im/element-web/issues/23207
return ListAlgorithm.Natural;
}
/**
* Adds a filter condition to the room list store. Filters may be applied async,
* and thus might not cause an update to the store immediately.
* @param {IFilterCondition} filter The filter condition to add.
*/
public async addFilter(filter: IFilterCondition): Promise<void> {
// Do nothing, the filters are only used by SpaceWatcher to see if a room should appear
// in the room list. We do not support arbitrary code for filters in sliding sync.
}
/**
* Removes a filter condition from the room list store. If the filter was
* not previously added to the room list store, this will no-op. The effects
* of removing a filter may be applied async and therefore might not cause
* an update right away.
* @param {IFilterCondition} filter The filter condition to remove.
*/
public removeFilter(filter: IFilterCondition): void {
// Do nothing, the filters are only used by SpaceWatcher to see if a room should appear
// in the room list. We do not support arbitrary code for filters in sliding sync.
}
/**
* Gets the tags for a room identified by the store. The returned set
* should never be empty, and will contain DefaultTagID.Untagged if
* the store is not aware of any tags.
* @param room The room to get the tags for.
* @returns The tags for the room.
*/
public getTagsForRoom(room: Room): TagID[] {
// check all lists for each tag we know about and see if the room is there
const tags: TagID[] = [];
for (const tagId in this.tagIdToSortAlgo) {
const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId);
if (!listData) {
continue;
}
for (const roomIndex in listData.roomIndexToRoomId) {
const roomId = listData.roomIndexToRoomId[roomIndex];
if (roomId === room.roomId) {
tags.push(tagId);
break;
}
}
}
return tags;
}
/**
* Manually update a room with a given cause. This should only be used if the
* room list store would otherwise be incapable of doing the update itself. Note
* that this may race with the room list's regular operation.
* @param {Room} room The room to update.
* @param {RoomUpdateCause} cause The cause to update for.
*/
public async manualRoomUpdate(room: Room, cause: RoomUpdateCause): Promise<void> {
// TODO: this is only used when you forget a room, not that important for now.
}
public get orderedLists(): ITagMap {
return this.tagMap;
}
private refreshOrderedLists(tagId: string, roomIndexToRoomId: Record<number, string>): void {
const tagMap = this.tagMap;
// this room will not move due to it being viewed: it is sticky. This can be null to indicate
// no sticky room if you aren't viewing a room.
this.stickyRoomId = this.context.roomViewStore.getRoomId();
let stickyRoomNewIndex = -1;
const stickyRoomOldIndex = (tagMap[tagId] || []).findIndex((room): boolean => {
return room.roomId === this.stickyRoomId;
});
// order from low to high
const orderedRoomIndexes = Object.keys(roomIndexToRoomId)
.map((numStr) => {
return Number(numStr);
})
.sort((a, b) => {
return a - b;
});
const seenRoomIds = new Set<string>();
const orderedRoomIds = orderedRoomIndexes.map((i) => {
const rid = roomIndexToRoomId[i];
if (seenRoomIds.has(rid)) {
logger.error("room " + rid + " already has an index position: duplicate room!");
}
seenRoomIds.add(rid);
if (!rid) {
throw new Error("index " + i + " has no room ID: Map => " + JSON.stringify(roomIndexToRoomId));
}
if (rid === this.stickyRoomId) {
stickyRoomNewIndex = i;
}
return rid;
});
logger.debug(
`SlidingRoomListStore.refreshOrderedLists ${tagId} sticky: ${this.stickyRoomId}`,
`${stickyRoomOldIndex} -> ${stickyRoomNewIndex}`,
"rooms:",
orderedRoomIds.length < 30 ? orderedRoomIds : orderedRoomIds.length,
);
if (this.stickyRoomId && stickyRoomOldIndex >= 0 && stickyRoomNewIndex >= 0) {
// this update will move this sticky room from old to new, which we do not want.
// Instead, keep the sticky room ID index position as it is, swap it with
// whatever was in its place.
// Some scenarios with sticky room S and bump room B (other letters unimportant):
// A, S, C, B S, A, B
// B, A, S, C <---- without sticky rooms ---> B, S, A
// B, S, A, C <- with sticky rooms applied -> S, B, A
// In other words, we need to swap positions to keep it locked in place.
const inWayRoomId = orderedRoomIds[stickyRoomOldIndex];
orderedRoomIds[stickyRoomOldIndex] = this.stickyRoomId;
orderedRoomIds[stickyRoomNewIndex] = inWayRoomId;
}
// now set the rooms
const rooms: Room[] = [];
orderedRoomIds.forEach((roomId) => {
const room = this.matrixClient?.getRoom(roomId);
if (!room) {
return;
}
rooms.push(room);
});
tagMap[tagId] = rooms;
this.tagMap = tagMap;
}
private onSlidingSyncListUpdate(tagId: string, joinCount: number, roomIndexToRoomId: Record<number, string>): void {
this.counts[tagId] = joinCount;
this.refreshOrderedLists(tagId, roomIndexToRoomId);
// let the UI update
this.emit(LISTS_UPDATE_EVENT);
}
private onRoomViewStoreUpdated(): void {
// we only care about this to know when the user has clicked on a room to set the stickiness value
if (this.context.roomViewStore.getRoomId() === this.stickyRoomId) {
return;
}
let hasUpdatedAnyList = false;
// every list with the OLD sticky room ID needs to be resorted because it now needs to take
// its proper place as it is no longer sticky. The newly sticky room can remain the same though,
// as we only actually care about its sticky status when we get list updates.
const oldStickyRoom = this.stickyRoomId;
// it's not safe to check the data in slidingSync as it is tracking the server's view of the
// room list. There's an edge case whereby the sticky room has gone outside the window and so
// would not be present in the roomIndexToRoomId map anymore, and hence clicking away from it
// will make it disappear eventually. We need to check orderedLists as that is the actual
// sorted renderable list of rooms which sticky rooms apply to.
for (const tagId in this.orderedLists) {
const list = this.orderedLists[tagId];
const room = list.find((room) => {
return room.roomId === oldStickyRoom;
});
if (room) {
// resort it based on the slidingSync view of the list. This may cause this old sticky
// room to cease to exist.
const listData = this.context.slidingSyncManager.slidingSync?.getListData(tagId);
if (!listData) {
continue;
}
this.refreshOrderedLists(tagId, listData.roomIndexToRoomId);
hasUpdatedAnyList = true;
}
}
// in the event we didn't call refreshOrderedLists, it helps to still remember the sticky room ID.
this.stickyRoomId = this.context.roomViewStore.getRoomId();
if (hasUpdatedAnyList) {
this.emit(LISTS_UPDATE_EVENT);
}
}
protected async onReady(): Promise<any> {
logger.info("SlidingRoomListStore.onReady");
// permanent listeners: never get destroyed. Could be an issue if we want to test this in isolation.
this.context.slidingSyncManager.slidingSync!.on(SlidingSyncEvent.List, this.onSlidingSyncListUpdate.bind(this));
this.context.roomViewStore.addListener(UPDATE_EVENT, this.onRoomViewStoreUpdated.bind(this));
this.context.spaceStore.on(UPDATE_SELECTED_SPACE, this.onSelectedSpaceUpdated.bind(this));
if (this.context.spaceStore.activeSpace) {
this.onSelectedSpaceUpdated(this.context.spaceStore.activeSpace, false);
}
// sliding sync has an initial response for spaces. Now request all the lists.
// We do the spaces list _first_ to avoid potential flickering on DefaultTagID.Untagged list
// which would be caused by initially having no `spaces` filter set, and then suddenly setting one.
OrderedDefaultTagIDs.forEach((tagId) => {
const filter = filterConditions[tagId];
if (!filter) {
logger.info("SlidingRoomListStore.onReady unsupported list ", tagId);
return; // we do not support this list yet.
}
const sort = SortAlgorithm.Recent; // default to recency sort, TODO: read from config
this.tagIdToSortAlgo[tagId] = sort;
this.emit(LISTS_LOADING_EVENT, tagId, true);
this.context.slidingSyncManager
.ensureListRegistered(tagId, {
filters: filter,
sort: SlidingSyncSortToFilter[sort],
})
.then(() => {
this.emit(LISTS_LOADING_EVENT, tagId, false);
});
});
}
private onSelectedSpaceUpdated = (activeSpace: SpaceKey, allRoomsInHome: boolean): void => {
logger.info("SlidingRoomListStore.onSelectedSpaceUpdated", activeSpace);
// update the untagged filter
const tagId = DefaultTagID.Untagged;
const filters = filterConditions[tagId];
const oldSpace = filters.spaces?.[0];
filters.spaces = activeSpace && activeSpace != MetaSpace.Home ? [activeSpace] : undefined;
if (oldSpace !== activeSpace) {
// include subspaces in this list
this.context.spaceStore.traverseSpace(
activeSpace,
(roomId: string) => {
if (roomId === activeSpace) {
return;
}
if (!filters.spaces) {
filters.spaces = [];
}
filters.spaces.push(roomId); // add subspace
},
false,
);
this.emit(LISTS_LOADING_EVENT, tagId, true);
this.context.slidingSyncManager
.ensureListRegistered(tagId, {
filters: filters,
})
.then(() => {
this.emit(LISTS_LOADING_EVENT, tagId, false);
});
}
};
// Intended for test usage
public async resetStore(): Promise<void> {
// Test function
}
/**
* Regenerates the room whole room list, discarding any previous results.
*
* Note: This is only exposed externally for the tests. Do not call this from within
* the app.
* @param trigger Set to false to prevent a list update from being sent. Should only
* be used if the calling code will manually trigger the update.
*/
public regenerateAllLists({ trigger = true }): void {
// Test function
}
protected async onNotReady(): Promise<any> {
await this.resetStore();
}
protected async onAction(payload: ActionPayload): Promise<void> {}
}

View File

@@ -69,12 +69,6 @@ export const getLastTs = (r: Room, userId: string): number => {
if (!r?.timeline) {
return Number.MAX_SAFE_INTEGER;
}
// MSC4186: Simplified Sliding Sync sets this.
// If it's present, sort by it.
const bumpStamp = r.getBumpStamp();
if (bumpStamp) {
return bumpStamp;
}
// If the room hasn't been joined yet, it probably won't have a timeline to
// parse. We'll still fall back to the timeline if this fails, but chances

View File

@@ -161,7 +161,6 @@ export const showToast = (kind: Kind): void => {
const onSecondaryClick = (): void => {
if (kind === Kind.KEY_STORAGE_OUT_OF_SYNC) {
// Open the user settings dialog to the encryption tab and start the flow to reset encryption
const payload: OpenToTabPayload = {
action: Action.ViewUserSettings,
initialTabId: UserTab.Encryption,

View File

@@ -661,7 +661,6 @@ export function mkStubRoom(
getUnreadNotificationCount: jest.fn(() => 0),
getRoomUnreadNotificationCount: jest.fn().mockReturnValue(0),
getVersion: jest.fn().mockReturnValue("1"),
getBumpStamp: jest.fn().mockReturnValue(0),
hasMembershipState: () => false,
isElementVideoRoom: jest.fn().mockReturnValue(false),
isSpaceRoom: jest.fn().mockReturnValue(false),

View File

@@ -6,31 +6,18 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
Please see LICENSE files in the repository root for full details.
*/
import { type SlidingSync, SlidingSyncEvent, SlidingSyncState } from "matrix-js-sdk/src/sliding-sync";
import { SlidingSync } from "matrix-js-sdk/src/sliding-sync";
import { mocked } from "jest-mock";
import { ClientEvent, type MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import { type MatrixClient, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
import fetchMockJest from "fetch-mock-jest";
import EventEmitter from "events";
import { waitFor } from "jest-matrix-react";
import { SlidingSyncManager } from "../../src/SlidingSyncManager";
import { mkStubRoom, stubClient } from "../test-utils";
import { stubClient } from "../test-utils";
import SlidingSyncController from "../../src/settings/controllers/SlidingSyncController";
import SettingsStore from "../../src/settings/SettingsStore";
class MockSlidingSync extends EventEmitter {
lists = {};
listModifiedCount = 0;
terminated = false;
needsResend = false;
modifyRoomSubscriptions = jest.fn();
getRoomSubscriptions = jest.fn();
useCustomSubscription = jest.fn();
getListParams = jest.fn();
setList = jest.fn();
setListRanges = jest.fn();
getListData = jest.fn();
extensions = jest.fn();
desiredRoomSubscriptions = jest.fn();
}
jest.mock("matrix-js-sdk/src/sliding-sync");
const MockSlidingSync = <jest.Mock<SlidingSync>>(<unknown>SlidingSync);
describe("SlidingSyncManager", () => {
let manager: SlidingSyncManager;
@@ -38,12 +25,12 @@ describe("SlidingSyncManager", () => {
let client: MatrixClient;
beforeEach(() => {
slidingSync = new MockSlidingSync() as unknown as SlidingSync;
slidingSync = new MockSlidingSync();
manager = new SlidingSyncManager();
client = stubClient();
// by default the client has no rooms: stubClient magically makes rooms annoyingly.
mocked(client.getRoom).mockReturnValue(null);
(manager as any).configure(client, "invalid");
manager.configure(client, "invalid");
manager.slidingSync = slidingSync;
fetchMockJest.reset();
fetchMockJest.get("https://proxy/client/server.json", {});
@@ -52,13 +39,12 @@ describe("SlidingSyncManager", () => {
describe("setRoomVisible", () => {
it("adds a subscription for the room", async () => {
const roomId = "!room:id";
mocked(client.getRoom).mockReturnValue(mkStubRoom(roomId, "foo", client));
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
await manager.setRoomVisible(roomId);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
expect(slidingSync.modifyRoomSubscriptions).toHaveBeenCalledWith(new Set<string>([roomId]));
});
it("adds a custom subscription for a lazy-loadable room", async () => {
const roomId = "!lazy:id";
const room = new Room(roomId, client, client.getUserId()!);
@@ -81,37 +67,19 @@ describe("SlidingSyncManager", () => {
});
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
await manager.setRoomVisible(roomId);
mocked(slidingSync.modifyRoomSubscriptions).mockResolvedValue("yep");
await manager.setRoomVisible(roomId, true);
expect(slidingSync.modifyRoomSubscriptions).toHaveBeenCalledWith(new Set<string>([roomId]));
// we aren't prescriptive about what the sub name is.
expect(slidingSync.useCustomSubscription).toHaveBeenCalledWith(roomId, expect.anything());
});
it("waits if the room is not yet known", async () => {
const roomId = "!room:id";
mocked(client.getRoom).mockReturnValue(null);
const subs = new Set<string>();
mocked(slidingSync.getRoomSubscriptions).mockReturnValue(subs);
const setVisibleDone = jest.fn();
manager.setRoomVisible(roomId).then(setVisibleDone);
await waitFor(() => expect(client.getRoom).toHaveBeenCalledWith(roomId));
expect(setVisibleDone).not.toHaveBeenCalled();
const stubRoom = mkStubRoom(roomId, "foo", client);
mocked(client.getRoom).mockReturnValue(stubRoom);
client.emit(ClientEvent.Room, stubRoom);
await waitFor(() => expect(setVisibleDone).toHaveBeenCalled());
});
});
describe("ensureListRegistered", () => {
it("creates a new list based on the key", async () => {
const listKey = "key";
mocked(slidingSync.getListParams).mockReturnValue(null);
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
@@ -128,6 +96,7 @@ describe("SlidingSyncManager", () => {
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
sort: ["by_recency"],
});
@@ -145,6 +114,7 @@ describe("SlidingSyncManager", () => {
mocked(slidingSync.getListParams).mockReturnValue({
ranges: [[0, 42]],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 52]],
});
@@ -158,6 +128,7 @@ describe("SlidingSyncManager", () => {
ranges: [[0, 42]],
sort: ["by_recency"],
});
mocked(slidingSync.setList).mockResolvedValue("yep");
await manager.ensureListRegistered(listKey, {
ranges: [[0, 42]],
sort: ["by_recency"],
@@ -168,77 +139,183 @@ describe("SlidingSyncManager", () => {
});
describe("startSpidering", () => {
it("requests in expanding batchSizes", async () => {
it("requests in batchSizes", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.setListRanges).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 64,
roomIndexToRoomId: {},
};
});
await (manager as any).startSpidering(slidingSync, batchSize, gapMs);
await manager.startSpidering(batchSize, gapMs);
// we expect calls for 10,19 -> 20,29 -> 30,39 -> 40,49 -> 50,59 -> 60,69
const wantWindows = [
[0, 10],
[0, 20],
[0, 30],
[0, 40],
[0, 50],
[0, 60],
[0, 70],
[10, 19],
[20, 29],
[30, 39],
[40, 49],
[50, 59],
[60, 69],
];
for (let i = 1; i < wantWindows.length; ++i) {
// each time we emit, it should expand the range of all 5 lists by 10 until
// they all include all the rooms (64), which is 6 emits.
slidingSync.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, null, undefined);
await waitFor(() => expect(slidingSync.getListData).toHaveBeenCalledTimes(i * 5));
expect(slidingSync.setListRanges).toHaveBeenCalledTimes(i * 5);
expect(slidingSync.setListRanges).toHaveBeenCalledWith("spaces", [wantWindows[i]]);
}
expect(slidingSync.getListData).toHaveBeenCalledTimes(wantWindows.length);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setListRanges).toHaveBeenCalledTimes(wantWindows.length - 1);
wantWindows.forEach((range, i) => {
if (i === 0) {
// eslint-disable-next-line jest/no-conditional-expect
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
// eslint-disable-next-line jest/no-conditional-expect
expect.objectContaining({
ranges: [[0, batchSize - 1], range],
}),
);
return;
}
expect(slidingSync.setListRanges).toHaveBeenCalledWith(SlidingSyncManager.ListSearch, [
[0, batchSize - 1],
range,
]);
});
});
it("handles accounts with zero rooms", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockResolvedValue("yep");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 0,
roomIndexToRoomId: {},
};
});
await (manager as any).startSpidering(slidingSync, batchSize, gapMs);
slidingSync.emit(SlidingSyncEvent.Lifecycle, SlidingSyncState.Complete, null, undefined);
await waitFor(() => expect(slidingSync.getListData).toHaveBeenCalledTimes(5));
// should not have needed to expand the range
expect(slidingSync.setListRanges).not.toHaveBeenCalled();
await manager.startSpidering(batchSize, gapMs);
expect(slidingSync.getListData).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
expect.objectContaining({
ranges: [
[0, batchSize - 1],
[batchSize, batchSize + batchSize - 1],
],
}),
);
});
it("continues even when setList rejects", async () => {
const gapMs = 1;
const batchSize = 10;
mocked(slidingSync.setList).mockRejectedValue("narp");
mocked(slidingSync.getListData).mockImplementation((key) => {
return {
joinedCount: 0,
roomIndexToRoomId: {},
};
});
await manager.startSpidering(batchSize, gapMs);
expect(slidingSync.getListData).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledTimes(1);
expect(slidingSync.setList).toHaveBeenCalledWith(
SlidingSyncManager.ListSearch,
expect.objectContaining({
ranges: [
[0, batchSize - 1],
[batchSize, batchSize + batchSize - 1],
],
}),
);
});
});
describe("checkSupport", () => {
beforeEach(() => {
SlidingSyncManager.serverSupportsSlidingSync = false;
SlidingSyncController.serverSupportsSlidingSync = false;
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
});
it("shorts out if the server has 'native' sliding sync support", async () => {
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(true);
expect(SlidingSyncManager.serverSupportsSlidingSync).toBeFalsy();
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(SlidingSyncManager.serverSupportsSlidingSync).toBeTruthy();
expect(manager.getProxyFromWellKnown).not.toHaveBeenCalled(); // We return earlier
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
it("tries to find a sliding sync proxy url from the client well-known if there's no 'native' support", async () => {
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(false);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(manager.getProxyFromWellKnown).toHaveBeenCalled();
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
it("should query well-known on server_name not baseUrl", async () => {
fetchMockJest.get("https://matrix.org/.well-known/matrix/client", {
"m.homeserver": {
base_url: "https://matrix-client.matrix.org",
server: "matrix.org",
},
"org.matrix.msc3575.proxy": {
url: "https://proxy/",
},
});
fetchMockJest.get("https://matrix-client.matrix.org/_matrix/client/versions", { versions: ["v1.4"] });
mocked(manager.getProxyFromWellKnown).mockRestore();
jest.spyOn(manager, "nativeSlidingSyncSupport").mockResolvedValue(false);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client);
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
expect(fetchMockJest).not.toHaveFetched("https://matrix-client.matrix.org/.well-known/matrix/client");
});
});
describe("nativeSlidingSyncSupport", () => {
beforeEach(() => {
SlidingSyncController.serverSupportsSlidingSync = false;
});
it("should make an OPTIONS request to avoid unintended side effects", async () => {
// See https://github.com/element-hq/element-web/issues/27426
const unstableSpy = jest
.spyOn(client, "doesServerSupportUnstableFeature")
.mockImplementation(async (feature: string) => {
expect(feature).toBe("org.matrix.msc3575");
return true;
});
const proxySpy = jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
expect(SlidingSyncController.serverSupportsSlidingSync).toBeFalsy();
await manager.checkSupport(client); // first thing it does is call nativeSlidingSyncSupport
expect(proxySpy).not.toHaveBeenCalled();
expect(unstableSpy).toHaveBeenCalled();
expect(SlidingSyncController.serverSupportsSlidingSync).toBeTruthy();
});
});
describe("setup", () => {
let untypedManager: any;
beforeEach(() => {
untypedManager = manager;
jest.spyOn(untypedManager, "configure");
jest.spyOn(untypedManager, "startSpidering");
jest.spyOn(manager, "configure");
jest.spyOn(manager, "startSpidering");
});
it("uses the baseUrl", async () => {
it("uses the baseUrl as a proxy if no proxy is set in the client well-known and the server has no native support", async () => {
await manager.setup(client);
expect(untypedManager.configure).toHaveBeenCalled();
expect(untypedManager.configure).toHaveBeenCalledWith(client, client.baseUrl);
expect(untypedManager.startSpidering).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, client.baseUrl);
expect(manager.startSpidering).toHaveBeenCalled();
});
it("uses the proxy declared in the client well-known", async () => {
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, "https://proxy/");
expect(manager.startSpidering).toHaveBeenCalled();
});
it("uses the legacy `feature_sliding_sync_proxy_url` if it was set", async () => {
jest.spyOn(manager, "getProxyFromWellKnown").mockResolvedValue("https://proxy/");
jest.spyOn(SettingsStore, "getValue").mockImplementation((name: string): any => {
if (name === "feature_sliding_sync_proxy_url") return "legacy-proxy";
});
await manager.setup(client);
expect(manager.configure).toHaveBeenCalled();
expect(manager.configure).toHaveBeenCalledWith(client, "legacy-proxy");
expect(manager.startSpidering).toHaveBeenCalled();
});
});
});

View File

@@ -68,9 +68,9 @@ describe("SupportedBrowser", () => {
// Latest Firefox on macOS Sonoma
"Mozilla/5.0 (Macintosh; Intel Mac OS X 14.7; rv:135.0) Gecko/20100101 Firefox/135.0",
// Latest Edge on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/134.0.3124.72",
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/131.0.2903.86",
// Latest Edge on macOS
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/134.0.3124.72",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36 Edg/131.0.2903.86",
// Latest Firefox on Windows
"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:135.0) Gecko/20100101 Firefox/135.0",
// Latest Firefox on Linux

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.
*/
import React from "react";
import * as React from "react";
import { render } from "jest-matrix-react";
import SdkConfig from "../../../../src/SdkConfig";

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