Compare commits
53 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 186f7e71be | |||
| 9eb90a8204 | |||
| 9f560f1f89 | |||
| 8e3fb5288b | |||
| 160a7c1ae3 | |||
| c3c04323e1 | |||
| d6a1d9aa3d | |||
| a8ca4ff90c | |||
| 83e6753c4e | |||
| adc110a8d9 | |||
| 6329f69557 | |||
| ce4b9860a8 | |||
| 714f8f40dd | |||
| 22d5c00174 | |||
| 5e7b58a722 | |||
| ee59849307 | |||
| 5933f50930 | |||
| f6a3a429f7 | |||
| 5dba03dff2 | |||
| 75d9898dff | |||
| da6ac36f11 | |||
| c1f145d802 | |||
| 81260fef57 | |||
| 09ceb3c580 | |||
| 1077729a19 | |||
| 4f32727829 | |||
| fd455179f7 | |||
| 6767e4d6ad | |||
| 1743257ca0 | |||
| d2fdd45c47 | |||
| f250575b08 | |||
| db9428de87 | |||
| b511bf064d | |||
| 427e61309b | |||
| aa821a5b6f | |||
| 8b06714a02 | |||
| 079e1fcbc8 | |||
| 07c1b406ac | |||
| af9bde5137 | |||
| ee8c1ffef4 | |||
| fa1043426a | |||
| 18a7250cf9 | |||
| 7e5f96c85d | |||
| a8e0b54d8a | |||
| 302e3e153e | |||
| 7642054b74 | |||
| f6955124ac | |||
| 9207f25dc3 | |||
| f476da8bec | |||
| 34e08af274 | |||
| 6c4bd0c8b1 | |||
| 88e06cdc55 | |||
| 8e3830acee |
@@ -30,6 +30,10 @@ module.exports = {
|
||||
["window.innerHeight", "window.innerWidth", "window.visualViewport"],
|
||||
"Use UIStore to access window dimensions instead.",
|
||||
),
|
||||
...buildRestrictedPropertiesOptions(
|
||||
["React.forwardRef", "*.forwardRef", "forwardRef"],
|
||||
"Use ref props instead.",
|
||||
),
|
||||
...buildRestrictedPropertiesOptions(
|
||||
["*.mxcUrlToHttp", "*.getHttpUriForMxc"],
|
||||
"Use Media helper instead to centralise access for customisation.",
|
||||
@@ -55,6 +59,11 @@ module.exports = {
|
||||
"error",
|
||||
{
|
||||
paths: [
|
||||
{
|
||||
name: "react",
|
||||
importNames: ["forwardRef"],
|
||||
message: "Use ref props instead.",
|
||||
},
|
||||
{
|
||||
name: "@testing-library/react",
|
||||
message: "Please use jest-matrix-react instead",
|
||||
|
||||
@@ -132,7 +132,7 @@ jobs:
|
||||
cosign sign --yes ${images}
|
||||
|
||||
- name: Update repo description
|
||||
uses: peter-evans/dockerhub-description@0505d8b04853a30189aee66f5bb7fd1511bbac71 # v4
|
||||
uses: peter-evans/dockerhub-description@432a30c9e07499fd01da9f8a49f0faf9e0ca5b77 # v4
|
||||
if: github.event_name != 'pull_request'
|
||||
continue-on-error: true
|
||||
with:
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
repo: matrix-org/matrix-js-sdk
|
||||
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
wait-interval: 10
|
||||
check-name: draft
|
||||
check-name: "draft / draft"
|
||||
allowed-conclusions: success
|
||||
|
||||
- name: Wait for element-web draft
|
||||
@@ -111,7 +111,7 @@ jobs:
|
||||
repo: element-hq/element-web
|
||||
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
wait-interval: 10
|
||||
check-name: draft
|
||||
check-name: "draft / draft"
|
||||
allowed-conclusions: success
|
||||
|
||||
- name: Wait for element-desktop draft
|
||||
@@ -122,5 +122,5 @@ jobs:
|
||||
repo: element-hq/element-desktop
|
||||
repo-token: ${{ secrets.ELEMENT_BOT_TOKEN }}
|
||||
wait-interval: 10
|
||||
check-name: draft
|
||||
check-name: "draft / draft"
|
||||
allowed-conclusions: success
|
||||
|
||||
@@ -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@9b1d102b3c32583174557f58c53e3b09d43d1b1d
|
||||
uses: guibranco/github-status-action-v2@5f2b01ce1394109f70954ae6b69ef41cf7928e63
|
||||
with:
|
||||
authToken: ${{ secrets.GITHUB_TOKEN }}
|
||||
state: success
|
||||
|
||||
@@ -11,7 +11,8 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
if: |
|
||||
contains(github.event.issue.assignees.*.login, 't3chguy') ||
|
||||
contains(github.event.issue.assignees.*.login, 'andybalaam') ||
|
||||
contains(github.event.issue.assignees.*.login, 'florianduros') ||
|
||||
contains(github.event.issue.assignees.*.login, 'dbkr') ||
|
||||
contains(github.event.issue.assignees.*.login, 'MidhunSureshR')
|
||||
steps:
|
||||
- uses: actions/add-to-project@main
|
||||
|
||||
@@ -1,3 +1,26 @@
|
||||
Changes in [1.11.100](https://github.com/element-hq/element-web/releases/tag/v1.11.100) (2025-05-06)
|
||||
====================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Move rich topics out of labs / stabilise MSC3765 ([#29817](https://github.com/element-hq/element-web/pull/29817)). Contributed by @Johennes.
|
||||
* Spell out that Element Web does \*not\* work on mobile. ([#29211](https://github.com/element-hq/element-web/pull/29211)). Contributed by @ara4n.
|
||||
* Add message preview support to the new room list ([#29784](https://github.com/element-hq/element-web/pull/29784)). Contributed by @dbkr.
|
||||
* Global configuration flag for media previews ([#29582](https://github.com/element-hq/element-web/pull/29582)). Contributed by @Half-Shot.
|
||||
* New room list: add partial keyboard shortcuts support ([#29783](https://github.com/element-hq/element-web/pull/29783)). Contributed by @florianduros.
|
||||
* MVVM RoomSummaryCard Topic ([#29710](https://github.com/element-hq/element-web/pull/29710)). Contributed by @MarcWadai.
|
||||
* Warn on self change from settings > roles ([#28926](https://github.com/element-hq/element-web/pull/28926)). Contributed by @MarcWadai.
|
||||
* New room list: new visual for invitation ([#29773](https://github.com/element-hq/element-web/pull/29773)). Contributed by @florianduros.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Fix incorrect display of the user info display name ([#29826](https://github.com/element-hq/element-web/pull/29826)). Contributed by @langleyd.
|
||||
* RoomListStore: Remove invite rooms on decline ([#29804](https://github.com/element-hq/element-web/pull/29804)). Contributed by @MidhunSureshR.
|
||||
* Fix the buttons not being displayed with long preview text ([#29811](https://github.com/element-hq/element-web/pull/29811)). Contributed by @dbkr.
|
||||
* New room list: fix missing/incorrect notification decoration ([#29796](https://github.com/element-hq/element-web/pull/29796)). Contributed by @florianduros.
|
||||
* New Room List: Prevent potential scroll jump/flicker when switching spaces ([#29781](https://github.com/element-hq/element-web/pull/29781)). Contributed by @MidhunSureshR.
|
||||
* New room list: fix incorrect decoration ([#29770](https://github.com/element-hq/element-web/pull/29770)). Contributed by @florianduros.
|
||||
|
||||
|
||||
Changes in [1.11.99](https://github.com/element-hq/element-web/releases/tag/v1.11.99) (2025-04-23)
|
||||
==================================================================================================
|
||||
No changes, just bumping the version to accommodate a new Element Desktop release
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# syntax=docker.io/docker/dockerfile:1.14-labs
|
||||
# syntax=docker.io/docker/dockerfile:1.15-labs
|
||||
|
||||
# Builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-bullseye AS builder
|
||||
|
||||
@@ -101,10 +101,6 @@ Under the hood this stops Element Web from adding the `perParticipantE2EE` flag
|
||||
|
||||
This is useful while we experiment with encryption and to make calling compatible with platforms that don't use encryption yet.
|
||||
|
||||
## Rich text in room topics (`feature_html_topic`) [In Development]
|
||||
|
||||
Enables rendering of MD / HTML in room topics.
|
||||
|
||||
## Enable the notifications panel in the room header (`feature_notifications`)
|
||||
|
||||
Unreliable in encrypted rooms.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.11.99",
|
||||
"version": "1.11.100",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
@@ -68,14 +68,14 @@
|
||||
"postinstall": "patch-package"
|
||||
},
|
||||
"resolutions": {
|
||||
"**/pretty-format/react-is": "19.0.0",
|
||||
"**/pretty-format/react-is": "19.1.0",
|
||||
"@playwright/test": "1.51.1",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/react": "19.1.1",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"oidc-client-ts": "3.2.0",
|
||||
"jwt-decode": "4.0.0",
|
||||
"caniuse-lite": "1.0.30001707",
|
||||
"testcontainers": "10.23.0",
|
||||
"caniuse-lite": "1.0.30001714",
|
||||
"testcontainers": "10.24.2",
|
||||
"wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0",
|
||||
"wrap-ansi": "npm:wrap-ansi@^7.0.0"
|
||||
},
|
||||
@@ -93,7 +93,7 @@
|
||||
"@types/png-chunks-extract": "^1.0.2",
|
||||
"@types/react-virtualized": "^9.21.30",
|
||||
"@vector-im/compound-design-tokens": "^4.0.0",
|
||||
"@vector-im/compound-web": "^7.10.1",
|
||||
"@vector-im/compound-web": "^7.10.2",
|
||||
"@vector-im/matrix-wysiwyg": "2.38.3",
|
||||
"@zxcvbn-ts/core": "^3.0.4",
|
||||
"@zxcvbn-ts/language-common": "^3.0.4",
|
||||
@@ -130,7 +130,7 @@
|
||||
"maplibre-gl": "^5.0.0",
|
||||
"matrix-encrypt-attachment": "^1.0.3",
|
||||
"matrix-events-sdk": "0.0.1",
|
||||
"matrix-js-sdk": "37.4.0",
|
||||
"matrix-js-sdk": "37.5.0",
|
||||
"matrix-widget-api": "^1.10.0",
|
||||
"memoize-one": "^6.0.0",
|
||||
"mime": "^4.0.4",
|
||||
@@ -138,7 +138,7 @@
|
||||
"opus-recorder": "^8.0.3",
|
||||
"pako": "^2.0.3",
|
||||
"png-chunks-extract": "^1.0.0",
|
||||
"posthog-js": "1.157.2",
|
||||
"posthog-js": "1.236.1",
|
||||
"qrcode": "1.5.4",
|
||||
"re-resizable": "6.11.2",
|
||||
"react": "^19.0.0",
|
||||
@@ -185,8 +185,9 @@
|
||||
"@peculiar/webcrypto": "^1.4.3",
|
||||
"@playwright/test": "^1.50.1",
|
||||
"@principalstudio/html-webpack-inject-preload": "^1.2.7",
|
||||
"@rrweb/types": "^2.0.0-alpha.18",
|
||||
"@sentry/webpack-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^3.0.0",
|
||||
"@stylistic/eslint-plugin": "^4.0.0",
|
||||
"@svgr/webpack": "^8.0.0",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/jest-dom": "^6.4.8",
|
||||
@@ -211,9 +212,9 @@
|
||||
"@types/node-fetch": "^2.6.2",
|
||||
"@types/pako": "^2.0.0",
|
||||
"@types/qrcode": "^1.3.5",
|
||||
"@types/react": "19.0.10",
|
||||
"@types/react": "19.1.1",
|
||||
"@types/react-beautiful-dnd": "^13.0.0",
|
||||
"@types/react-dom": "19.0.4",
|
||||
"@types/react-dom": "19.1.2",
|
||||
"@types/react-transition-group": "^4.4.0",
|
||||
"@types/sanitize-html": "2.15.0",
|
||||
"@types/semver": "^7.5.8",
|
||||
@@ -246,7 +247,7 @@
|
||||
"eslint-plugin-react-compiler": "^19.0.0-beta-df7b47d-20241124",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-unicorn": "^56.0.0",
|
||||
"express": "^4.18.2",
|
||||
"express": "^5.0.0",
|
||||
"fake-indexeddb": "^6.0.0",
|
||||
"fetch-mock": "9.11.0",
|
||||
"fetch-mock-jest": "^1.5.1",
|
||||
@@ -286,13 +287,13 @@
|
||||
"semver": "^7.5.2",
|
||||
"source-map-loader": "^5.0.0",
|
||||
"stylelint": "^16.13.0",
|
||||
"stylelint-config-standard": "^37.0.0",
|
||||
"stylelint-config-standard": "^38.0.0",
|
||||
"stylelint-scss": "^6.0.0",
|
||||
"stylelint-value-no-unknown-custom-properties": "^6.0.1",
|
||||
"terser-webpack-plugin": "^5.3.9",
|
||||
"testcontainers": "^10.20.0",
|
||||
"ts-node": "^10.9.1",
|
||||
"typescript": "5.8.2",
|
||||
"typescript": "5.8.3",
|
||||
"util": "^0.12.5",
|
||||
"web-streams-polyfill": "^4.0.0",
|
||||
"webpack": "^5.89.0",
|
||||
|
||||
@@ -140,29 +140,35 @@ test.describe("Room list filters and sort", () => {
|
||||
expect(await roomList.locator("role=gridcell").count()).toBe(3);
|
||||
});
|
||||
|
||||
test("unread filter should only match unread rooms that have a count", async ({ page, app, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
test(
|
||||
"unread filter should only match unread rooms that have a count",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, app, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
// Let's configure unread dm room so that we only get notification for mentions and keywords
|
||||
await app.viewRoomById(unReadDmId);
|
||||
await app.settings.openRoomSettings("Notifications");
|
||||
await page.getByText("@mentions & keywords").click();
|
||||
await app.settings.closeDialog();
|
||||
// Let's configure unread dm room so that we only get notification for mentions and keywords
|
||||
await app.viewRoomById(unReadDmId);
|
||||
await app.settings.openRoomSettings("Notifications");
|
||||
await page.getByText("@mentions & keywords").click();
|
||||
await app.settings.closeDialog();
|
||||
|
||||
// Let's open a room other than unread room or unread dm
|
||||
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click();
|
||||
// Let's open a room other than unread room or unread dm
|
||||
await roomListView.getByRole("gridcell", { name: "Open room favourite room" }).click();
|
||||
|
||||
// Let's make the bot send a new message in both rooms
|
||||
await bot.sendMessage(unReadDmId, "Hello!");
|
||||
await bot.sendMessage(unReadRoomId, "Hello!");
|
||||
// Let's make the bot send a new message in both rooms
|
||||
await bot.sendMessage(unReadDmId, "Hello!");
|
||||
await bot.sendMessage(unReadRoomId, "Hello!");
|
||||
|
||||
// Let's activate the unread filter now
|
||||
await page.getByRole("option", { name: "Unread" }).click();
|
||||
// Let's activate the unread filter now
|
||||
await page.getByRole("option", { name: "Unread" }).click();
|
||||
|
||||
// Unread filter should only show unread room and not unread dm!
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room unread room" })).toBeVisible();
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
|
||||
});
|
||||
// Unread filter should only show unread room and not unread dm!
|
||||
const unreadDm = roomListView.getByRole("gridcell", { name: "Open room unread room" });
|
||||
await expect(unreadDm).toBeVisible();
|
||||
await expect(unreadDm).toMatchScreenshot("unread-dm.png");
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room unread dm" })).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.describe("Empty room list", () => {
|
||||
|
||||
@@ -142,6 +142,47 @@ test.describe("Room list", () => {
|
||||
await filters.getByRole("option", { name: "People" }).click();
|
||||
await expect(roomListView.getByRole("gridcell", { name: "Open room room0" })).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("Shortcuts", () => {
|
||||
test("should select the next room", async ({ page, app, user }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
||||
await page.keyboard.press("Alt+ArrowDown");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "room28", level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should select the previous room", async ({ page, app, user }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room28" }).click();
|
||||
await page.keyboard.press("Alt+ArrowUp");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "room29", level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should select the last room", async ({ page, app, user }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room29" }).click();
|
||||
await page.keyboard.press("Alt+ArrowUp");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "room0", level: 1 })).toBeVisible();
|
||||
});
|
||||
|
||||
test("should select the next unread room", async ({ page, app, user, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
const roomId = await app.client.createRoom({ name: "1 notification" });
|
||||
await app.client.inviteUser(roomId, bot.credentials.userId);
|
||||
await bot.joinRoom(roomId);
|
||||
await bot.sendMessage(roomId, "I am a robot. Beep.");
|
||||
|
||||
await roomListView.getByRole("gridcell", { name: "Open room room20" }).click();
|
||||
|
||||
await page.keyboard.press("Alt+Shift+ArrowDown");
|
||||
|
||||
await expect(page.getByRole("heading", { name: "1 notification", level: 1 })).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
test.describe("Avatar decoration", () => {
|
||||
@@ -230,6 +271,22 @@ test.describe("Room list", () => {
|
||||
await expect(room).toMatchScreenshot("room-list-item-mention.png");
|
||||
});
|
||||
|
||||
test("should render a message preview", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
await page.getByRole("button", { name: "Room Options" }).click();
|
||||
await page.getByRole("menuitemcheckbox", { name: "Show message previews" }).click();
|
||||
|
||||
const roomId = await app.client.createRoom({ name: "activity" });
|
||||
await app.client.inviteUser(roomId, bot.credentials.userId);
|
||||
await bot.joinRoom(roomId);
|
||||
await bot.sendMessage(roomId, "I am a robot. Beep.");
|
||||
|
||||
const room = roomListView.getByRole("gridcell", { name: "activity" });
|
||||
await expect(room.getByText("I am a robot. Beep.")).toBeVisible();
|
||||
await expect(room).toMatchScreenshot("room-list-item-message-preview.png");
|
||||
});
|
||||
|
||||
test("should render an activity decoration", { tag: "@screenshot" }, async ({ page, app, user, bot }) => {
|
||||
const roomListView = getRoomList(page);
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { type Locator, type Page } from "@playwright/test";
|
||||
import { test, expect } from "../../element-web-test";
|
||||
import { checkRoomSummaryCard, viewRoomSummaryByName } from "./utils";
|
||||
import { isDendrite } from "../../plugins/homeserver/dendrite";
|
||||
import { Bot } from "../../pages/bot";
|
||||
|
||||
const ROOM_NAME = "Test room";
|
||||
const ROOM_NAME_LONG =
|
||||
@@ -21,20 +22,23 @@ const ROOM_NAME_LONG =
|
||||
"officia deserunt mollit anim id est laborum.";
|
||||
const SPACE_NAME = "Test space";
|
||||
const NAME = "Alice";
|
||||
const LONG_NAME = "Bob long long long long long long long long long long long long long long long name";
|
||||
|
||||
const ROOM_ADDRESS_LONG =
|
||||
"loremIpsumDolorSitAmetConsecteturAdipisicingElitSedDoEiusmodTemporIncididuntUtLaboreEtDoloreMagnaAliqua";
|
||||
|
||||
function getMemberTileByName(page: Page, name: string): Locator {
|
||||
return page.locator(`.mx_MemberTileView, [title="${name}"]`);
|
||||
return page.locator(".mx_MemberListView .mx_MemberTileView_name").filter({ hasText: name });
|
||||
}
|
||||
|
||||
test.describe("RightPanel", () => {
|
||||
let testRoomId: string;
|
||||
test.use({
|
||||
displayName: NAME,
|
||||
});
|
||||
|
||||
test.beforeEach(async ({ app, user }) => {
|
||||
await app.client.createRoom({ name: ROOM_NAME });
|
||||
testRoomId = await app.client.createRoom({ name: ROOM_NAME });
|
||||
await app.client.createSpace({ name: SPACE_NAME });
|
||||
});
|
||||
|
||||
@@ -134,6 +138,29 @@ test.describe("RightPanel", () => {
|
||||
await page.getByLabel("Room info").nth(1).click();
|
||||
await checkRoomSummaryCard(page, ROOM_NAME);
|
||||
});
|
||||
|
||||
test(
|
||||
"should handle viewing long room member name",
|
||||
{ tag: "@screenshot" },
|
||||
async ({ page, homeserver, app }) => {
|
||||
const bobLongName = new Bot(page, homeserver, { displayName: LONG_NAME });
|
||||
await bobLongName.prepareClient();
|
||||
await app.client.inviteUser(testRoomId, bobLongName.credentials.userId);
|
||||
await bobLongName.joinRoom(testRoomId);
|
||||
|
||||
await viewRoomSummaryByName(page, app, ROOM_NAME);
|
||||
|
||||
await page.locator(".mx_RightPanel").getByRole("menuitem", { name: "People" }).click();
|
||||
await expect(page.locator(".mx_MemberListView")).toBeVisible();
|
||||
|
||||
await getMemberTileByName(page, LONG_NAME).click();
|
||||
await expect(page.locator(".mx_UserInfo")).toBeVisible();
|
||||
await expect(page.locator(".mx_UserInfo_profile").getByText(LONG_NAME)).toBeVisible();
|
||||
|
||||
await expect(page.locator(".mx_UserInfo")).toMatchScreenshot("with-long-name.png");
|
||||
},
|
||||
);
|
||||
|
||||
test.describe("room reporting", () => {
|
||||
test.skip(isDendrite, "Dendrite does not implement room reporting");
|
||||
test("should handle reporting a room", { tag: "@screenshot" }, async ({ page, app }) => {
|
||||
|
||||
@@ -19,7 +19,14 @@ test.describe("Invites", () => {
|
||||
const roomId = await bot.createRoom({ is_direct: true });
|
||||
await bot.inviteUser(roomId, user.userId);
|
||||
await app.viewRoomByName("Bob");
|
||||
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png");
|
||||
await expect(page.locator(".mx_RoomView")).toMatchScreenshot("Invites_room_view.png", {
|
||||
// Hide the mxid, which is not stable.
|
||||
css: `
|
||||
.mx_RoomPreviewBar_inviter_mxid {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
});
|
||||
|
||||
test("should be able to decline an invite", async ({ page, homeserver, user, bot, app }) => {
|
||||
|
||||
@@ -37,6 +37,15 @@ test.describe("Roles & Permissions room settings tab", () => {
|
||||
// Change the role of Alice to Moderator (50)
|
||||
await combobox.selectOption("Moderator");
|
||||
await expect(combobox).toHaveValue("50");
|
||||
|
||||
// Should display a modal to warn that we are demoting the only admin user
|
||||
const modal = await page.locator(".mx_Dialog", {
|
||||
hasText: "Warning",
|
||||
});
|
||||
await expect(modal).toBeVisible();
|
||||
// Click on the continue button in the modal
|
||||
await modal.getByRole("button", { name: "Continue" }).click();
|
||||
|
||||
const respPromise = page.waitForRequest("**/state/**");
|
||||
await applyButton.click();
|
||||
await respPromise;
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
/*
|
||||
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 * as fs from "node:fs";
|
||||
import { type EventType, type MsgType, type RoomJoinRulesEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { test, expect } from "../../element-web-test";
|
||||
|
||||
const MEDIA_FILE = fs.readFileSync("playwright/sample-files/riot.png");
|
||||
|
||||
test.describe("Media preview settings", () => {
|
||||
test.use({
|
||||
displayName: "Alan",
|
||||
botCreateOpts: {
|
||||
displayName: "Bob",
|
||||
},
|
||||
room: async ({ app, page, homeserver, bot, user }, use) => {
|
||||
const mxc = (await bot.uploadContent(MEDIA_FILE, { name: "image.png", type: "image/png" })).content_uri;
|
||||
const roomId = await bot.createRoom({
|
||||
name: "Test room",
|
||||
invite: [user.userId],
|
||||
initial_state: [{ type: "m.room.avatar", content: { url: mxc }, state_key: "" }],
|
||||
});
|
||||
await bot.sendEvent(roomId, null, "m.room.message" as EventType, {
|
||||
msgtype: "m.image" as MsgType,
|
||||
body: "image.png",
|
||||
url: mxc,
|
||||
});
|
||||
|
||||
await use({ roomId });
|
||||
},
|
||||
});
|
||||
|
||||
test("should be able to hide avatars of inviters", { tag: "@screenshot" }, async ({ page, app, room, user }) => {
|
||||
let settings = await app.settings.openUserSettings("Preferences");
|
||||
await settings.getByLabel("Hide avatars of room and inviter").click();
|
||||
await app.closeDialog();
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(
|
||||
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
|
||||
).toMatchScreenshot("invite-no-avatar.png", {
|
||||
// Hide the mxid, which is not stable.
|
||||
css: `
|
||||
.mx_RoomPreviewBar_inviter_mxid {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
|
||||
).toMatchScreenshot("invite-room-tree-no-avatar.png");
|
||||
|
||||
// And then go back to being visible
|
||||
settings = await app.settings.openUserSettings("Preferences");
|
||||
await settings.getByLabel("Hide avatars of room and inviter").click();
|
||||
await app.closeDialog();
|
||||
await page.goto("#/home");
|
||||
await app.viewRoomById(room.roomId);
|
||||
await expect(
|
||||
page.getByRole("complementary").filter({ hasText: "Do you want to join Test room" }),
|
||||
).toMatchScreenshot("invite-with-avatar.png", {
|
||||
// Hide the mxid, which is not stable.
|
||||
css: `
|
||||
.mx_RoomPreviewBar_inviter_mxid {
|
||||
display: none !important;
|
||||
}
|
||||
`,
|
||||
});
|
||||
await expect(
|
||||
page.getByRole("tree", { name: "Rooms" }).getByRole("treeitem", { name: "Test room" }),
|
||||
).toMatchScreenshot("invite-room-tree-with-avatar.png");
|
||||
});
|
||||
|
||||
test("should be able to hide media in rooms globally", async ({ page, app, room, user }) => {
|
||||
const settings = await app.settings.openUserSettings("Preferences");
|
||||
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
|
||||
await app.closeDialog();
|
||||
await app.viewRoomById(room.roomId);
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(page.getByText("Show image")).toBeVisible();
|
||||
});
|
||||
test("should be able to hide media in non-private rooms globally", async ({ page, app, room, user, bot }) => {
|
||||
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
|
||||
join_rule: "public",
|
||||
});
|
||||
const settings = await app.settings.openUserSettings("Preferences");
|
||||
await settings.getByLabel("Show media in timeline").getByLabel("In private rooms").click();
|
||||
await app.closeDialog();
|
||||
await app.viewRoomById(room.roomId);
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(page.getByText("Show image")).toBeVisible();
|
||||
for (const joinRule of ["invite", "knock", "restricted"] as RoomJoinRulesEventContent["join_rule"][]) {
|
||||
await bot.sendStateEvent(room.roomId, "m.room.join_rules", {
|
||||
join_rule: joinRule,
|
||||
} satisfies RoomJoinRulesEventContent);
|
||||
await expect(page.getByText("Show image")).not.toBeVisible();
|
||||
}
|
||||
});
|
||||
test("should be able to show media in rooms globally", async ({ page, app, room, user }) => {
|
||||
const settings = await app.settings.openUserSettings("Preferences");
|
||||
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
|
||||
await app.closeDialog();
|
||||
await app.viewRoomById(room.roomId);
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
await expect(page.getByText("Show image")).not.toBeVisible();
|
||||
});
|
||||
test("should be able to hide media in an individual room", async ({ page, app, room, user }) => {
|
||||
const settings = await app.settings.openUserSettings("Preferences");
|
||||
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
|
||||
await app.closeDialog();
|
||||
|
||||
await app.viewRoomById(room.roomId);
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
const roomSettings = await app.settings.openRoomSettings("General");
|
||||
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
|
||||
await app.closeDialog();
|
||||
|
||||
await expect(page.getByText("Show image")).toBeVisible();
|
||||
});
|
||||
test("should be able to show media in an individual room", async ({ page, app, room, user }) => {
|
||||
const settings = await app.settings.openUserSettings("Preferences");
|
||||
await settings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always hide" }).click();
|
||||
await app.closeDialog();
|
||||
|
||||
await app.viewRoomById(room.roomId);
|
||||
await page.getByRole("button", { name: "Accept" }).click();
|
||||
|
||||
const roomSettings = await app.settings.openRoomSettings("General");
|
||||
await roomSettings.getByLabel("Show media in timeline").getByRole("radio", { name: "Always show" }).click();
|
||||
await app.closeDialog();
|
||||
|
||||
await expect(page.getByText("Show image")).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
Before Width: | Height: | Size: 24 KiB After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
|
Before Width: | Height: | Size: 31 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 79 KiB |
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 76 KiB |
|
Before Width: | Height: | Size: 75 KiB After Width: | Height: | Size: 74 KiB |
|
Before Width: | Height: | Size: 23 KiB After Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 247 KiB After Width: | Height: | Size: 241 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 2.8 KiB |
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 20 KiB After Width: | Height: | Size: 20 KiB |
@@ -279,6 +279,7 @@
|
||||
@import "./views/rooms/RoomListPanel/_RoomListPanel.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListPrimaryFilters.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListSearch.pcss";
|
||||
@import "./views/rooms/RoomListPanel/_RoomListSecondaryFilters.pcss";
|
||||
@import "./views/rooms/_AppsDrawer.pcss";
|
||||
@import "./views/rooms/_Autocomplete.pcss";
|
||||
@import "./views/rooms/_AuxPanel.pcss";
|
||||
@@ -378,6 +379,7 @@
|
||||
@import "./views/settings/tabs/user/_AppearanceUserSettingsTab.pcss";
|
||||
@import "./views/settings/tabs/user/_HelpUserSettingsTab.pcss";
|
||||
@import "./views/settings/tabs/user/_KeyboardUserSettingsTab.pcss";
|
||||
@import "./views/settings/tabs/user/_MediaPreviewAccountSettings.pcss";
|
||||
@import "./views/settings/tabs/user/_MjolnirUserSettingsTab.pcss";
|
||||
@import "./views/settings/tabs/user/_PreferencesUserSettingsTab.pcss";
|
||||
@import "./views/settings/tabs/user/_SecurityUserSettingsTab.pcss";
|
||||
|
||||
@@ -16,7 +16,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
border: 1px solid $quinary-content;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0px 1px 3px rgba(23, 25, 28, 0.05);
|
||||
box-shadow: 0px 1px 3px rgb(23, 25, 28, 0.05);
|
||||
|
||||
background-color: $system;
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
border-radius: 50%;
|
||||
filter: drop-shadow(0px 3px 5px rgba(0, 0, 0, 0.2));
|
||||
filter: drop-shadow(0px 3px 5px rgb(0, 0, 0, 0.2));
|
||||
background-color: currentColor;
|
||||
|
||||
display: flex;
|
||||
|
||||
@@ -24,7 +24,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
height: $ZoomButtons_button-size;
|
||||
width: $ZoomButtons_button-size;
|
||||
background: $background;
|
||||
box-shadow: 0px 4px 12px rgba(0, 0, 0, 0.25);
|
||||
box-shadow: 0px 4px 12px rgb(0, 0, 0, 0.25);
|
||||
|
||||
.mx_ZoomButtons_icon {
|
||||
$ZoomButtons_icon-size: 12px;
|
||||
|
||||
@@ -48,7 +48,7 @@ $height: 220px;
|
||||
display: flex;
|
||||
font-size: $font-12px;
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
background: linear-gradient(rgba(0, 0, 0, 0.9), rgba(0, 0, 0, 0));
|
||||
background: linear-gradient(rgb(0, 0, 0, 0.9), rgb(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
.mx_WidgetPip_backButton {
|
||||
@@ -69,5 +69,5 @@ $height: 220px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.9));
|
||||
background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.9));
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
.mx_ContextualMenu {
|
||||
border-radius: 12px;
|
||||
box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1);
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
|
||||
color: $primary-content;
|
||||
|
||||
@@ -41,7 +41,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
padding-bottom: 10px;
|
||||
|
||||
border: 1px solid $quinary-content;
|
||||
box-shadow: 0 1px 3px rgba(23, 25, 28, 0.05);
|
||||
box-shadow: 0 1px 3px rgb(23, 25, 28, 0.05);
|
||||
}
|
||||
|
||||
.mx_ContextualMenu_chevron_top {
|
||||
|
||||
@@ -293,7 +293,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
> hr {
|
||||
border: none;
|
||||
height: 1px;
|
||||
background-color: rgba(141, 151, 165, 0.2);
|
||||
background-color: rgb(141, 151, 165, 0.2);
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -352,9 +352,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
mask-image: linear-gradient(
|
||||
to top,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 30%) 4px,
|
||||
rgba(255, 255, 255, 55%) 8px,
|
||||
rgba(255, 255, 255, 75%) 12px,
|
||||
rgb(255, 255, 255, 30%) 4px,
|
||||
rgb(255, 255, 255, 55%) 8px,
|
||||
rgb(255, 255, 255, 75%) 12px,
|
||||
black 16px
|
||||
);
|
||||
}
|
||||
@@ -370,9 +370,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
linear-gradient(
|
||||
to top,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 30%) 4px,
|
||||
rgba(255, 255, 255, 55%) 8px,
|
||||
rgba(255, 255, 255, 75%) 12px,
|
||||
rgb(255, 255, 255, 30%) 4px,
|
||||
rgb(255, 255, 255, 55%) 8px,
|
||||
rgb(255, 255, 255, 75%) 12px,
|
||||
black 16px
|
||||
);
|
||||
mask-position:
|
||||
|
||||
@@ -19,12 +19,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
background-image:
|
||||
radial-gradient(
|
||||
53.85% 66.75% at 87.55% 0%,
|
||||
hsla(250deg, 76%, 71%, 0.261) 0%,
|
||||
hsla(250deg, 100%, 88%, 0) 100%
|
||||
hsl(250deg, 76%, 71%, 0.261) 0%,
|
||||
hsl(250deg, 100%, 88%, 0) 100%
|
||||
),
|
||||
radial-gradient(41.93% 41.93% at 0% 0%, hsla(222deg, 29%, 20%, 0.28) 0%, hsla(250deg, 100%, 88%, 0) 100%),
|
||||
radial-gradient(100% 100% at 0% 0%, hsla(250deg, 100%, 88%, 0.174) 0%, hsla(0deg, 100%, 86%, 0) 100%),
|
||||
radial-gradient(106.35% 96.26% at 100% 0%, hsla(250deg, 100%, 88%, 0.4) 0%, hsla(167deg, 76%, 82%, 0) 100%);
|
||||
radial-gradient(41.93% 41.93% at 0% 0%, hsl(222deg, 29%, 20%, 0.28) 0%, hsl(250deg, 100%, 88%, 0) 100%),
|
||||
radial-gradient(100% 100% at 0% 0%, hsl(250deg, 100%, 88%, 0.174) 0%, hsl(0deg, 100%, 86%, 0) 100%),
|
||||
radial-gradient(106.35% 96.26% at 100% 0%, hsl(250deg, 100%, 88%, 0.4) 0%, hsl(167deg, 76%, 82%, 0) 100%);
|
||||
/* blur to reduce color banding issues due to alpha-blending multiple gradients */
|
||||
filter: blur(8px);
|
||||
inset: -9px;
|
||||
@@ -34,8 +34,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
/* gradient to apply different amounts of dithering to different parts of the gradient */
|
||||
linear-gradient(
|
||||
to bottom,
|
||||
/* 10% dithering at the top */ rgba(0, 0, 0, 0.9) 20%,
|
||||
/* 80% dithering at the bottom */ rgba(0, 0, 0, 0.2) 100%
|
||||
/* 10% dithering at the top */ rgb(0, 0, 0, 0.9) 20%,
|
||||
/* 80% dithering at the bottom */ rgb(0, 0, 0, 0.2) 100%
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
grid-row: 2 / 4;
|
||||
grid-column: 1;
|
||||
background-color: $system;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.5);
|
||||
box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.5);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
@@ -30,7 +30,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
grid-column: 1;
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
color: $primary-content;
|
||||
box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1);
|
||||
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: 12px;
|
||||
overflow: hidden;
|
||||
|
||||
@@ -37,7 +37,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
justify-content: space-between;
|
||||
padding-top: 16px;
|
||||
margin-top: 16px;
|
||||
border-top: 1px solid rgba(141, 151, 165, 0.2);
|
||||
border-top: 1px solid rgb(141, 151, 165, 0.2);
|
||||
|
||||
> * {
|
||||
flex-basis: content;
|
||||
|
||||
@@ -11,7 +11,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
font: var(--cpd-font-body-md-regular);
|
||||
opacity: 0.72;
|
||||
padding: 20px 0;
|
||||
background: linear-gradient(rgba(0, 0, 0, 0), rgba(0, 0, 0, 0.8));
|
||||
background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.8));
|
||||
}
|
||||
|
||||
.mx_AuthFooter a:link,
|
||||
|
||||
@@ -19,7 +19,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
display: flex;
|
||||
margin: 100px auto auto;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 4px 0 rgba(0, 0, 0, 0.33);
|
||||
box-shadow: 0 2px 4px 0 rgb(0, 0, 0, 0.33);
|
||||
background-color: $authpage-modal-bg-color;
|
||||
|
||||
@media only screen and (max-height: 768px) {
|
||||
|
||||
@@ -77,7 +77,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
max-height: 80%;
|
||||
|
||||
.mx_CompoundDialog_footer {
|
||||
box-shadow: 0px -4px 4px rgba(0, 0, 0, 0.05); /* hardcoded colour for both themes */
|
||||
box-shadow: 0px -4px 4px rgb(0, 0, 0, 0.05); /* hardcoded colour for both themes */
|
||||
z-index: 1; /* needed to make footer & shadow appear above dialog content */
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,13 +34,13 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
.mx_EditHistoryMessage_deletion {
|
||||
color: rgb(255, 76, 85);
|
||||
background-color: rgba(255, 76, 85, 0.1);
|
||||
background-color: rgb(255, 76, 85, 0.1);
|
||||
text-decoration: line-through;
|
||||
}
|
||||
|
||||
.mx_EditHistoryMessage_insertion {
|
||||
color: rgb(26, 169, 123);
|
||||
background-color: rgba(26, 169, 123, 0.1);
|
||||
background-color: rgb(26, 169, 123, 0.1);
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
.mx_ServerPicker {
|
||||
margin-bottom: 14px;
|
||||
padding-bottom: $spacing-16;
|
||||
border-bottom: 1px solid rgba(141, 151, 165, 0.2);
|
||||
border-bottom: 1px solid rgb(141, 151, 165, 0.2);
|
||||
display: grid;
|
||||
grid-template-columns: auto min-content;
|
||||
grid-template-rows: auto auto auto;
|
||||
|
||||
@@ -20,7 +20,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
flex: 1;
|
||||
overflow-y: scroll;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(0, 0, 0, 0.2) transparent;
|
||||
scrollbar-color: rgb(0, 0, 0, 0.2) transparent;
|
||||
}
|
||||
|
||||
.mx_EmojiPicker_header {
|
||||
@@ -201,7 +201,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
|
||||
.mx_EmojiPicker_item_selected {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
color: rgb(0, 0, 0, 0.5);
|
||||
border: 1px solid $accent;
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
@@ -76,7 +76,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
pointer-events: none;
|
||||
|
||||
span {
|
||||
box-shadow: 0px 4px 15px rgba(0, 0, 0, 0.15);
|
||||
box-shadow: 0px 4px 15px rgb(0, 0, 0, 0.15);
|
||||
border-radius: 8px;
|
||||
padding: $spacing-8;
|
||||
background-color: $background;
|
||||
|
||||
@@ -25,7 +25,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
overflow: hidden;
|
||||
|
||||
/* Hardcoded colours because it's the same on all themes */
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
background-color: rgb(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
|
||||
@@ -99,7 +99,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
.mx_AccessibleButton_kind_secondary {
|
||||
color: $secondary-content;
|
||||
background-color: rgba(141, 151, 165, 0.2);
|
||||
background-color: rgb(141, 151, 165, 0.2);
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
padding-bottom: 10px;
|
||||
|
||||
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
|
||||
box-shadow: 0px 4px 24px rgba(0, 0, 0, 0.1);
|
||||
box-shadow: 0px 4px 24px rgb(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.mx_ContextualMenu_chevron_top {
|
||||
|
||||
@@ -30,7 +30,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
height: 775px;
|
||||
right: -253.77px;
|
||||
top: 0;
|
||||
background: radial-gradient(49.95% 49.95% at 50% 50%, rgba(13, 189, 139, 0.12) 0%, rgba(18, 115, 235, 0) 100%);
|
||||
background: radial-gradient(49.95% 49.95% at 50% 50%, rgb(13, 189, 139, 0.12) 0%, rgb(18, 115, 235, 0) 100%);
|
||||
transform: rotate(-89.69deg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
top: var(--cpd-space-2x); /* equal to padding-top of parent */
|
||||
left: 0;
|
||||
border-radius: 12px;
|
||||
background-color: rgba(141, 151, 165, 0.1);
|
||||
background-color: rgb(141, 151, 165, 0.1);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
box-shadow: 0 4px 24px 0 rgba(27, 29, 34, 0.1);
|
||||
box-shadow: 0 4px 24px 0 rgb(27, 29, 34, 0.1);
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
}
|
||||
|
||||
|
||||
@@ -109,6 +109,15 @@ Please see LICENSE files in the repository root for full details.
|
||||
font-size: $font-20px;
|
||||
line-height: $font-25px;
|
||||
|
||||
/* E2E icon wrapper */
|
||||
.mx_Flex > span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_UserInfo_profile_name {
|
||||
min-height: 30px;
|
||||
|
||||
/* limit to 2 lines, show an ellipsis if it overflows */
|
||||
/* this looks webkit specific but is supported by Firefox 68+ */
|
||||
display: -webkit-box;
|
||||
@@ -118,15 +127,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
overflow: hidden;
|
||||
word-break: break-all;
|
||||
text-overflow: ellipsis;
|
||||
|
||||
/* E2E icon wrapper */
|
||||
.mx_Flex > span {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_UserInfo_profile_name {
|
||||
height: 30px;
|
||||
}
|
||||
|
||||
.mx_UserInfo_profile_mxid {
|
||||
|
||||
@@ -20,10 +20,6 @@
|
||||
|
||||
&:hover {
|
||||
background-color: var(--cpd-color-bg-action-secondary-hovered);
|
||||
|
||||
.mx_RoomListItemView_content {
|
||||
padding-right: var(--cpd-space-1-5x);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_container {
|
||||
@@ -39,11 +35,23 @@
|
||||
box-sizing: border-box;
|
||||
min-width: 0;
|
||||
|
||||
.mx_RoomListItemView_text {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_roomName {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.mx_RoomListItemView_messagePreview {
|
||||
font: var(--cpd-font-body-sm-regular);
|
||||
color: var(--cpd-color-text-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
/*
|
||||
* 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_RoomListSecondaryFilters {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
margin: var(--cpd-space-2x);
|
||||
margin-left: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.mx_RoomListSecondaryFilters_roomOptionsButton {
|
||||
/* Size the button appropriately (should this be in em, maybe,
|
||||
* so it gets bigger with font size? These values taken from the figma.
|
||||
*/
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-left: auto;
|
||||
|
||||
svg {
|
||||
color: var(--cpd-color-icon-primary);
|
||||
}
|
||||
}
|
||||
@@ -312,7 +312,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
.mx_MessageTimestamp {
|
||||
border-radius: var(--MBody-border-radius);
|
||||
/* Hardcoded colours because it's the same on all themes */
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
background-color: rgb(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
padding: 0px 4px 0px 4px;
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
border-bottom: 1px solid var(--cpd-color-gray-400);
|
||||
|
||||
/* From figma */
|
||||
box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgba(27, 29, 34, 0.1);
|
||||
box-shadow: 0 var(--cpd-space-2x) var(--cpd-space-6x) calc(var(--cpd-space-2x) * -1) rgb(27, 29, 34, 0.1);
|
||||
|
||||
.mx_PinnedMessageBanner_main {
|
||||
background: transparent;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
padding: var(--cpd-space-10x);
|
||||
border-radius: var(--cpd-space-4x);
|
||||
/* From figma */
|
||||
box-shadow: 0 1.2px 2.4px 0 rgba(27, 29, 34, 0.15);
|
||||
box-shadow: 0 1.2px 2.4px 0 rgb(27, 29, 34, 0.15);
|
||||
border: 1px solid var(--cpd-color-gray-400);
|
||||
|
||||
.mx_EncryptionCard_header {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
/*
|
||||
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_MediaPreviewAccountSetting_Radio {
|
||||
margin: var(--cpd-space-1x) 0;
|
||||
}
|
||||
|
||||
.mx_MediaPreviewAccountSetting {
|
||||
margin-top: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.mx_MediaPreviewAccountSetting_RadioHelp {
|
||||
margin-top: 0;
|
||||
margin-bottom: var(--cpd-space-1x);
|
||||
}
|
||||
|
||||
.mx_MediaPreviewAccountSetting_Form {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx_MediaPreviewAccountSetting_ToggleSwitch {
|
||||
font: var(--cpd-font-body-md-medium);
|
||||
letter-spacing: var(--cpd-font-letter-spacing-body-md);
|
||||
}
|
||||
@@ -95,7 +95,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
height: 100%;
|
||||
left: 0;
|
||||
right: 0;
|
||||
background-color: rgba(0, 0, 0, 0.6);
|
||||
background-color: rgb(0, 0, 0, 0.6);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -144,7 +144,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
border-radius: 8px;
|
||||
|
||||
background-color: $system;
|
||||
box-shadow: 0px 4px 20px rgba(0, 0, 0, 0.2);
|
||||
box-shadow: 0px 4px 20px rgb(0, 0, 0, 0.2);
|
||||
|
||||
.mx_LegacyCallViewButtons {
|
||||
bottom: 13px;
|
||||
|
||||
@@ -56,7 +56,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
|
||||
background-color: rgba(0, 0, 0, 0.5); /* Same on both themes */
|
||||
background-color: rgb(0, 0, 0, 0.5); /* Same on both themes */
|
||||
border-radius: 100%;
|
||||
|
||||
&::before {
|
||||
|
||||
@@ -29,7 +29,6 @@ import type LegacyCallHandler from "../LegacyCallHandler";
|
||||
import type UserActivity from "../UserActivity";
|
||||
import { type ModalWidgetStore } from "../stores/ModalWidgetStore";
|
||||
import { type WidgetLayoutStore } from "../stores/widgets/WidgetLayoutStore";
|
||||
import type VoipUserMapper from "../VoipUserMapper";
|
||||
import { type SpaceStoreClass } from "../stores/spaces/SpaceStore";
|
||||
import type TypingStore from "../stores/TypingStore";
|
||||
import { type EventIndexPeg } from "../indexing/EventIndexPeg";
|
||||
@@ -113,7 +112,6 @@ declare global {
|
||||
mxLegacyCallHandler: LegacyCallHandler;
|
||||
mxUserActivity: UserActivity;
|
||||
mxModalWidgetStore: ModalWidgetStore;
|
||||
mxVoipUserMapper: VoipUserMapper;
|
||||
mxSpaceStore: SpaceStoreClass;
|
||||
mxVoiceRecordingStore: VoiceRecordingStore;
|
||||
mxTypingStore: TypingStore;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -14,6 +14,7 @@ import type { EncryptedFile } from "matrix-js-sdk/src/types";
|
||||
import type { EmptyObject } from "matrix-js-sdk/src/matrix";
|
||||
import type { DeviceClientInformation } from "../utils/device/types.ts";
|
||||
import type { UserWidget } from "../utils/WidgetUtils-types.ts";
|
||||
import { type MediaPreviewConfig } from "./media_preview.ts";
|
||||
|
||||
// Extend Matrix JS SDK types via Typescript declaration merging to support unspecced event fields and types
|
||||
declare module "matrix-js-sdk/src/types" {
|
||||
@@ -87,6 +88,8 @@ declare module "matrix-js-sdk/src/types" {
|
||||
"m.accepted_terms": {
|
||||
accepted: string[];
|
||||
};
|
||||
|
||||
"io.element.msc4278.media_preview_config": MediaPreviewConfig;
|
||||
}
|
||||
|
||||
export interface AudioContent {
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export enum MediaPreviewValue {
|
||||
/**
|
||||
* Media previews should be enabled.
|
||||
*/
|
||||
On = "on",
|
||||
/**
|
||||
* Media previews should only be enabled for rooms with non-public join rules.
|
||||
*/
|
||||
Private = "private",
|
||||
/**
|
||||
* Media previews should be disabled.
|
||||
*/
|
||||
Off = "off",
|
||||
}
|
||||
|
||||
export const MEDIA_PREVIEW_ACCOUNT_DATA_TYPE = "io.element.msc4278.media_preview_config";
|
||||
export interface MediaPreviewConfig extends Record<string, unknown> {
|
||||
/**
|
||||
* Media preview setting for thumbnails of media in rooms.
|
||||
*/
|
||||
media_previews: MediaPreviewValue;
|
||||
/**
|
||||
* Media preview settings for avatars of rooms we have been invited to.
|
||||
*/
|
||||
invite_avatars: MediaPreviewValue.On | MediaPreviewValue.Off;
|
||||
}
|
||||
@@ -6,16 +6,9 @@ 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 PropsWithChildren } from "react";
|
||||
|
||||
import type React from "react";
|
||||
import { type ComponentType } from "react";
|
||||
|
||||
declare module "react" {
|
||||
// Fix forwardRef types for Generic components - https://stackoverflow.com/a/58473012
|
||||
function forwardRef<T, P extends object>(
|
||||
render: (props: PropsWithChildren<P>, ref: React.ForwardedRef<T>) => React.ReactElement | null,
|
||||
): (props: P & React.RefAttributes<T>) => React.ReactElement | null;
|
||||
|
||||
// Fix lazy types - https://stackoverflow.com/a/71017028
|
||||
function lazy<T extends ComponentType<any>>(factory: () => Promise<{ default: T }>): T;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
Copyright 2019 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2017, 2018 New Vector Ltd
|
||||
@@ -294,6 +294,10 @@ export interface EventRenderOpts {
|
||||
disableBigEmoji?: boolean;
|
||||
stripReplyFallback?: boolean;
|
||||
forComposerQuote?: boolean;
|
||||
/**
|
||||
* Should inline media be rendered?
|
||||
*/
|
||||
mediaIsVisible?: boolean;
|
||||
}
|
||||
|
||||
function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: EventRenderOpts = {}): EventAnalysis {
|
||||
@@ -302,6 +306,20 @@ function analyseEvent(content: IContent, highlights: Optional<string[]>, opts: E
|
||||
sanitizeParams = composerSanitizeHtmlParams;
|
||||
}
|
||||
|
||||
if (opts.mediaIsVisible === false && sanitizeParams.transformTags?.["img"]) {
|
||||
// Prevent mutating the source of sanitizeParams.
|
||||
sanitizeParams = {
|
||||
...sanitizeParams,
|
||||
transformTags: {
|
||||
...sanitizeParams.transformTags,
|
||||
img: (tagName) => {
|
||||
// Remove element
|
||||
return { tagName, attribs: {} };
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const isFormattedBody =
|
||||
content.format === "org.matrix.custom.html" && typeof content.formatted_body === "string";
|
||||
@@ -462,10 +480,6 @@ export function topicToHtml(
|
||||
ref?: LegacyRef<HTMLSpanElement>,
|
||||
allowExtendedHtml = false,
|
||||
): ReactNode {
|
||||
if (!SettingsStore.getValue("feature_html_topic")) {
|
||||
htmlTopic = undefined;
|
||||
}
|
||||
|
||||
let isFormattedTopic = !!htmlTopic;
|
||||
let topicHasEmoji = false;
|
||||
let safeTopic = "";
|
||||
|
||||
@@ -39,14 +39,12 @@ import { WidgetMessagingStore } from "./stores/widgets/WidgetMessagingStore";
|
||||
import { ElementWidgetActions } from "./stores/widgets/ElementWidgetActions";
|
||||
import { UIFeature } from "./settings/UIFeature";
|
||||
import { Action } from "./dispatcher/actions";
|
||||
import VoipUserMapper from "./VoipUserMapper";
|
||||
import { addManagedHybridWidget, isManagedHybridWidgetEnabled } from "./widgets/ManagedHybrid";
|
||||
import SdkConfig from "./SdkConfig";
|
||||
import { ensureDMExists } from "./createRoom";
|
||||
import { Container, WidgetLayoutStore } from "./stores/widgets/WidgetLayoutStore";
|
||||
import IncomingLegacyCallToast, { getIncomingLegacyCallToastKey } from "./toasts/IncomingLegacyCallToast";
|
||||
import ToastStore from "./stores/ToastStore";
|
||||
import Resend from "./Resend";
|
||||
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||
import { InviteKind } from "./components/views/dialogs/InviteDialogTypes";
|
||||
import { type OpenInviteDialogPayload } from "./dispatcher/payloads/OpenInviteDialogPayload";
|
||||
@@ -59,8 +57,6 @@ import { Jitsi } from "./widgets/Jitsi.ts";
|
||||
|
||||
export const PROTOCOL_PSTN = "m.protocol.pstn";
|
||||
export const PROTOCOL_PSTN_PREFIXED = "im.vector.protocol.pstn";
|
||||
export const PROTOCOL_SIP_NATIVE = "im.vector.protocol.sip_native";
|
||||
export const PROTOCOL_SIP_VIRTUAL = "im.vector.protocol.sip_virtual";
|
||||
|
||||
const CHECK_PROTOCOLS_ATTEMPTS = 3;
|
||||
|
||||
@@ -107,27 +103,9 @@ const debuglog = (...args: any[]): void => {
|
||||
}
|
||||
};
|
||||
|
||||
interface ThirdpartyLookupResponseFields {
|
||||
/* eslint-disable camelcase */
|
||||
|
||||
// im.vector.sip_native
|
||||
virtual_mxid?: string;
|
||||
is_virtual?: boolean;
|
||||
|
||||
// im.vector.sip_virtual
|
||||
native_mxid?: string;
|
||||
is_native?: boolean;
|
||||
|
||||
// common
|
||||
lookup_success?: boolean;
|
||||
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
|
||||
interface ThirdpartyLookupResponse {
|
||||
userid: string;
|
||||
protocol: string;
|
||||
fields: ThirdpartyLookupResponseFields;
|
||||
}
|
||||
|
||||
export enum LegacyCallHandlerEvent {
|
||||
@@ -158,7 +136,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
private transferees = new Map<string, MatrixCall>(); // callId (target) -> call (transferee)
|
||||
private supportsPstnProtocol: boolean | null = null;
|
||||
private pstnSupportPrefixed: boolean | null = null; // True if the server only support the prefixed pstn protocol
|
||||
private supportsSipNativeVirtual: boolean | null = null; // im.vector.protocol.sip_virtual and im.vector.protocol.sip_native
|
||||
|
||||
// Map of the asserted identity users after we've looked them up using the API.
|
||||
// We need to be be able to determine the mapped room synchronously, so we
|
||||
@@ -179,8 +156,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
}
|
||||
|
||||
/*
|
||||
* Gets the user-facing room associated with a call (call.roomId may be the call "virtual room"
|
||||
* if a voip_mxid_translate_pattern is set in the config)
|
||||
* Gets the user-facing room associated with a call
|
||||
*/
|
||||
public roomIdForCall(call?: MatrixCall): string | null {
|
||||
if (!call) return null;
|
||||
@@ -195,7 +171,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
}
|
||||
}
|
||||
|
||||
return VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(call.roomId) ?? call.roomId ?? null;
|
||||
return call.roomId ?? null;
|
||||
}
|
||||
|
||||
public start(): void {
|
||||
@@ -278,12 +254,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
this.supportsPstnProtocol = null;
|
||||
}
|
||||
|
||||
if (protocols[PROTOCOL_SIP_NATIVE] !== undefined && protocols[PROTOCOL_SIP_VIRTUAL] !== undefined) {
|
||||
this.supportsSipNativeVirtual = Boolean(
|
||||
protocols[PROTOCOL_SIP_NATIVE] && protocols[PROTOCOL_SIP_VIRTUAL],
|
||||
);
|
||||
}
|
||||
|
||||
this.emit(LegacyCallHandlerEvent.ProtocolSupport);
|
||||
} catch (e) {
|
||||
if (maxTries === 1) {
|
||||
@@ -305,10 +275,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
return this.supportsPstnProtocol ?? false;
|
||||
}
|
||||
|
||||
public getSupportsVirtualRooms(): boolean | null {
|
||||
return this.supportsSipNativeVirtual;
|
||||
}
|
||||
|
||||
public async pstnLookup(phoneNumber: string): Promise<ThirdpartyLookupResponse[]> {
|
||||
try {
|
||||
return await MatrixClientPeg.safeGet().getThirdpartyUser(
|
||||
@@ -323,28 +289,6 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
}
|
||||
}
|
||||
|
||||
public async sipVirtualLookup(nativeMxid: string): Promise<ThirdpartyLookupResponse[]> {
|
||||
try {
|
||||
return await MatrixClientPeg.safeGet().getThirdpartyUser(PROTOCOL_SIP_VIRTUAL, {
|
||||
native_mxid: nativeMxid,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn("Failed to query SIP identity for user", e);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
public async sipNativeLookup(virtualMxid: string): Promise<ThirdpartyLookupResponse[]> {
|
||||
try {
|
||||
return await MatrixClientPeg.safeGet().getThirdpartyUser(PROTOCOL_SIP_NATIVE, {
|
||||
virtual_mxid: virtualMxid,
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn("Failed to query identity for SIP user", e);
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
}
|
||||
|
||||
private onCallIncoming = (call: MatrixCall): void => {
|
||||
// if the runtime env doesn't do VoIP, stop here.
|
||||
if (!MatrixClientPeg.get()?.supportsVoip()) {
|
||||
@@ -537,24 +481,16 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
}
|
||||
|
||||
const newAssertedIdentity = call.getRemoteAssertedIdentity()?.id;
|
||||
let newNativeAssertedIdentity = newAssertedIdentity;
|
||||
if (newAssertedIdentity) {
|
||||
const response = await this.sipNativeLookup(newAssertedIdentity);
|
||||
if (response.length && response[0].fields.lookup_success) {
|
||||
newNativeAssertedIdentity = response[0].userid;
|
||||
}
|
||||
}
|
||||
logger.log(`Asserted identity ${newAssertedIdentity} mapped to ${newNativeAssertedIdentity}`);
|
||||
|
||||
if (newNativeAssertedIdentity) {
|
||||
this.assertedIdentityNativeUsers.set(call.callId, newNativeAssertedIdentity);
|
||||
if (newAssertedIdentity) {
|
||||
this.assertedIdentityNativeUsers.set(call.callId, newAssertedIdentity);
|
||||
|
||||
// If we don't already have a room with this user, make one. This will be slightly odd
|
||||
// if they called us because we'll be inviting them, but there's not much we can do about
|
||||
// this if we want the actual, native room to exist (which we do). This is why it's
|
||||
// important to only obey asserted identity in trusted environments, since anyone you're
|
||||
// on a call with can cause you to send a room invite to someone.
|
||||
await ensureDMExists(MatrixClientPeg.safeGet(), newNativeAssertedIdentity);
|
||||
await ensureDMExists(MatrixClientPeg.safeGet(), newAssertedIdentity);
|
||||
|
||||
const newMappedRoomId = this.roomIdForCall(call);
|
||||
logger.log(`Old room ID: ${mappedRoomId}, new room ID: ${newMappedRoomId}`);
|
||||
@@ -810,24 +746,10 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
|
||||
private async placeMatrixCall(roomId: string, type: CallType, transferee?: MatrixCall): Promise<void> {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const mappedRoomId = (await VoipUserMapper.sharedInstance().getOrCreateVirtualRoomForRoom(roomId)) || roomId;
|
||||
logger.debug("Mapped real room " + roomId + " to room ID " + mappedRoomId);
|
||||
|
||||
// If we're using a virtual room nd there are any events pending, try to resend them,
|
||||
// otherwise the call will fail and because its a virtual room, the user won't be able
|
||||
// to see it to either retry or clear the pending events. There will only be call events
|
||||
// in this queue, and since we're about to place a new call, they can only be events from
|
||||
// previous calls that are probably stale by now, so just cancel them.
|
||||
if (mappedRoomId !== roomId) {
|
||||
const mappedRoom = cli.getRoom(mappedRoomId);
|
||||
if (mappedRoom?.getPendingEvents().length) {
|
||||
Resend.cancelUnsentEvents(mappedRoom);
|
||||
}
|
||||
}
|
||||
|
||||
const timeUntilTurnCresExpire = cli.getTurnServersExpiry() - Date.now();
|
||||
logger.log("Current turn creds expire in " + timeUntilTurnCresExpire + " ms");
|
||||
const call = cli.createCall(mappedRoomId)!;
|
||||
const call = cli.createCall(roomId)!;
|
||||
|
||||
try {
|
||||
this.addCallForRoom(roomId, call);
|
||||
@@ -978,19 +900,7 @@ export default class LegacyCallHandler extends TypedEventEmitter<LegacyCallHandl
|
||||
}
|
||||
const userId = results[0].userid;
|
||||
|
||||
// Now check to see if this is a virtual user, in which case we should find the
|
||||
// native user
|
||||
let nativeUserId;
|
||||
if (this.getSupportsVirtualRooms()) {
|
||||
const nativeLookupResults = await this.sipNativeLookup(userId);
|
||||
const lookupSuccess = nativeLookupResults.length > 0 && nativeLookupResults[0].fields.lookup_success;
|
||||
nativeUserId = lookupSuccess ? nativeLookupResults[0].userid : userId;
|
||||
logger.log("Looked up " + number + " to " + userId + " and mapped to native user " + nativeUserId);
|
||||
} else {
|
||||
nativeUserId = userId;
|
||||
}
|
||||
|
||||
const roomId = await ensureDMExists(MatrixClientPeg.safeGet(), nativeUserId);
|
||||
const roomId = await ensureDMExists(MatrixClientPeg.safeGet(), userId);
|
||||
if (!roomId) {
|
||||
throw new Error("Failed to ensure DM exists for dialing number");
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2024 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
@@ -12,7 +12,6 @@ import { merge } from "lodash";
|
||||
import _Linkify from "linkify-react";
|
||||
|
||||
import { _linkifyString, ELEMENT_URL_PATTERN, options as linkifyMatrixOptions } from "./linkify-matrix";
|
||||
import SettingsStore from "./settings/SettingsStore";
|
||||
import { tryTransformPermalinkToLocalHref } from "./utils/permalinks/Permalinks";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import { PERMITTED_URL_SCHEMES } from "./utils/UrlUtils";
|
||||
@@ -47,10 +46,7 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
|
||||
// Strip out imgs that aren't `mxc` here instead of using allowedSchemesByTag
|
||||
// because transformTags is used _before_ we filter by allowedSchemesByTag and
|
||||
// we don't want to allow images with `https?` `src`s.
|
||||
// We also drop inline images (as if they were not present at all) when the "show
|
||||
// images" preference is disabled. Future work might expose some UI to reveal them
|
||||
// like standalone image events have.
|
||||
if (!src || !SettingsStore.getValue("showImages")) {
|
||||
if (!src) {
|
||||
return { tagName, attribs: {} };
|
||||
}
|
||||
|
||||
@@ -78,7 +74,6 @@ export const transformTags: NonNullable<IOptions["transformTags"]> = {
|
||||
if (requestedHeight) {
|
||||
attribs.style += "height: 100%;";
|
||||
}
|
||||
|
||||
attribs.src = mediaFromMxc(src).getThumbnailOfSourceHttp(width, height)!;
|
||||
return { tagName, attribs };
|
||||
},
|
||||
|
||||
@@ -43,8 +43,6 @@ import { isPushNotifyDisabled } from "./settings/controllers/NotificationControl
|
||||
import UserActivity from "./UserActivity";
|
||||
import { mediaFromMxc } from "./customisations/Media";
|
||||
import ErrorDialog from "./components/views/dialogs/ErrorDialog";
|
||||
import LegacyCallHandler from "./LegacyCallHandler";
|
||||
import VoipUserMapper from "./VoipUserMapper";
|
||||
import { SdkContextClass } from "./contexts/SDKContext";
|
||||
import { localNotificationsAreSilenced, createLocalNotificationSettingsIfNeeded } from "./utils/notifications";
|
||||
import { getIncomingCallToastKey, IncomingCallToast } from "./toasts/IncomingCallToast";
|
||||
@@ -447,14 +445,7 @@ class NotifierClass extends TypedEventEmitter<keyof EmittedEvents, EmittedEvents
|
||||
|
||||
// XXX: exported for tests
|
||||
public evaluateEvent(ev: MatrixEvent): void {
|
||||
let roomId = ev.getRoomId()!;
|
||||
if (LegacyCallHandler.instance.getSupportsVirtualRooms()) {
|
||||
// Attempt to translate a virtual room to a native one
|
||||
const nativeRoomId = VoipUserMapper.sharedInstance().nativeRoomForVirtualRoom(roomId);
|
||||
if (nativeRoomId) {
|
||||
roomId = nativeRoomId;
|
||||
}
|
||||
}
|
||||
const roomId = ev.getRoomId()!;
|
||||
const room = MatrixClientPeg.safeGet().getRoom(roomId);
|
||||
if (!room) {
|
||||
// e.g we are in the process of joining a room.
|
||||
|
||||
@@ -52,7 +52,6 @@ import SlashCommandHelpDialog from "./components/views/dialogs/SlashCommandHelpD
|
||||
import { shouldShowComponent } from "./customisations/helpers/UIComponents";
|
||||
import { TimelineRenderingType } from "./contexts/RoomContext";
|
||||
import { type ViewRoomPayload } from "./dispatcher/payloads/ViewRoomPayload";
|
||||
import VoipUserMapper from "./VoipUserMapper";
|
||||
import { htmlSerializeFromMdIfNeeded } from "./editor/serialize";
|
||||
import { leaveRoomBehaviour } from "./utils/leave-behaviour";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
@@ -743,28 +742,6 @@ export const Commands = [
|
||||
},
|
||||
category: CommandCategories.advanced,
|
||||
}),
|
||||
new Command({
|
||||
command: "tovirtual",
|
||||
description: _td("slash_command|tovirtual"),
|
||||
category: CommandCategories.advanced,
|
||||
isEnabled(cli): boolean {
|
||||
return !!LegacyCallHandler.instance.getSupportsVirtualRooms() && !isCurrentLocalRoom(cli);
|
||||
},
|
||||
runFn: (cli, roomId) => {
|
||||
return success(
|
||||
(async (): Promise<void> => {
|
||||
const room = await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(roomId);
|
||||
if (!room) throw new UserFriendlyError("slash_command|tovirtual_not_found");
|
||||
dis.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "SlashCommand",
|
||||
metricsViaKeyboard: true,
|
||||
});
|
||||
})(),
|
||||
);
|
||||
},
|
||||
}),
|
||||
new Command({
|
||||
command: "query",
|
||||
description: _td("slash_command|query"),
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room, EventType } from "matrix-js-sdk/src/matrix";
|
||||
import { KnownMembership } from "matrix-js-sdk/src/types";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { ensureVirtualRoomExists } from "./createRoom";
|
||||
import { MatrixClientPeg } from "./MatrixClientPeg";
|
||||
import DMRoomMap from "./utils/DMRoomMap";
|
||||
import LegacyCallHandler from "./LegacyCallHandler";
|
||||
import { VIRTUAL_ROOM_EVENT_TYPE } from "./call-types";
|
||||
import { findDMForUser } from "./utils/dm/findDMForUser";
|
||||
|
||||
// Functions for mapping virtual users & rooms. Currently the only lookup
|
||||
// is sip virtual: there could be others in the future.
|
||||
|
||||
export default class VoipUserMapper {
|
||||
// We store mappings of virtual -> native room IDs here until the local echo for the
|
||||
// account data arrives.
|
||||
private virtualToNativeRoomIdCache = new Map<string, string>();
|
||||
|
||||
public static sharedInstance(): VoipUserMapper {
|
||||
if (window.mxVoipUserMapper === undefined) window.mxVoipUserMapper = new VoipUserMapper();
|
||||
return window.mxVoipUserMapper;
|
||||
}
|
||||
|
||||
private async userToVirtualUser(userId: string): Promise<string | null> {
|
||||
const results = await LegacyCallHandler.instance.sipVirtualLookup(userId);
|
||||
if (results.length === 0 || !results[0].fields.lookup_success) return null;
|
||||
return results[0].userid;
|
||||
}
|
||||
|
||||
private async getVirtualUserForRoom(roomId: string): Promise<string | null> {
|
||||
const userId = DMRoomMap.shared().getUserIdForRoomId(roomId);
|
||||
if (!userId) return null;
|
||||
|
||||
const virtualUser = await this.userToVirtualUser(userId);
|
||||
if (!virtualUser) return null;
|
||||
|
||||
return virtualUser;
|
||||
}
|
||||
|
||||
public async getOrCreateVirtualRoomForRoom(roomId: string): Promise<string | null> {
|
||||
const virtualUser = await this.getVirtualUserForRoom(roomId);
|
||||
if (!virtualUser) return null;
|
||||
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const virtualRoomId = await ensureVirtualRoomExists(cli, virtualUser, roomId);
|
||||
cli.setRoomAccountData(virtualRoomId!, VIRTUAL_ROOM_EVENT_TYPE, {
|
||||
native_room: roomId,
|
||||
});
|
||||
|
||||
this.virtualToNativeRoomIdCache.set(virtualRoomId!, roomId);
|
||||
|
||||
return virtualRoomId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the ID of the virtual room for a room, or null if the room has no
|
||||
* virtual room
|
||||
*/
|
||||
public async getVirtualRoomForRoom(roomId: string): Promise<Room | undefined> {
|
||||
const virtualUser = await this.getVirtualUserForRoom(roomId);
|
||||
if (!virtualUser) return undefined;
|
||||
|
||||
return findDMForUser(MatrixClientPeg.safeGet(), virtualUser);
|
||||
}
|
||||
|
||||
public nativeRoomForVirtualRoom(roomId: string): string | null {
|
||||
const cachedNativeRoomId = this.virtualToNativeRoomIdCache.get(roomId);
|
||||
if (cachedNativeRoomId) {
|
||||
logger.log(
|
||||
"Returning native room ID " + cachedNativeRoomId + " for virtual room ID " + roomId + " from cache",
|
||||
);
|
||||
return cachedNativeRoomId;
|
||||
}
|
||||
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const virtualRoom = cli.getRoom(roomId);
|
||||
if (!virtualRoom) return null;
|
||||
const virtualRoomEvent = virtualRoom.getAccountData(VIRTUAL_ROOM_EVENT_TYPE);
|
||||
if (!virtualRoomEvent || !virtualRoomEvent.getContent()) return null;
|
||||
const nativeRoomID = virtualRoomEvent.getContent()["native_room"];
|
||||
const nativeRoom = cli.getRoom(nativeRoomID);
|
||||
if (!nativeRoom || nativeRoom.getMyMembership() !== KnownMembership.Join) return null;
|
||||
|
||||
return nativeRoomID;
|
||||
}
|
||||
|
||||
public isVirtualRoom(room: Room): boolean {
|
||||
if (this.nativeRoomForVirtualRoom(room.roomId)) return true;
|
||||
|
||||
if (this.virtualToNativeRoomIdCache.has(room.roomId)) return true;
|
||||
|
||||
// also look in the create event for the claimed native room ID, which is the only
|
||||
// way we can recognise a virtual room we've created when it first arrives down
|
||||
// our stream. We don't trust this in general though, as it could be faked by an
|
||||
// inviter: our main source of truth is the DM state.
|
||||
const roomCreateEvent = room.currentState.getStateEvents(EventType.RoomCreate, "");
|
||||
if (!roomCreateEvent || !roomCreateEvent.getContent()) return false;
|
||||
// we only look at this for rooms we created (so inviters can't just cause rooms
|
||||
// to be invisible)
|
||||
if (roomCreateEvent.getSender() !== MatrixClientPeg.safeGet().getUserId()) return false;
|
||||
const claimedNativeRoomId = roomCreateEvent.getContent()[VIRTUAL_ROOM_EVENT_TYPE];
|
||||
return Boolean(claimedNativeRoomId);
|
||||
}
|
||||
|
||||
public async onNewInvitedRoom(invitedRoom: Room): Promise<void> {
|
||||
if (!LegacyCallHandler.instance.getSupportsVirtualRooms()) return;
|
||||
|
||||
const inviterId = invitedRoom.getDMInviter();
|
||||
if (!inviterId) {
|
||||
logger.error("Could not find DM inviter for room id: " + invitedRoom.roomId);
|
||||
}
|
||||
|
||||
logger.log(`Checking virtual-ness of room ID ${invitedRoom.roomId}, invited by ${inviterId}`);
|
||||
const result = await LegacyCallHandler.instance.sipNativeLookup(inviterId!);
|
||||
if (result.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result[0].fields.is_virtual) {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
const nativeUser = result[0].userid;
|
||||
const nativeRoom = findDMForUser(cli, nativeUser);
|
||||
if (nativeRoom) {
|
||||
// It's a virtual room with a matching native room, so set the room account data. This
|
||||
// will make sure we know where how to map calls and also allow us know not to display
|
||||
// it in the future.
|
||||
cli.setRoomAccountData(invitedRoom.roomId, VIRTUAL_ROOM_EVENT_TYPE, {
|
||||
native_room: nativeRoom.roomId,
|
||||
});
|
||||
// also auto-join the virtual room if we have a matching native room
|
||||
// (possibly we should only join if we've also joined the native room, then we'd also have
|
||||
// to make sure we joined virtual rooms on joining a native one)
|
||||
cli.joinRoom(invitedRoom.roomId);
|
||||
|
||||
// also put this room in the virtual room ID cache so isVirtualRoom return the right answer
|
||||
// in however long it takes for the echo of setAccountData to come down the sync
|
||||
this.virtualToNativeRoomIdCache.set(invitedRoom.roomId, nativeRoom.roomId);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,18 +6,20 @@ 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, { forwardRef } from "react";
|
||||
import React, { type Ref, type JSX } from "react";
|
||||
|
||||
import { RovingTabIndexProvider } from "./RovingTabIndex";
|
||||
import { getKeyBindingsManager } from "../KeyBindingsManager";
|
||||
import { KeyBindingAction } from "./KeyboardShortcuts";
|
||||
|
||||
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {}
|
||||
interface IProps extends Omit<React.HTMLProps<HTMLDivElement>, "onKeyDown"> {
|
||||
ref?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
// This component implements the Toolbar design pattern from the WAI-ARIA Authoring Practices guidelines.
|
||||
// https://www.w3.org/TR/wai-aria-practices-1.1/#toolbar
|
||||
// All buttons passed in children must use RovingTabIndex to set `onFocus`, `isActive`, `ref`
|
||||
const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref) => {
|
||||
const Toolbar = ({ children, ref, ...props }: IProps): JSX.Element => {
|
||||
const onKeyDown = (ev: React.KeyboardEvent): void => {
|
||||
const target = ev.target as HTMLElement;
|
||||
// Don't interfere with input default keydown behaviour
|
||||
@@ -55,6 +57,6 @@ const Toolbar = forwardRef<HTMLDivElement, IProps>(({ children, ...props }, ref)
|
||||
)}
|
||||
</RovingTabIndexProvider>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default Toolbar;
|
||||
|
||||
@@ -8,7 +8,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, { forwardRef, type Ref } from "react";
|
||||
import React, { type Ref, type JSX } from "react";
|
||||
|
||||
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
|
||||
|
||||
@@ -16,13 +16,19 @@ type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
|
||||
label?: string;
|
||||
// whether the context menu is currently open
|
||||
isExpanded: boolean;
|
||||
ref?: Ref<HTMLElementTagNameMap[T]>;
|
||||
};
|
||||
|
||||
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||
export const ContextMenuButton = forwardRef(function <T extends keyof HTMLElementTagNameMap>(
|
||||
{ label, isExpanded, children, onClick, onContextMenu, ...props }: Props<T>,
|
||||
ref: Ref<HTMLElementTagNameMap[T]>,
|
||||
) {
|
||||
export const ContextMenuButton = function <T extends keyof HTMLElementTagNameMap>({
|
||||
label,
|
||||
isExpanded,
|
||||
children,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
ref,
|
||||
...props
|
||||
}: Props<T>): JSX.Element {
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
@@ -36,4 +42,4 @@ export const ContextMenuButton = forwardRef(function <T extends keyof HTMLElemen
|
||||
{children}
|
||||
</AccessibleButton>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -8,7 +8,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, { forwardRef, type Ref } from "react";
|
||||
import React, { type JSX } from "react";
|
||||
|
||||
import AccessibleButton, { type ButtonProps } from "../../components/views/elements/AccessibleButton";
|
||||
|
||||
@@ -18,10 +18,14 @@ type Props<T extends keyof HTMLElementTagNameMap> = ButtonProps<T> & {
|
||||
};
|
||||
|
||||
// Semantic component for representing the AccessibleButton which launches a <ContextMenu />
|
||||
export const ContextMenuTooltipButton = forwardRef(function <T extends keyof HTMLElementTagNameMap>(
|
||||
{ isExpanded, children, onClick, onContextMenu, ...props }: Props<T>,
|
||||
ref: Ref<HTMLElementTagNameMap[T]>,
|
||||
) {
|
||||
export const ContextMenuTooltipButton = function <T extends keyof HTMLElementTagNameMap>({
|
||||
isExpanded,
|
||||
children,
|
||||
onClick,
|
||||
onContextMenu,
|
||||
ref,
|
||||
...props
|
||||
}: Props<T>): JSX.Element {
|
||||
return (
|
||||
<AccessibleButton
|
||||
{...props}
|
||||
@@ -35,4 +39,4 @@ export const ContextMenuTooltipButton = forwardRef(function <T extends keyof HTM
|
||||
{children}
|
||||
</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, { forwardRef } from "react";
|
||||
import React, { type Ref, type JSX } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
/* These were earlier stateless functional components but had to be converted
|
||||
@@ -16,14 +16,24 @@ presumably wrap them in a <div> before rendering but I think this is the better
|
||||
*/
|
||||
|
||||
interface ITextualCompletionProps {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
description?: string;
|
||||
className?: string;
|
||||
"title"?: string;
|
||||
"subtitle"?: string;
|
||||
"description"?: string;
|
||||
"className"?: string;
|
||||
"aria-selected"?: boolean;
|
||||
"ref"?: Ref<HTMLDivElement>;
|
||||
}
|
||||
|
||||
export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props, ref) => {
|
||||
const { title, subtitle, description, className, "aria-selected": ariaSelectedAttribute, ...restProps } = props;
|
||||
export const TextualCompletion = (props: ITextualCompletionProps): JSX.Element => {
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
description,
|
||||
className,
|
||||
"aria-selected": ariaSelectedAttribute,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
return (
|
||||
<div
|
||||
{...restProps}
|
||||
@@ -37,13 +47,13 @@ export const TextualCompletion = forwardRef<ITextualCompletionProps, any>((props
|
||||
<span className="mx_Autocomplete_Completion_description">{description}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
interface IPillCompletionProps extends ITextualCompletionProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref) => {
|
||||
export const PillCompletion = (props: IPillCompletionProps): JSX.Element => {
|
||||
const {
|
||||
title,
|
||||
subtitle,
|
||||
@@ -51,6 +61,7 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
|
||||
className,
|
||||
children,
|
||||
"aria-selected": ariaSelectedAttribute,
|
||||
ref,
|
||||
...restProps
|
||||
} = props;
|
||||
return (
|
||||
@@ -67,4 +78,4 @@ export const PillCompletion = forwardRef<IPillCompletionProps, any>((props, ref)
|
||||
<span className="mx_Autocomplete_Completion_description">{description}</span>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -127,7 +127,7 @@ export default class UserProvider extends AutocompleteProvider {
|
||||
suffix: selection.beginning && range!.start === 0 ? ": " : " ",
|
||||
href: makeUserPermalink(user.userId),
|
||||
component: (
|
||||
<PillCompletion title={displayName} description={description}>
|
||||
<PillCompletion title={displayName} description={description ?? undefined}>
|
||||
<MemberAvatar member={user} size="24px" />
|
||||
</PillCompletion>
|
||||
),
|
||||
|
||||
@@ -6,10 +6,6 @@ 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.
|
||||
*/
|
||||
|
||||
// Event type for room account data and room creation content used to mark rooms as virtual rooms
|
||||
// (and store the ID of their native room)
|
||||
export const VIRTUAL_ROOM_EVENT_TYPE = "im.vector.is_virtual_room";
|
||||
|
||||
export const JitsiCallMemberEventType = "io.element.video.member";
|
||||
|
||||
export interface JitsiCallMemberContent {
|
||||
|
||||
@@ -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, { type PropsWithChildren, useEffect, useState } from "react";
|
||||
import React, { type PropsWithChildren, useEffect, useState, type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
@@ -85,7 +85,7 @@ interface Props {
|
||||
* A React component which exposes a {@link MatrixClientContext} and a {@link LocalDeviceVerificationStateContext}
|
||||
* to its children.
|
||||
*/
|
||||
export function MatrixClientContextProvider(props: PropsWithChildren<Props>): React.JSX.Element {
|
||||
export function MatrixClientContextProvider(props: PropsWithChildren<Props>): JSX.Element {
|
||||
const verificationState = useLocalVerificationState(props.client);
|
||||
return (
|
||||
<MatrixClientContext.Provider value={props.client}>
|
||||
|
||||
@@ -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, { type JSX, forwardRef, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import React, { type JSX, type Ref, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
type ISearchResults,
|
||||
type IThreadBundledRelationship,
|
||||
@@ -44,269 +44,275 @@ interface Props {
|
||||
resizeNotifier: ResizeNotifier;
|
||||
className: string;
|
||||
onUpdate(inProgress: boolean, results: ISearchResults | null, error: Error | null): void;
|
||||
ref?: Ref<ScrollPanel>;
|
||||
}
|
||||
|
||||
// XXX: todo: merge overlapping results somehow?
|
||||
// XXX: why doesn't searching on name work?
|
||||
export const RoomSearchView = forwardRef<ScrollPanel, Props>(
|
||||
({ term, scope, promise, abortController, resizeNotifier, className, onUpdate, inProgress }: Props, ref) => {
|
||||
const client = useContext(MatrixClientContext);
|
||||
const roomContext = useScopedRoomContext("showHiddenEvents");
|
||||
const [highlights, setHighlights] = useState<string[] | null>(null);
|
||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||
const aborted = useRef(false);
|
||||
// A map from room ID to permalink creator
|
||||
const permalinkCreators = useMemo(() => new Map<string, RoomPermalinkCreator>(), []);
|
||||
const innerRef = useRef<ScrollPanel>(null);
|
||||
export const RoomSearchView = ({
|
||||
term,
|
||||
scope,
|
||||
promise,
|
||||
abortController,
|
||||
resizeNotifier,
|
||||
className,
|
||||
onUpdate,
|
||||
inProgress,
|
||||
ref,
|
||||
}: Props): JSX.Element => {
|
||||
const client = useContext(MatrixClientContext);
|
||||
const roomContext = useScopedRoomContext("showHiddenEvents");
|
||||
const [highlights, setHighlights] = useState<string[] | null>(null);
|
||||
const [results, setResults] = useState<ISearchResults | null>(null);
|
||||
const aborted = useRef(false);
|
||||
// A map from room ID to permalink creator
|
||||
const permalinkCreators = useMemo(() => new Map<string, RoomPermalinkCreator>(), []);
|
||||
const innerRef = useRef<ScrollPanel>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
permalinkCreators.forEach((pc) => pc.stop());
|
||||
permalinkCreators.clear();
|
||||
};
|
||||
}, [permalinkCreators]);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
permalinkCreators.forEach((pc) => pc.stop());
|
||||
permalinkCreators.clear();
|
||||
};
|
||||
}, [permalinkCreators]);
|
||||
|
||||
const handleSearchResult = useCallback(
|
||||
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
|
||||
onUpdate(true, null, null);
|
||||
const handleSearchResult = useCallback(
|
||||
(searchPromise: Promise<ISearchResults>): Promise<boolean> => {
|
||||
onUpdate(true, null, null);
|
||||
|
||||
return searchPromise.then(
|
||||
async (results): Promise<boolean> => {
|
||||
debuglog("search complete");
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
return searchPromise.then(
|
||||
async (results): Promise<boolean> => {
|
||||
debuglog("search complete");
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
|
||||
// postgres on synapse returns us precise details of the strings
|
||||
// which actually got matched for highlighting.
|
||||
//
|
||||
// In either case, we want to highlight the literal search term
|
||||
// whether it was used by the search engine or not.
|
||||
// postgres on synapse returns us precise details of the strings
|
||||
// which actually got matched for highlighting.
|
||||
//
|
||||
// In either case, we want to highlight the literal search term
|
||||
// whether it was used by the search engine or not.
|
||||
|
||||
let highlights = results.highlights;
|
||||
if (!highlights.includes(term)) {
|
||||
highlights = highlights.concat(term);
|
||||
}
|
||||
let highlights = results.highlights;
|
||||
if (!highlights.includes(term)) {
|
||||
highlights = highlights.concat(term);
|
||||
}
|
||||
|
||||
// For overlapping highlights,
|
||||
// favour longer (more specific) terms first
|
||||
highlights = highlights.sort(function (a, b) {
|
||||
return b.length - a.length;
|
||||
});
|
||||
// For overlapping highlights,
|
||||
// favour longer (more specific) terms first
|
||||
highlights = highlights.sort(function (a, b) {
|
||||
return b.length - a.length;
|
||||
});
|
||||
|
||||
for (const result of results.results) {
|
||||
for (const event of result.context.getTimeline()) {
|
||||
const bundledRelationship =
|
||||
event.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||
THREAD_RELATION_TYPE.name,
|
||||
);
|
||||
if (!bundledRelationship || event.getThread()) continue;
|
||||
const room = client.getRoom(event.getRoomId());
|
||||
const thread = room?.findThreadForEvent(event);
|
||||
if (thread) {
|
||||
event.setThread(thread);
|
||||
} else {
|
||||
room?.createThread(event.getId()!, event, [], true);
|
||||
}
|
||||
for (const result of results.results) {
|
||||
for (const event of result.context.getTimeline()) {
|
||||
const bundledRelationship = event.getServerAggregatedRelation<IThreadBundledRelationship>(
|
||||
THREAD_RELATION_TYPE.name,
|
||||
);
|
||||
if (!bundledRelationship || event.getThread()) continue;
|
||||
const room = client.getRoom(event.getRoomId());
|
||||
const thread = room?.findThreadForEvent(event);
|
||||
if (thread) {
|
||||
event.setThread(thread);
|
||||
} else {
|
||||
room?.createThread(event.getId()!, event, [], true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setHighlights(highlights);
|
||||
setResults({ ...results }); // copy to force a refresh
|
||||
onUpdate(false, results, null);
|
||||
setHighlights(highlights);
|
||||
setResults({ ...results }); // copy to force a refresh
|
||||
onUpdate(false, results, null);
|
||||
return false;
|
||||
},
|
||||
(error) => {
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
},
|
||||
(error) => {
|
||||
if (aborted.current) {
|
||||
logger.error("Discarding stale search results");
|
||||
return false;
|
||||
}
|
||||
logger.error("Search failed", error);
|
||||
onUpdate(false, null, error);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
},
|
||||
[client, term, onUpdate],
|
||||
);
|
||||
|
||||
// Mount & unmount effect
|
||||
useEffect(() => {
|
||||
aborted.current = false;
|
||||
handleSearchResult(promise);
|
||||
return () => {
|
||||
aborted.current = true;
|
||||
abortController?.abort();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// show searching spinner
|
||||
if (results === null) {
|
||||
return (
|
||||
<div
|
||||
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
|
||||
data-testid="messagePanelSearchSpinner"
|
||||
/>
|
||||
}
|
||||
logger.error("Search failed", error);
|
||||
onUpdate(false, null, error);
|
||||
return false;
|
||||
},
|
||||
);
|
||||
}
|
||||
},
|
||||
[client, term, onUpdate],
|
||||
);
|
||||
|
||||
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
|
||||
if (!backwards) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!results.next_batch) {
|
||||
debuglog("no more search results");
|
||||
return false;
|
||||
}
|
||||
|
||||
debuglog("requesting more search results");
|
||||
const searchPromise = searchPagination(client, results);
|
||||
return handleSearchResult(searchPromise);
|
||||
// Mount & unmount effect
|
||||
useEffect(() => {
|
||||
aborted.current = false;
|
||||
handleSearchResult(promise);
|
||||
return () => {
|
||||
aborted.current = true;
|
||||
abortController?.abort();
|
||||
};
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const ret: JSX.Element[] = [];
|
||||
// show searching spinner
|
||||
if (results === null) {
|
||||
return (
|
||||
<div
|
||||
className="mx_RoomView_messagePanel mx_RoomView_messagePanelSearchSpinner"
|
||||
data-testid="messagePanelSearchSpinner"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (inProgress) {
|
||||
ret.push(
|
||||
<li key="search-spinner">
|
||||
<Spinner />
|
||||
</li>,
|
||||
);
|
||||
const onSearchResultsFillRequest = async (backwards: boolean): Promise<boolean> => {
|
||||
if (!backwards) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!results.next_batch) {
|
||||
if (!results?.results?.length) {
|
||||
debuglog("no more search results");
|
||||
return false;
|
||||
}
|
||||
|
||||
debuglog("requesting more search results");
|
||||
const searchPromise = searchPagination(client, results);
|
||||
return handleSearchResult(searchPromise);
|
||||
};
|
||||
|
||||
const ret: JSX.Element[] = [];
|
||||
|
||||
if (inProgress) {
|
||||
ret.push(
|
||||
<li key="search-spinner">
|
||||
<Spinner />
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
|
||||
if (!results.next_batch) {
|
||||
if (!results?.results?.length) {
|
||||
ret.push(
|
||||
<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2>
|
||||
</li>,
|
||||
);
|
||||
} else {
|
||||
ret.push(
|
||||
<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
|
||||
</li>,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const onRef = (e: ScrollPanel | null): void => {
|
||||
if (typeof ref === "function") {
|
||||
ref(e);
|
||||
} else if (!!ref) {
|
||||
ref.current = e;
|
||||
}
|
||||
innerRef.current = e;
|
||||
};
|
||||
|
||||
let lastRoomId: string | undefined;
|
||||
let mergedTimeline: MatrixEvent[] = [];
|
||||
let ourEventsIndexes: number[] = [];
|
||||
|
||||
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
|
||||
const result = results.results[i];
|
||||
|
||||
const mxEv = result.context.getEvent();
|
||||
const roomId = mxEv.getRoomId()!;
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
|
||||
// As per the spec, an all rooms search can create this condition,
|
||||
// it happens with Seshat but not Synapse.
|
||||
// It will make the result count not match the displayed count.
|
||||
logger.log("Hiding search result from an unknown room", roomId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scope === SearchScope.All) {
|
||||
if (roomId !== lastRoomId) {
|
||||
ret.push(
|
||||
<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{_t("common|no_results")}</h2>
|
||||
</li>,
|
||||
);
|
||||
} else {
|
||||
ret.push(
|
||||
<li key="search-top-marker">
|
||||
<h2 className="mx_RoomView_topMarker">{_t("no_more_results")}</h2>
|
||||
<li key={mxEv.getId() + "-room"}>
|
||||
<h2>
|
||||
{_t("common|room")}: {room.name}
|
||||
</h2>
|
||||
</li>,
|
||||
);
|
||||
lastRoomId = roomId;
|
||||
}
|
||||
}
|
||||
|
||||
const onRef = (e: ScrollPanel | null): void => {
|
||||
if (typeof ref === "function") {
|
||||
ref(e);
|
||||
} else if (!!ref) {
|
||||
ref.current = e;
|
||||
}
|
||||
innerRef.current = e;
|
||||
};
|
||||
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
|
||||
|
||||
let lastRoomId: string | undefined;
|
||||
let mergedTimeline: MatrixEvent[] = [];
|
||||
let ourEventsIndexes: number[] = [];
|
||||
|
||||
for (let i = (results?.results?.length || 0) - 1; i >= 0; i--) {
|
||||
const result = results.results[i];
|
||||
|
||||
const mxEv = result.context.getEvent();
|
||||
const roomId = mxEv.getRoomId()!;
|
||||
const room = client.getRoom(roomId);
|
||||
if (!room) {
|
||||
// if we do not have the room in js-sdk stores then hide it as we cannot easily show it
|
||||
// As per the spec, an all rooms search can create this condition,
|
||||
// it happens with Seshat but not Synapse.
|
||||
// It will make the result count not match the displayed count.
|
||||
logger.log("Hiding search result from an unknown room", roomId);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!haveRendererForEvent(mxEv, client, roomContext.showHiddenEvents)) {
|
||||
// XXX: can this ever happen? It will make the result count
|
||||
// not match the displayed count.
|
||||
continue;
|
||||
}
|
||||
|
||||
if (scope === SearchScope.All) {
|
||||
if (roomId !== lastRoomId) {
|
||||
ret.push(
|
||||
<li key={mxEv.getId() + "-room"}>
|
||||
<h2>
|
||||
{_t("common|room")}: {room.name}
|
||||
</h2>
|
||||
</li>,
|
||||
);
|
||||
lastRoomId = roomId;
|
||||
}
|
||||
}
|
||||
|
||||
const resultLink = "#/room/" + roomId + "/" + mxEv.getId();
|
||||
|
||||
// merging two successive search result if the query is present in both of them
|
||||
const currentTimeline = result.context.getTimeline();
|
||||
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
|
||||
|
||||
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
|
||||
// if this is the first searchResult we merge then add all values of the current searchResult
|
||||
if (mergedTimeline.length == 0) {
|
||||
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
|
||||
mergedTimeline.push(currentTimeline[j]);
|
||||
}
|
||||
ourEventsIndexes.push(result.context.getOurEventIndex());
|
||||
}
|
||||
|
||||
// merge the events of the next searchResult
|
||||
for (let j = 1; j < nextTimeline.length; j++) {
|
||||
mergedTimeline.push(nextTimeline[j]);
|
||||
}
|
||||
|
||||
// add the index of the matching event of the next searchResult
|
||||
ourEventsIndexes.push(
|
||||
ourEventsIndexes[ourEventsIndexes.length - 1] +
|
||||
results.results[i - 1].context.getOurEventIndex() +
|
||||
1,
|
||||
);
|
||||
|
||||
continue;
|
||||
}
|
||||
// merging two successive search result if the query is present in both of them
|
||||
const currentTimeline = result.context.getTimeline();
|
||||
const nextTimeline = i > 0 ? results.results[i - 1].context.getTimeline() : [];
|
||||
|
||||
if (i > 0 && currentTimeline[currentTimeline.length - 1].getId() == nextTimeline[0].getId()) {
|
||||
// if this is the first searchResult we merge then add all values of the current searchResult
|
||||
if (mergedTimeline.length == 0) {
|
||||
mergedTimeline = result.context.getTimeline();
|
||||
ourEventsIndexes = [];
|
||||
for (let j = mergedTimeline.length == 0 ? 0 : 1; j < result.context.getTimeline().length; j++) {
|
||||
mergedTimeline.push(currentTimeline[j]);
|
||||
}
|
||||
ourEventsIndexes.push(result.context.getOurEventIndex());
|
||||
}
|
||||
|
||||
let permalinkCreator = permalinkCreators.get(roomId);
|
||||
if (!permalinkCreator) {
|
||||
permalinkCreator = new RoomPermalinkCreator(room);
|
||||
permalinkCreator.start();
|
||||
permalinkCreators.set(roomId, permalinkCreator);
|
||||
// merge the events of the next searchResult
|
||||
for (let j = 1; j < nextTimeline.length; j++) {
|
||||
mergedTimeline.push(nextTimeline[j]);
|
||||
}
|
||||
|
||||
ret.push(
|
||||
<SearchResultTile
|
||||
key={mxEv.getId()}
|
||||
timeline={mergedTimeline}
|
||||
ourEventsIndexes={ourEventsIndexes}
|
||||
searchHighlights={highlights ?? []}
|
||||
resultLink={resultLink}
|
||||
permalinkCreator={permalinkCreator}
|
||||
/>,
|
||||
// add the index of the matching event of the next searchResult
|
||||
ourEventsIndexes.push(
|
||||
ourEventsIndexes[ourEventsIndexes.length - 1] + results.results[i - 1].context.getOurEventIndex() + 1,
|
||||
);
|
||||
|
||||
ourEventsIndexes = [];
|
||||
mergedTimeline = [];
|
||||
continue;
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollPanel
|
||||
ref={onRef}
|
||||
className={"mx_RoomView_searchResultsPanel " + className}
|
||||
onFillRequest={onSearchResultsFillRequest}
|
||||
resizeNotifier={resizeNotifier}
|
||||
>
|
||||
<li className="mx_RoomView_scrollheader" />
|
||||
{ret}
|
||||
</ScrollPanel>
|
||||
if (mergedTimeline.length == 0) {
|
||||
mergedTimeline = result.context.getTimeline();
|
||||
ourEventsIndexes = [];
|
||||
ourEventsIndexes.push(result.context.getOurEventIndex());
|
||||
}
|
||||
|
||||
let permalinkCreator = permalinkCreators.get(roomId);
|
||||
if (!permalinkCreator) {
|
||||
permalinkCreator = new RoomPermalinkCreator(room);
|
||||
permalinkCreator.start();
|
||||
permalinkCreators.set(roomId, permalinkCreator);
|
||||
}
|
||||
|
||||
ret.push(
|
||||
<SearchResultTile
|
||||
key={mxEv.getId()}
|
||||
timeline={mergedTimeline}
|
||||
ourEventsIndexes={ourEventsIndexes}
|
||||
searchHighlights={highlights ?? []}
|
||||
resultLink={resultLink}
|
||||
permalinkCreator={permalinkCreator}
|
||||
/>,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
ourEventsIndexes = [];
|
||||
mergedTimeline = [];
|
||||
}
|
||||
|
||||
return (
|
||||
<ScrollPanel
|
||||
ref={onRef}
|
||||
className={"mx_RoomView_searchResultsPanel " + className}
|
||||
onFillRequest={onSearchResultsFillRequest}
|
||||
resizeNotifier={resizeNotifier}
|
||||
>
|
||||
<li className="mx_RoomView_scrollheader" />
|
||||
{ret}
|
||||
</ScrollPanel>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -120,8 +120,6 @@ import { isVideoRoom } from "../../utils/video-rooms";
|
||||
import { SDKContext } from "../../contexts/SDKContext";
|
||||
import { RoomSearchView } from "./RoomSearchView";
|
||||
import eventSearch, { type SearchInfo, SearchScope } from "../../Searching";
|
||||
import VoipUserMapper from "../../VoipUserMapper";
|
||||
import { isCallEvent } from "./LegacyCallEventGrouper";
|
||||
import { WidgetType } from "../../widgets/WidgetType";
|
||||
import WidgetUtils from "../../utils/WidgetUtils";
|
||||
import { shouldEncryptRoomWithSingle3rdPartyInvite } from "../../utils/room/shouldEncryptRoomWithSingle3rdPartyInvite";
|
||||
@@ -165,7 +163,6 @@ export { MainSplitContentType };
|
||||
|
||||
export interface IRoomState {
|
||||
room?: Room;
|
||||
virtualRoom?: Room;
|
||||
roomId?: string;
|
||||
roomAlias?: string;
|
||||
roomLoading: boolean;
|
||||
@@ -1344,12 +1341,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
return this.messagePanel.canResetTimeline();
|
||||
};
|
||||
|
||||
private loadVirtualRoom = async (room?: Room): Promise<void> => {
|
||||
const virtualRoom = room?.roomId && (await VoipUserMapper.sharedInstance().getVirtualRoomForRoom(room?.roomId));
|
||||
|
||||
this.setState({ virtualRoom: virtualRoom || undefined });
|
||||
};
|
||||
|
||||
// called when state.room is first initialised (either at initial load,
|
||||
// after a successful peek, or after we join the room).
|
||||
private onRoomLoaded = (room: Room): void => {
|
||||
@@ -1362,7 +1353,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
this.calculateRecommendedVersion(room);
|
||||
this.updatePermissions(room);
|
||||
this.checkWidgets(room);
|
||||
this.loadVirtualRoom(room);
|
||||
this.updateRoomEncrypted(room);
|
||||
|
||||
if (
|
||||
@@ -2444,8 +2434,6 @@ export class RoomView extends React.Component<IRoomProps, IRoomState> {
|
||||
<TimelinePanel
|
||||
ref={this.gatherTimelinePanelRef}
|
||||
timelineSet={this.state.room.getUnfilteredTimelineSet()}
|
||||
overlayTimelineSet={this.state.virtualRoom?.getUnfilteredTimelineSet()}
|
||||
overlayTimelineSetFilter={isCallEvent}
|
||||
showReadReceipts={this.state.showReadReceipts}
|
||||
manageReadReceipts={!this.state.isPeeking}
|
||||
sendReadReceiptOnLoad={!this.state.wasContextSwitch}
|
||||
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
ThreadEvent,
|
||||
ReceiptType,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { debounce, findLastIndex } from "lodash";
|
||||
import { debounce } from "lodash";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
@@ -73,25 +73,12 @@ const debuglog = (...args: any[]): void => {
|
||||
}
|
||||
};
|
||||
|
||||
const overlaysBefore = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean =>
|
||||
overlayEvent.localTimestamp < mainEvent.localTimestamp;
|
||||
|
||||
const overlaysAfter = (overlayEvent: MatrixEvent, mainEvent: MatrixEvent): boolean =>
|
||||
overlayEvent.localTimestamp >= mainEvent.localTimestamp;
|
||||
|
||||
interface IProps {
|
||||
// The js-sdk EventTimelineSet object for the timeline sequence we are
|
||||
// representing. This may or may not have a room, depending on what it's
|
||||
// a timeline representing. If it has a room, we maintain RRs etc for
|
||||
// that room.
|
||||
timelineSet: EventTimelineSet;
|
||||
// overlay events from a second timelineset on the main timeline
|
||||
// added to support virtual rooms
|
||||
// events from the overlay timeline set will be added by localTimestamp
|
||||
// into the main timeline
|
||||
overlayTimelineSet?: EventTimelineSet;
|
||||
// filter events from overlay timeline
|
||||
overlayTimelineSetFilter?: (event: MatrixEvent) => boolean;
|
||||
showReadReceipts?: boolean;
|
||||
// Enable managing RRs and RMs. These require the timelineSet to have a room.
|
||||
manageReadReceipts?: boolean;
|
||||
@@ -251,7 +238,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
private readonly messagePanel = createRef<MessagePanel>();
|
||||
private dispatcherRef?: string;
|
||||
private timelineWindow?: TimelineWindow;
|
||||
private overlayTimelineWindow?: TimelineWindow;
|
||||
private unmounted = false;
|
||||
private readReceiptActivityTimer: Timer | null = null;
|
||||
private readMarkerActivityTimer: Timer | null = null;
|
||||
@@ -349,16 +335,12 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
const differentEventId = prevProps.eventId != this.props.eventId;
|
||||
const differentHighlightedEventId = prevProps.highlightedEventId != this.props.highlightedEventId;
|
||||
const differentAvoidJump = prevProps.eventScrollIntoView && !this.props.eventScrollIntoView;
|
||||
const differentOverlayTimeline = prevProps.overlayTimelineSet !== this.props.overlayTimelineSet;
|
||||
if (differentEventId || differentHighlightedEventId || differentAvoidJump) {
|
||||
logger.log(
|
||||
`TimelinePanel switching to eventId ${this.props.eventId} (was ${prevProps.eventId}), ` +
|
||||
`scrollIntoView: ${this.props.eventScrollIntoView} (was ${prevProps.eventScrollIntoView})`,
|
||||
);
|
||||
this.initTimeline(this.props);
|
||||
} else if (differentOverlayTimeline) {
|
||||
logger.log(`TimelinePanel updating overlay timeline.`);
|
||||
this.initTimeline(this.props);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -509,24 +491,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
// this particular event should be the first or last to be unpaginated.
|
||||
const eventId = scrollToken;
|
||||
|
||||
// The event in question could belong to either the main timeline or
|
||||
// overlay timeline; let's check both
|
||||
const mainEvents = this.timelineWindow!.getEvents();
|
||||
const overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
|
||||
|
||||
let marker = mainEvents.findIndex((ev) => ev.getId() === eventId);
|
||||
let overlayMarker: number;
|
||||
if (marker === -1) {
|
||||
// The event must be from the overlay timeline instead
|
||||
overlayMarker = overlayEvents.findIndex((ev) => ev.getId() === eventId);
|
||||
marker = backwards
|
||||
? findLastIndex(mainEvents, (ev) => overlaysAfter(overlayEvents[overlayMarker], ev))
|
||||
: mainEvents.findIndex((ev) => overlaysBefore(overlayEvents[overlayMarker], ev));
|
||||
} else {
|
||||
overlayMarker = backwards
|
||||
? findLastIndex(overlayEvents, (ev) => overlaysBefore(ev, mainEvents[marker]))
|
||||
: overlayEvents.findIndex((ev) => overlaysAfter(ev, mainEvents[marker]));
|
||||
}
|
||||
const marker = mainEvents.findIndex((ev) => ev.getId() === eventId);
|
||||
|
||||
// The number of events to unpaginate from the main timeline
|
||||
let count: number;
|
||||
@@ -536,24 +503,11 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
count = backwards ? marker + 1 : mainEvents.length - marker;
|
||||
}
|
||||
|
||||
// The number of events to unpaginate from the overlay timeline
|
||||
let overlayCount: number;
|
||||
if (overlayMarker === -1) {
|
||||
overlayCount = 0;
|
||||
} else {
|
||||
overlayCount = backwards ? overlayMarker + 1 : overlayEvents.length - overlayMarker;
|
||||
}
|
||||
|
||||
if (count > 0) {
|
||||
debuglog("Unpaginating", count, "in direction", dir);
|
||||
this.timelineWindow!.unpaginate(count, backwards);
|
||||
}
|
||||
|
||||
if (overlayCount > 0) {
|
||||
debuglog("Unpaginating", count, "from overlay timeline in direction", dir);
|
||||
this.overlayTimelineWindow!.unpaginate(overlayCount, backwards);
|
||||
}
|
||||
|
||||
const { events, liveEvents } = this.getEvents();
|
||||
this.buildLegacyCallEventGroupers(events);
|
||||
this.setState({
|
||||
@@ -610,10 +564,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (this.overlayTimelineWindow) {
|
||||
await this.extendOverlayWindowToCoverMainWindow();
|
||||
}
|
||||
|
||||
debuglog("paginate complete backwards:" + backwards + "; success:" + r);
|
||||
|
||||
const { events, liveEvents } = this.getEvents();
|
||||
@@ -705,10 +655,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
data: IRoomTimelineData,
|
||||
): void => {
|
||||
// ignore events for other timeline sets
|
||||
if (
|
||||
data.timeline.getTimelineSet() !== this.props.timelineSet &&
|
||||
data.timeline.getTimelineSet() !== this.props.overlayTimelineSet
|
||||
) {
|
||||
if (data.timeline.getTimelineSet() !== this.props.timelineSet) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -748,69 +695,60 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
// timeline window.
|
||||
//
|
||||
// see https://github.com/vector-im/vector-web/issues/1035
|
||||
this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false)
|
||||
.then(() => {
|
||||
if (this.overlayTimelineWindow) {
|
||||
return this.overlayTimelineWindow.paginate(EventTimeline.FORWARDS, 1, false);
|
||||
this.timelineWindow!.paginate(EventTimeline.FORWARDS, 1, false).then(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { events, liveEvents } = this.getEvents();
|
||||
this.buildLegacyCallEventGroupers(events);
|
||||
const lastLiveEvent = liveEvents[liveEvents.length - 1];
|
||||
|
||||
const updatedState: Partial<IState> = {
|
||||
events,
|
||||
liveEvents,
|
||||
};
|
||||
|
||||
let callRMUpdated = false;
|
||||
if (this.props.manageReadMarkers) {
|
||||
// when a new event arrives when the user is not watching the
|
||||
// window, but the window is in its auto-scroll mode, make sure the
|
||||
// read marker is visible.
|
||||
//
|
||||
// We ignore events we have sent ourselves; we don't want to see the
|
||||
// read-marker when a remote echo of an event we have just sent takes
|
||||
// more than the timeout on userActiveRecently.
|
||||
//
|
||||
const myUserId = MatrixClientPeg.safeGet().credentials.userId;
|
||||
callRMUpdated = false;
|
||||
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
updatedState.readMarkerVisible = true;
|
||||
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
|
||||
// we know we're stuckAtBottom, so we can advance the RM
|
||||
// immediately, to save a later render cycle
|
||||
|
||||
this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true);
|
||||
updatedState.readMarkerVisible = false;
|
||||
updatedState.readMarkerEventId = lastLiveEvent.getId();
|
||||
callRMUpdated = true;
|
||||
}
|
||||
})
|
||||
.then(() => {
|
||||
if (this.unmounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.setState(updatedState as IState, () => {
|
||||
this.messagePanel.current?.updateTimelineMinHeight();
|
||||
if (callRMUpdated) {
|
||||
this.props.onReadMarkerUpdated?.();
|
||||
}
|
||||
|
||||
const { events, liveEvents } = this.getEvents();
|
||||
this.buildLegacyCallEventGroupers(events);
|
||||
const lastLiveEvent = liveEvents[liveEvents.length - 1];
|
||||
|
||||
const updatedState: Partial<IState> = {
|
||||
events,
|
||||
liveEvents,
|
||||
};
|
||||
|
||||
let callRMUpdated = false;
|
||||
if (this.props.manageReadMarkers) {
|
||||
// when a new event arrives when the user is not watching the
|
||||
// window, but the window is in its auto-scroll mode, make sure the
|
||||
// read marker is visible.
|
||||
//
|
||||
// We ignore events we have sent ourselves; we don't want to see the
|
||||
// read-marker when a remote echo of an event we have just sent takes
|
||||
// more than the timeout on userActiveRecently.
|
||||
//
|
||||
const myUserId = MatrixClientPeg.safeGet().credentials.userId;
|
||||
callRMUpdated = false;
|
||||
if (ev.getSender() !== myUserId && !UserActivity.sharedInstance().userActiveRecently()) {
|
||||
updatedState.readMarkerVisible = true;
|
||||
} else if (lastLiveEvent && this.getReadMarkerPosition() === 0) {
|
||||
// we know we're stuckAtBottom, so we can advance the RM
|
||||
// immediately, to save a later render cycle
|
||||
|
||||
this.setReadMarker(lastLiveEvent.getId() ?? null, lastLiveEvent.getTs(), true);
|
||||
updatedState.readMarkerVisible = false;
|
||||
updatedState.readMarkerEventId = lastLiveEvent.getId();
|
||||
callRMUpdated = true;
|
||||
}
|
||||
}
|
||||
|
||||
this.setState(updatedState as IState, () => {
|
||||
this.messagePanel.current?.updateTimelineMinHeight();
|
||||
if (callRMUpdated) {
|
||||
this.props.onReadMarkerUpdated?.();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
private hasTimelineSetFor(roomId: string | undefined): boolean {
|
||||
return (
|
||||
(roomId !== undefined && roomId === this.props.timelineSet.room?.roomId) ||
|
||||
roomId === this.props.overlayTimelineSet?.room?.roomId
|
||||
);
|
||||
return roomId !== undefined && roomId === this.props.timelineSet.room?.roomId;
|
||||
}
|
||||
|
||||
private onRoomTimelineReset = (room: Room | undefined, timelineSet: EventTimelineSet): void => {
|
||||
if (timelineSet !== this.props.timelineSet && timelineSet !== this.props.overlayTimelineSet) return;
|
||||
if (timelineSet !== this.props.timelineSet) return;
|
||||
|
||||
if (this.canResetTimeline()) {
|
||||
this.loadTimeline();
|
||||
@@ -1475,48 +1413,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
});
|
||||
}
|
||||
|
||||
private async extendOverlayWindowToCoverMainWindow(): Promise<void> {
|
||||
const mainWindow = this.timelineWindow!;
|
||||
const overlayWindow = this.overlayTimelineWindow!;
|
||||
const mainEvents = mainWindow.getEvents();
|
||||
|
||||
if (mainEvents.length > 0) {
|
||||
let paginationRequests: Promise<unknown>[];
|
||||
|
||||
// Keep paginating until the main window is covered
|
||||
do {
|
||||
paginationRequests = [];
|
||||
const overlayEvents = overlayWindow.getEvents();
|
||||
|
||||
if (
|
||||
overlayWindow.canPaginate(EventTimeline.BACKWARDS) &&
|
||||
(overlayEvents.length === 0 ||
|
||||
overlaysAfter(overlayEvents[0], mainEvents[0]) ||
|
||||
!mainWindow.canPaginate(EventTimeline.BACKWARDS))
|
||||
) {
|
||||
// Paginating backwards could reveal more events to be overlaid in the main window
|
||||
paginationRequests.push(
|
||||
this.onPaginationRequest(overlayWindow, EventTimeline.BACKWARDS, PAGINATE_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
overlayWindow.canPaginate(EventTimeline.FORWARDS) &&
|
||||
(overlayEvents.length === 0 ||
|
||||
overlaysBefore(overlayEvents.at(-1)!, mainEvents.at(-1)!) ||
|
||||
!mainWindow.canPaginate(EventTimeline.FORWARDS))
|
||||
) {
|
||||
// Paginating forwards could reveal more events to be overlaid in the main window
|
||||
paginationRequests.push(
|
||||
this.onPaginationRequest(overlayWindow, EventTimeline.FORWARDS, PAGINATE_SIZE),
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(paginationRequests);
|
||||
} while (paginationRequests.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* (re)-load the event timeline, and initialise the scroll state, centered
|
||||
* around the given event.
|
||||
@@ -1536,9 +1432,6 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
private loadTimeline(eventId?: string, pixelOffset?: number, offsetBase?: number, scrollIntoView = true): void {
|
||||
const cli = MatrixClientPeg.safeGet();
|
||||
this.timelineWindow = new TimelineWindow(cli, this.props.timelineSet, { windowLimit: this.props.timelineCap });
|
||||
this.overlayTimelineWindow = this.props.overlayTimelineSet
|
||||
? new TimelineWindow(cli, this.props.overlayTimelineSet, { windowLimit: this.props.timelineCap })
|
||||
: undefined;
|
||||
|
||||
const onLoaded = (): void => {
|
||||
if (this.unmounted) return;
|
||||
@@ -1554,14 +1447,8 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
|
||||
this.setState(
|
||||
{
|
||||
canBackPaginate:
|
||||
(this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS) ||
|
||||
this.overlayTimelineWindow?.canPaginate(EventTimeline.BACKWARDS)) ??
|
||||
false,
|
||||
canForwardPaginate:
|
||||
(this.timelineWindow?.canPaginate(EventTimeline.FORWARDS) ||
|
||||
this.overlayTimelineWindow?.canPaginate(EventTimeline.FORWARDS)) ??
|
||||
false,
|
||||
canBackPaginate: this.timelineWindow?.canPaginate(EventTimeline.BACKWARDS) ?? false,
|
||||
canForwardPaginate: this.timelineWindow?.canPaginate(EventTimeline.FORWARDS) ?? false,
|
||||
timelineLoading: false,
|
||||
},
|
||||
() => {
|
||||
@@ -1636,7 +1523,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
// This is a hot-path optimization by skipping a promise tick
|
||||
// by repeating a no-op sync branch in
|
||||
// TimelineSet.getTimelineForEvent & MatrixClient.getEventTimeline
|
||||
if (this.props.timelineSet.getTimelineForEvent(eventId) && !this.overlayTimelineWindow) {
|
||||
if (this.props.timelineSet.getTimelineForEvent(eventId)) {
|
||||
// if we've got an eventId, and the timeline exists, we can skip
|
||||
// the promise tick.
|
||||
this.timelineWindow.load(eventId, INITIAL_SIZE);
|
||||
@@ -1645,14 +1532,7 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
return;
|
||||
}
|
||||
|
||||
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE).then(async (): Promise<void> => {
|
||||
if (this.overlayTimelineWindow) {
|
||||
// TODO: use timestampToEvent to load the overlay timeline
|
||||
// with more correct position when main TL eventId is truthy
|
||||
await this.overlayTimelineWindow.load(undefined, INITIAL_SIZE);
|
||||
await this.extendOverlayWindowToCoverMainWindow();
|
||||
}
|
||||
});
|
||||
const prom = this.timelineWindow.load(eventId, INITIAL_SIZE);
|
||||
this.buildLegacyCallEventGroupers();
|
||||
this.setState({
|
||||
events: [],
|
||||
@@ -1683,38 +1563,9 @@ class TimelinePanel extends React.Component<IProps, IState> {
|
||||
this.reloadEvents();
|
||||
}
|
||||
|
||||
// get the list of events from the timeline windows and the pending event list
|
||||
// get the list of events from the timeline window and the pending event list
|
||||
private getEvents(): Pick<IState, "events" | "liveEvents"> {
|
||||
const mainEvents = this.timelineWindow!.getEvents();
|
||||
let overlayEvents = this.overlayTimelineWindow?.getEvents() ?? [];
|
||||
if (this.props.overlayTimelineSetFilter !== undefined) {
|
||||
overlayEvents = overlayEvents.filter(this.props.overlayTimelineSetFilter);
|
||||
}
|
||||
|
||||
// maintain the main timeline event order as returned from the HS
|
||||
// merge overlay events at approximately the right position based on local timestamp
|
||||
const events = overlayEvents.reduce(
|
||||
(acc: MatrixEvent[], overlayEvent: MatrixEvent) => {
|
||||
// find the first main tl event with a later timestamp
|
||||
const index = acc.findIndex((event) => overlaysBefore(overlayEvent, event));
|
||||
// insert overlay event into timeline at approximately the right place
|
||||
// if it's beyond the edge of the main window, hide it so that expanding
|
||||
// the main window doesn't cause new events to pop in and change its position
|
||||
if (index === -1) {
|
||||
if (!this.timelineWindow!.canPaginate(EventTimeline.FORWARDS)) {
|
||||
acc.push(overlayEvent);
|
||||
}
|
||||
} else if (index === 0) {
|
||||
if (!this.timelineWindow!.canPaginate(EventTimeline.BACKWARDS)) {
|
||||
acc.unshift(overlayEvent);
|
||||
}
|
||||
} else {
|
||||
acc.splice(index, 0, overlayEvent);
|
||||
}
|
||||
return acc;
|
||||
},
|
||||
[...mainEvents],
|
||||
);
|
||||
const events = this.timelineWindow!.getEvents();
|
||||
|
||||
// We want the last event to be decrypted first
|
||||
const client = MatrixClientPeg.safeGet();
|
||||
|
||||
@@ -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 React, { type JSX } from "react";
|
||||
import { type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import { CryptoEvent } from "matrix-js-sdk/src/crypto-api";
|
||||
|
||||
@@ -43,13 +43,13 @@ type MigrationState = {
|
||||
/**
|
||||
* The view that is displayed after we have logged in, before the first /sync is completed.
|
||||
*/
|
||||
export function LoginSplashView(props: Props): React.JSX.Element {
|
||||
export function LoginSplashView(props: Props): JSX.Element {
|
||||
const migrationState = useTypedEventEmitterState(
|
||||
props.matrixClient,
|
||||
CryptoEvent.LegacyCryptoStoreMigrationProgress,
|
||||
(progress?: number, total?: number): MigrationState => ({ progress: progress ?? -1, totalSteps: total ?? -1 }),
|
||||
);
|
||||
let errorBox: React.JSX.Element | undefined;
|
||||
let errorBox: JSX.Element | undefined;
|
||||
if (props.syncError) {
|
||||
errorBox = <div className="mx_LoginSplashView_syncError">{messageForSyncError(props.syncError)}</div>;
|
||||
}
|
||||
|
||||
@@ -5,32 +5,11 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
EventType,
|
||||
JoinRule,
|
||||
type MatrixEvent,
|
||||
type Room,
|
||||
RoomEvent,
|
||||
type User,
|
||||
UserEvent,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { EventType, JoinRule, type MatrixEvent, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
import { useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { getJoinedNonFunctionalMembers } from "../../../utils/room/getJoinedNonFunctionalMembers";
|
||||
import { BUSY_PRESENCE_NAME } from "../../views/rooms/PresenceLabel";
|
||||
import { isPresenceEnabled } from "../../../utils/presence";
|
||||
|
||||
/**
|
||||
* The presence of a user in a DM room.
|
||||
* - "online": The user is online.
|
||||
* - "offline": The user is offline.
|
||||
* - "busy": The user is busy.
|
||||
* - "unavailable": the presence is unavailable.
|
||||
* - null: the user is not in a DM room or presence is not enabled.
|
||||
*/
|
||||
export type Presence = "online" | "offline" | "busy" | "unavailable" | null;
|
||||
import { useDmMember, usePresence, type Presence } from "../../views/avatars/WithPresenceIndicator";
|
||||
|
||||
export interface RoomAvatarViewState {
|
||||
/**
|
||||
@@ -50,7 +29,7 @@ export interface RoomAvatarViewState {
|
||||
* The presence of the user in the DM room.
|
||||
* If null, the user is not in a DM room or presence is not enabled.
|
||||
*/
|
||||
presence: Presence;
|
||||
presence: Presence | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,7 +38,8 @@ export interface RoomAvatarViewState {
|
||||
*/
|
||||
export function useRoomAvatarViewModel(room: Room): RoomAvatarViewState {
|
||||
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
|
||||
const presence = useDMPresence(room);
|
||||
const roomMember = useDmMember(room);
|
||||
const presence = usePresence(room, roomMember);
|
||||
const isPublic = useIsPublic(room);
|
||||
|
||||
const hasDecoration = isPublic || isVideoRoom || presence !== null;
|
||||
@@ -97,48 +77,3 @@ function useIsPublic(room: Room): boolean {
|
||||
function isRoomPublic(room: Room): boolean {
|
||||
return room.getJoinRule() === JoinRule.Public;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook listening to the presence of the DM user.
|
||||
* @param room
|
||||
*/
|
||||
function useDMPresence(room: Room): Presence {
|
||||
const dmUser = getDMUser(room);
|
||||
const [presence, setPresence] = useState<Presence>(getPresence(dmUser));
|
||||
useTypedEventEmitter(dmUser, UserEvent.Presence, () => setPresence(getPresence(dmUser)));
|
||||
useTypedEventEmitter(dmUser, UserEvent.CurrentlyActive, () => setPresence(getPresence(dmUser)));
|
||||
|
||||
return presence;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the DM user of the room.
|
||||
* Return undefined if the room is not a DM room, if we can't find the user or if the presence is not enabled.
|
||||
* @param room
|
||||
* @returns found user
|
||||
*/
|
||||
function getDMUser(room: Room): User | undefined {
|
||||
const otherUserId = DMRoomMap.shared().getUserIdForRoomId(room.roomId);
|
||||
if (!otherUserId) return;
|
||||
if (getJoinedNonFunctionalMembers(room).length !== 2) return;
|
||||
if (!isPresenceEnabled(room.client)) return;
|
||||
|
||||
return room.client.getUser(otherUserId) || undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the presence of the DM user.
|
||||
* @param dmUser
|
||||
*/
|
||||
function getPresence(dmUser: User | undefined): Presence {
|
||||
if (!dmUser) return null;
|
||||
if (BUSY_PRESENCE_NAME.matches(dmUser.presence)) return "busy";
|
||||
|
||||
const isOnline = dmUser.currentlyActive || dmUser.presence === "online";
|
||||
if (isOnline) return "online";
|
||||
|
||||
if (dmUser.presence === "offline") return "offline";
|
||||
if (dmUser.presence === "unavailable") return "unavailable";
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
/*
|
||||
Copyright 2025 New Vector Ltd.
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type SyntheticEvent, useState } from "react";
|
||||
import { EventType, type Room, type ContentHelpers } from "matrix-js-sdk/src/matrix";
|
||||
import { type Optional } from "matrix-events-sdk";
|
||||
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { onRoomTopicLinkClick } from "../../views/elements/RoomTopic";
|
||||
import { useTopic } from "../../../hooks/room/useTopic";
|
||||
|
||||
export interface RoomTopicState {
|
||||
/**
|
||||
* The topic of the room, the value is taken from the room state
|
||||
*/
|
||||
topic: Optional<ContentHelpers.TopicState>;
|
||||
/**
|
||||
* Whether the topic is expanded or not
|
||||
*/
|
||||
expanded: boolean;
|
||||
/**
|
||||
* Whether the user have the permission to edit the topic
|
||||
*/
|
||||
canEditTopic: boolean;
|
||||
/**
|
||||
* The callback when the edit button is clicked
|
||||
*/
|
||||
onEditClick: (e: SyntheticEvent) => void;
|
||||
/**
|
||||
* When the expand button is clicked, it changes expanded state
|
||||
*/
|
||||
onExpandedClick: (ev: SyntheticEvent) => void;
|
||||
/**
|
||||
* The callback when the topic link is clicked
|
||||
*/
|
||||
onTopicLinkClick: React.MouseEventHandler<HTMLElement>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the room topic used in the RoomSummaryCard
|
||||
* @param room - the room to get the topic from
|
||||
* @returns the room topic state
|
||||
*/
|
||||
export function useRoomTopicViewModel(room: Room): RoomTopicState {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
const topic = useTopic(room);
|
||||
|
||||
const canEditTopic = useRoomState(room, (state) =>
|
||||
state.maySendStateEvent(EventType.RoomTopic, room.client.getSafeUserId()),
|
||||
);
|
||||
|
||||
const onEditClick = (e: SyntheticEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
defaultDispatcher.dispatch({ action: "open_room_settings" });
|
||||
};
|
||||
|
||||
const onExpandedClick = (e: SyntheticEvent): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setExpanded((_expanded) => !_expanded);
|
||||
};
|
||||
|
||||
const onTopicLinkClick = (e: React.MouseEvent): void => {
|
||||
if (e.target instanceof HTMLAnchorElement) {
|
||||
onRoomTopicLinkClick(e);
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
return {
|
||||
topic,
|
||||
expanded,
|
||||
canEditTopic,
|
||||
onEditClick,
|
||||
onExpandedClick,
|
||||
onTopicLinkClick,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
@@ -16,12 +16,20 @@ import { _t } from "../../../languageHandler";
|
||||
import { type RoomNotificationState } from "../../../stores/notifications/RoomNotificationState";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import { useEventEmitter, useEventEmitterState, useTypedEventEmitter } from "../../../hooks/useEventEmitter";
|
||||
import { DefaultTagID } from "../../../stores/room-list/models";
|
||||
import { useCall, useConnectionState, useParticipantCount } from "../../../hooks/useCall";
|
||||
import { type ConnectionState } from "../../../models/Call";
|
||||
import { NotificationStateEvents } from "../../../stores/notifications/NotificationState";
|
||||
import DMRoomMap from "../../../utils/DMRoomMap";
|
||||
import { MessagePreviewStore } from "../../../stores/room-list/MessagePreviewStore";
|
||||
import { useMessagePreviewToggle } from "./useMessagePreviewToggle";
|
||||
|
||||
export interface RoomListItemViewState {
|
||||
/**
|
||||
* The name of the room.
|
||||
*/
|
||||
name: string;
|
||||
/**
|
||||
* Whether the hover menu should be shown.
|
||||
*/
|
||||
@@ -55,6 +63,15 @@ export interface RoomListItemViewState {
|
||||
* Whether there are participants in the call.
|
||||
*/
|
||||
hasParticipantInCall: boolean;
|
||||
/**
|
||||
* Pre-rendered and translated preview for the latest message in the room, or undefined
|
||||
* if no preview should be shown.
|
||||
*/
|
||||
messagePreview: string | undefined;
|
||||
/**
|
||||
* Whether the notification decoration should be shown.
|
||||
*/
|
||||
showNotificationDecoration: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -65,12 +82,37 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
const matrixClient = useMatrixClientContext();
|
||||
const roomTags = useEventEmitterState(room, RoomEvent.Tags, () => room.tags);
|
||||
const isArchived = Boolean(roomTags[DefaultTagID.Archived]);
|
||||
const name = useEventEmitterState(room, RoomEvent.Name, () => room.name);
|
||||
|
||||
const showHoverMenu =
|
||||
hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived);
|
||||
const notificationState = useMemo(() => RoomNotificationStateStore.instance.getRoomState(room), [room]);
|
||||
const a11yLabel = getA11yLabel(room, notificationState);
|
||||
const isBold = notificationState.hasAnyNotificationOrActivity;
|
||||
|
||||
const [a11yLabel, setA11yLabel] = useState(getA11yLabel(name, notificationState));
|
||||
const [{ isBold, invited, hasVisibleNotification }, setNotificationValues] = useState(
|
||||
getNotificationValues(notificationState),
|
||||
);
|
||||
useEffect(() => {
|
||||
setA11yLabel(getA11yLabel(name, notificationState));
|
||||
}, [name, notificationState]);
|
||||
|
||||
// Listen to changes in the notification state and update the values
|
||||
useTypedEventEmitter(notificationState, NotificationStateEvents.Update, () => {
|
||||
setA11yLabel(getA11yLabel(name, notificationState));
|
||||
setNotificationValues(getNotificationValues(notificationState));
|
||||
});
|
||||
|
||||
// If the notification reference change due to room change, update the values
|
||||
useEffect(() => {
|
||||
setNotificationValues(getNotificationValues(notificationState));
|
||||
}, [notificationState]);
|
||||
|
||||
// We don't want to show the hover menu if
|
||||
// - there is an invitation for this room
|
||||
// - the user doesn't have access to both notification and more options menus
|
||||
const showHoverMenu =
|
||||
!invited &&
|
||||
(hasAccessToOptionsMenu(room) || hasAccessToNotificationMenu(room, matrixClient.isGuest(), isArchived));
|
||||
|
||||
const messagePreview = useRoomMessagePreview(room);
|
||||
|
||||
// Video room
|
||||
const isVideoRoom = room.isElementVideoRoom() || room.isCallRoom();
|
||||
@@ -80,6 +122,8 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
const hasParticipantInCall = useParticipantCount(call) > 0;
|
||||
const callConnectionState = call ? connectionState : null;
|
||||
|
||||
const showNotificationDecoration = hasVisibleNotification || hasParticipantInCall;
|
||||
|
||||
// Actions
|
||||
|
||||
const openRoom = useCallback((): void => {
|
||||
@@ -91,6 +135,7 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
}, [room]);
|
||||
|
||||
return {
|
||||
name,
|
||||
notificationState,
|
||||
showHoverMenu,
|
||||
openRoom,
|
||||
@@ -99,34 +144,93 @@ export function useRoomListItemViewModel(room: Room): RoomListItemViewState {
|
||||
isVideoRoom,
|
||||
callConnectionState,
|
||||
hasParticipantInCall,
|
||||
messagePreview,
|
||||
showNotificationDecoration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the values from the notification state
|
||||
* @param notificationState
|
||||
*/
|
||||
function getNotificationValues(notificationState: RoomNotificationState): {
|
||||
computeA11yLabel: (name: string) => string;
|
||||
isBold: boolean;
|
||||
invited: boolean;
|
||||
hasVisibleNotification: boolean;
|
||||
} {
|
||||
const invited = notificationState.invited;
|
||||
const computeA11yLabel = (name: string): string => getA11yLabel(name, notificationState);
|
||||
const isBold = notificationState.hasAnyNotificationOrActivity;
|
||||
|
||||
const hasVisibleNotification = notificationState.hasAnyNotificationOrActivity || notificationState.muted;
|
||||
|
||||
return {
|
||||
computeA11yLabel,
|
||||
isBold,
|
||||
invited,
|
||||
hasVisibleNotification,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the a11y label for the room list item
|
||||
* @param room
|
||||
* @param roomName
|
||||
* @param notificationState
|
||||
*/
|
||||
function getA11yLabel(room: Room, notificationState: RoomNotificationState): string {
|
||||
if (notificationState.isUnsetMessage) {
|
||||
function getA11yLabel(roomName: string, notificationState: RoomNotificationState): string {
|
||||
if (notificationState.isUnsentMessage) {
|
||||
return _t("a11y|room_messsage_not_sent", {
|
||||
roomName: room.name,
|
||||
roomName,
|
||||
});
|
||||
} else if (notificationState.invited) {
|
||||
return _t("a11y|room_n_unread_invite", {
|
||||
roomName: room.name,
|
||||
roomName,
|
||||
});
|
||||
} else if (notificationState.isMention) {
|
||||
return _t("a11y|room_n_unread_messages_mentions", {
|
||||
roomName: room.name,
|
||||
roomName,
|
||||
count: notificationState.count,
|
||||
});
|
||||
} else if (notificationState.hasUnreadCount) {
|
||||
return _t("a11y|room_n_unread_messages", {
|
||||
roomName: room.name,
|
||||
roomName,
|
||||
count: notificationState.count,
|
||||
});
|
||||
} else {
|
||||
return _t("room_list|room|open_room", { roomName: room.name });
|
||||
return _t("room_list|room|open_room", { roomName });
|
||||
}
|
||||
}
|
||||
|
||||
function useRoomMessagePreview(room: Room): string | undefined {
|
||||
const { shouldShowMessagePreview } = useMessagePreviewToggle();
|
||||
const [previewText, setPreviewText] = useState<string | undefined>(undefined);
|
||||
|
||||
const updatePreview = useCallback(async () => {
|
||||
if (!shouldShowMessagePreview) {
|
||||
setPreviewText(undefined);
|
||||
return;
|
||||
}
|
||||
|
||||
const roomIsDM = Boolean(DMRoomMap.shared().getUserIdForRoomId(room.roomId));
|
||||
// For the tag, we only care about whether the room is a DM or not as we don't show
|
||||
// display names in previewsd for DMs, so anything else we just say is 'untagged'
|
||||
// (even though it could actually be have other tags: we don't care about them).
|
||||
const messagePreview = await MessagePreviewStore.instance.getPreviewForRoom(
|
||||
room,
|
||||
roomIsDM ? DefaultTagID.DM : DefaultTagID.Untagged,
|
||||
);
|
||||
if (messagePreview) setPreviewText(messagePreview.text);
|
||||
}, [room, shouldShowMessagePreview]);
|
||||
|
||||
// MessagePreviewStore and the other AsyncStores need to be converted to TypedEventEmitter
|
||||
useEventEmitter(MessagePreviewStore.instance, MessagePreviewStore.getPreviewChangedEventName(room), () => {
|
||||
updatePreview();
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
updatePreview();
|
||||
}, [updatePreview]);
|
||||
|
||||
return previewText;
|
||||
}
|
||||
|
||||
@@ -19,6 +19,7 @@ import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { useMatrixClientContext } from "../../../contexts/MatrixClientContext";
|
||||
import { useStickyRoomList } from "./useStickyRoomList";
|
||||
import { useRoomListNavigation } from "./useRoomListNavigation";
|
||||
|
||||
export interface RoomListViewState {
|
||||
/**
|
||||
@@ -106,6 +107,8 @@ export function useRoomListViewModel(): RoomListViewState {
|
||||
} = useFilteredRooms();
|
||||
const { activeIndex, rooms } = useStickyRoomList(filteredRooms);
|
||||
|
||||
useRoomListNavigation(rooms);
|
||||
|
||||
const currentSpace = useEventEmitterState<Room | null>(
|
||||
SpaceStore.instance,
|
||||
UPDATE_SELECTED_SPACE,
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
* 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 { useCallback } from "react";
|
||||
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import { SettingLevel } from "../../../settings/SettingLevel";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
|
||||
interface MessagePreviewToggleState {
|
||||
shouldShowMessagePreview: boolean;
|
||||
@@ -20,17 +21,12 @@ interface MessagePreviewToggleState {
|
||||
* - Provides a function to toggle message previews.
|
||||
*/
|
||||
export function useMessagePreviewToggle(): MessagePreviewToggleState {
|
||||
const [shouldShowMessagePreview, setShouldShowMessagePreview] = useState(() =>
|
||||
SettingsStore.getValue("RoomList.showMessagePreview"),
|
||||
);
|
||||
const shouldShowMessagePreview = useSettingValue("RoomList.showMessagePreview");
|
||||
|
||||
const toggleMessagePreview = useCallback((): void => {
|
||||
setShouldShowMessagePreview((current) => {
|
||||
const toggled = !current;
|
||||
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
|
||||
return toggled;
|
||||
});
|
||||
}, []);
|
||||
const toggled = !shouldShowMessagePreview;
|
||||
SettingsStore.setValue("RoomList.showMessagePreview", null, SettingLevel.DEVICE, toggled);
|
||||
}, [shouldShowMessagePreview]);
|
||||
|
||||
return { toggleMessagePreview, shouldShowMessagePreview };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
* Copyright 2025 New Vector Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { type ViewRoomDeltaPayload } from "../../../dispatcher/payloads/ViewRoomDeltaPayload";
|
||||
import type { ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { RoomNotificationStateStore } from "../../../stores/notifications/RoomNotificationStateStore";
|
||||
|
||||
/**
|
||||
* Hook to navigate the room list using keyboard shortcuts.
|
||||
* It listens to the ViewRoomDelta action and updates the room list accordingly.
|
||||
* @param rooms
|
||||
*/
|
||||
export function useRoomListNavigation(rooms: Room[]): void {
|
||||
useDispatcher(dispatcher, (payload) => {
|
||||
if (payload.action !== Action.ViewRoomDelta) return;
|
||||
const roomId = SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
if (!roomId) return;
|
||||
|
||||
const { delta, unread } = payload as ViewRoomDeltaPayload;
|
||||
const filteredRooms = unread
|
||||
? // Filter the rooms to only include unread ones and the active room
|
||||
rooms.filter((room) => {
|
||||
const state = RoomNotificationStateStore.instance.getRoomState(room);
|
||||
return room.roomId === roomId || state.isUnread;
|
||||
})
|
||||
: rooms;
|
||||
|
||||
const currentIndex = filteredRooms.findIndex((room) => room.roomId === roomId);
|
||||
if (currentIndex === -1) return;
|
||||
|
||||
// Get the next/previous new room according to the delta
|
||||
// Use slice to loop on the list
|
||||
// If delta is -1 at the start of the list, it will go to the end
|
||||
// If delta is 1 at the end of the list, it will go to the start
|
||||
const [newRoom] = filteredRooms.slice((currentIndex + delta) % filteredRooms.length);
|
||||
if (!newRoom) return;
|
||||
|
||||
dispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: newRoom.roomId,
|
||||
show_room_tile: true, // to make sure the room gets scrolled into view
|
||||
metricsTrigger: "WebKeyboardShortcut",
|
||||
metricsViaKeyboard: true,
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
|
||||
import { SdkContextClass } from "../../../contexts/SDKContext";
|
||||
import { useDispatcher } from "../../../hooks/useDispatcher";
|
||||
@@ -13,6 +13,7 @@ import dispatcher from "../../../dispatcher/dispatcher";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import type { Room } from "matrix-js-sdk/src/matrix";
|
||||
import type { Optional } from "matrix-events-sdk";
|
||||
import SpaceStore from "../../../stores/spaces/SpaceStore";
|
||||
|
||||
function getIndexByRoomId(rooms: Room[], roomId: Optional<string>): number | undefined {
|
||||
const index = rooms.findIndex((room) => room.roomId === roomId);
|
||||
@@ -90,8 +91,10 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
|
||||
roomsWithStickyRoom: rooms,
|
||||
});
|
||||
|
||||
const currentSpaceRef = useRef(SpaceStore.instance.activeSpace);
|
||||
|
||||
const updateRoomsAndIndex = useCallback(
|
||||
(newRoomId?: string, isRoomChange: boolean = false) => {
|
||||
(newRoomId: string | null, isRoomChange: boolean = false) => {
|
||||
setListState((current) => {
|
||||
const activeRoomId = newRoomId ?? SdkContextClass.instance.roomViewStore.getRoomId();
|
||||
const newActiveIndex = getIndexByRoomId(rooms, activeRoomId);
|
||||
@@ -110,7 +113,21 @@ export function useStickyRoomList(rooms: Room[]): StickyRoomListResult {
|
||||
|
||||
// Re-calculate the index when the list of rooms has changed.
|
||||
useEffect(() => {
|
||||
updateRoomsAndIndex();
|
||||
let newRoomId: string | null = null;
|
||||
let isRoomChange = false;
|
||||
const newSpace = SpaceStore.instance.activeSpace;
|
||||
if (currentSpaceRef.current !== newSpace) {
|
||||
/*
|
||||
If the space has changed, we check if we can immediately set the active
|
||||
index to the last opened room in that space. Otherwise, we might see a
|
||||
flicker because of the delay between the space change event and
|
||||
active room change dispatch.
|
||||
*/
|
||||
newRoomId = SpaceStore.instance.getLastSelectedRoomIdForSpace(newSpace);
|
||||
isRoomChange = true;
|
||||
currentSpaceRef.current = newSpace;
|
||||
}
|
||||
updateRoomsAndIndex(newRoomId, isRoomChange);
|
||||
}, [rooms, updateRoomsAndIndex]);
|
||||
|
||||
return { activeIndex: listState.index, rooms: listState.roomsWithStickyRoom };
|
||||
|
||||
@@ -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, { type AriaRole, forwardRef, useCallback, useContext, useEffect, useState } from "react";
|
||||
import React, { type AriaRole, type JSX, type Ref, useCallback, useContext, useEffect, useState } from "react";
|
||||
import classNames from "classnames";
|
||||
import { ClientEvent, type SyncState } from "matrix-js-sdk/src/matrix";
|
||||
import { Avatar } from "@vector-im/compound-web";
|
||||
@@ -34,6 +34,7 @@ interface IProps {
|
||||
tabIndex?: number;
|
||||
altText?: string;
|
||||
role?: AriaRole;
|
||||
ref?: Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
const calculateUrls = (url?: string | null, urls?: string[], lowBandwidth = false): string[] => {
|
||||
@@ -87,7 +88,7 @@ const useImageUrl = ({ url, urls }: { url?: string | null; urls?: string[] }): [
|
||||
return [imageUrl, onError];
|
||||
};
|
||||
|
||||
const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
|
||||
const BaseAvatar = (props: IProps): JSX.Element => {
|
||||
const {
|
||||
name,
|
||||
idName,
|
||||
@@ -99,6 +100,7 @@ const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
|
||||
className,
|
||||
type = "round",
|
||||
altText = _t("common|avatar"),
|
||||
ref,
|
||||
...otherProps
|
||||
} = props;
|
||||
|
||||
@@ -134,7 +136,7 @@ const BaseAvatar = forwardRef<HTMLElement, IProps>((props, ref) => {
|
||||
data-testid="avatar-img"
|
||||
/>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
export default BaseAvatar;
|
||||
export type BaseAvatarType = React.FC<IProps>;
|
||||
|
||||
@@ -7,7 +7,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, { type JSX, forwardRef, type ReactNode, type Ref, useContext } from "react";
|
||||
import React, { type JSX, type ReactNode, type Ref, useContext } from "react";
|
||||
import { type RoomMember, type ResizeMethod } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import dis from "../../../dispatcher/dispatcher";
|
||||
@@ -33,21 +33,20 @@ interface IProps extends Omit<React.ComponentProps<typeof BaseAvatar>, "name" |
|
||||
forceHistorical?: boolean; // true to deny `useOnlyCurrentProfiles` usage. Default false.
|
||||
hideTitle?: boolean;
|
||||
children?: ReactNode;
|
||||
ref?: Ref<HTMLElement>;
|
||||
}
|
||||
|
||||
function MemberAvatar(
|
||||
{
|
||||
size,
|
||||
resizeMethod = "crop",
|
||||
viewUserOnClick,
|
||||
forceHistorical,
|
||||
fallbackUserId,
|
||||
hideTitle,
|
||||
member: propsMember,
|
||||
...props
|
||||
}: IProps,
|
||||
ref: Ref<HTMLElement>,
|
||||
): JSX.Element {
|
||||
export default function MemberAvatar({
|
||||
size,
|
||||
resizeMethod = "crop",
|
||||
viewUserOnClick,
|
||||
forceHistorical,
|
||||
fallbackUserId,
|
||||
hideTitle,
|
||||
member: propsMember,
|
||||
ref,
|
||||
...props
|
||||
}: IProps): JSX.Element {
|
||||
const cli = useContext(MatrixClientContext);
|
||||
const card = useContext(CardContext);
|
||||
|
||||
@@ -101,5 +100,3 @@ function MemberAvatar(
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default forwardRef(MemberAvatar);
|
||||
|
||||
@@ -20,6 +20,7 @@ import { filterBoolean } from "../../../utils/arrays";
|
||||
import { useSettingValue } from "../../../hooks/useSettings";
|
||||
import { useRoomState } from "../../../hooks/useRoomState";
|
||||
import { useRoomIdName } from "../../../hooks/room/useRoomIdName";
|
||||
import { MediaPreviewValue } from "../../../@types/media_preview";
|
||||
|
||||
interface IProps extends Omit<ComponentProps<typeof BaseAvatar>, "name" | "idName" | "url" | "onClick" | "size"> {
|
||||
// Room may be left unset here, but if it is,
|
||||
@@ -40,7 +41,8 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
|
||||
const avatarEvent = useRoomState(room, (state) => state.getStateEvents(EventType.RoomAvatar, ""));
|
||||
const roomIdName = useRoomIdName(room, oobData);
|
||||
|
||||
const showAvatarsOnInvites = useSettingValue("showAvatarsOnInvites", room?.roomId);
|
||||
const showAvatarsOnInvites =
|
||||
useSettingValue("mediaPreviewConfig", room?.roomId).invite_avatars === MediaPreviewValue.On;
|
||||
|
||||
const onRoomAvatarClick = useCallback(() => {
|
||||
const avatarUrl = Avatar.avatarUrlForRoom(room ?? null);
|
||||
@@ -63,7 +65,6 @@ const RoomAvatar: React.FC<IProps> = ({ room, viewAvatarOnClick, onClick, oobDat
|
||||
// parseInt ignores suffixes.
|
||||
const sizeInt = parseInt(size, 10);
|
||||
let oobAvatar: string | null = null;
|
||||
|
||||
if (oobData?.avatarUrl) {
|
||||
oobAvatar = mediaFromMxc(oobData?.avatarUrl).getThumbnailOfSourceHttp(sizeInt, sizeInt, "crop");
|
||||
}
|
||||
|
||||
@@ -15,8 +15,9 @@ import BusyIcon from "@vector-im/compound-design-tokens/assets/web/icons/presenc
|
||||
import classNames from "classnames";
|
||||
|
||||
import RoomAvatar from "./RoomAvatar";
|
||||
import { useRoomAvatarViewModel, type Presence } from "../../viewmodels/avatars/RoomAvatarViewModel";
|
||||
import { useRoomAvatarViewModel } from "../../viewmodels/avatars/RoomAvatarViewModel";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { Presence } from "./WithPresenceIndicator";
|
||||
|
||||
interface RoomAvatarViewProps {
|
||||
/**
|
||||
@@ -83,7 +84,7 @@ type PresenceDecorationProps = {
|
||||
*/
|
||||
function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element {
|
||||
switch (presence) {
|
||||
case "online":
|
||||
case Presence.Online:
|
||||
return (
|
||||
<OnlineOrUnavailableIcon
|
||||
width="8px"
|
||||
@@ -93,7 +94,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
||||
aria-label={_t("presence|online")}
|
||||
/>
|
||||
);
|
||||
case "unavailable":
|
||||
case Presence.Away:
|
||||
return (
|
||||
<OnlineOrUnavailableIcon
|
||||
width="8px"
|
||||
@@ -103,7 +104,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
||||
aria-label={_t("presence|away")}
|
||||
/>
|
||||
);
|
||||
case "offline":
|
||||
case Presence.Offline:
|
||||
return (
|
||||
<OfflineIcon
|
||||
width="8px"
|
||||
@@ -113,7 +114,7 @@ function PresenceDecoration({ presence }: PresenceDecorationProps): JSX.Element
|
||||
aria-label={_t("presence|offline")}
|
||||
/>
|
||||
);
|
||||
case "busy":
|
||||
case Presence.Busy:
|
||||
return (
|
||||
<BusyIcon
|
||||
width="8px"
|
||||
|
||||
@@ -26,7 +26,7 @@ interface Props {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
enum Presence {
|
||||
export enum Presence {
|
||||
// Note: the names here are used in CSS class names
|
||||
Online = "ONLINE",
|
||||
Away = "AWAY",
|
||||
@@ -86,7 +86,7 @@ function getPresence(member: RoomMember | null): Presence | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
const usePresence = (room: Room, member: RoomMember | null): Presence | null => {
|
||||
export const usePresence = (room: Room, member: RoomMember | null): Presence | null => {
|
||||
const [presence, setPresence] = useState<Presence | null>(getPresence(member));
|
||||
const updatePresence = (): void => {
|
||||
setPresence(getPresence(member));
|
||||
|
||||