Compare commits
25 Commits
hs/identif
...
hs/add-hid
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d7a185baea | ||
|
|
93009d4613 | ||
|
|
71257d97e7 | ||
|
|
60eeb8a7de | ||
|
|
571a2e373d | ||
|
|
d0b8564660 | ||
|
|
28ea91566a | ||
|
|
ef32747473 | ||
|
|
7696516e8b | ||
|
|
46b1234a1d | ||
|
|
b9c0d63e3e | ||
|
|
cf7e52c6fc | ||
|
|
e87eb127ee | ||
|
|
83e421daf2 | ||
|
|
d6fb24dea7 | ||
|
|
a518c8d662 | ||
|
|
c759e516bd | ||
|
|
c8b55c3dfe | ||
|
|
7197093744 | ||
|
|
4e34adb854 | ||
|
|
72c2a3eb07 | ||
|
|
4d290461c4 | ||
|
|
0cc06450d7 | ||
|
|
9376d71831 | ||
|
|
6d5442a87b |
@@ -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*
|
||||
|
||||
11
.github/workflows/build_develop.yml
vendored
@@ -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 }}
|
||||
|
||||
4
.github/workflows/docker.yaml
vendored
@@ -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
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
@@ -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
|
||||
|
||||
10
package.json
@@ -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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
});
|
||||
|
||||
|
Before Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 24 KiB |
|
Before Width: | Height: | Size: 7.9 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 2.2 KiB After Width: | Height: | Size: 2.2 KiB |
|
Before Width: | Height: | Size: 80 KiB After Width: | Height: | Size: 80 KiB |
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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%;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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: "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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()),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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())),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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)}
|
||||
|
||||
@@ -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} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
82
src/hooks/useSlidingSyncRoomSearch.ts
Normal 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;
|
||||
};
|
||||
@@ -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).",
|
||||
|
||||
@@ -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 don’t have direct chats with anyone yet",
|
||||
"no_people_description": "You can deselect filters in order to see your other chats",
|
||||
"no_rooms": "You’re not in any room yet",
|
||||
"no_rooms_description": "You can deselect filters in order to see your other chats",
|
||||
"no_unread": "Congrats! You don’t 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?",
|
||||
|
||||
@@ -64,7 +64,6 @@
|
||||
"go": "C’est 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 d’aucun 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 l’ajout 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 l’ajout 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 l’espace",
|
||||
"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 d’options",
|
||||
"open_room": "Ouvrir salon %(roomName)s"
|
||||
},
|
||||
"show_less": "En voir moins",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
391
src/stores/room-list/SlidingRoomListStore.ts
Normal 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> {}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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";
|
||||
|
||||