Merge branch 'develop' into midhun/call-tiles/call-declined-1
33
CHANGELOG.md
@ -1,3 +1,36 @@
|
||||
Changes in [1.12.18](https://github.com/element-hq/element-web/releases/tag/v1.12.18) (2026-05-12)
|
||||
==================================================================================================
|
||||
## ✨ Features
|
||||
|
||||
* Room list: add collapse/expand all sections ([#33318](https://github.com/element-hq/element-web/pull/33318)). Contributed by @florianduros.
|
||||
* Show user status in timeline ([#32991](https://github.com/element-hq/element-web/pull/32991)). Contributed by @Half-Shot.
|
||||
* Disable URL Preview setting if disabled on the homeserver ([#33279](https://github.com/element-hq/element-web/pull/33279)). Contributed by @Half-Shot.
|
||||
* Go to welcome on logout ([#33306](https://github.com/element-hq/element-web/pull/33306)). Contributed by @t3chguy.
|
||||
* Room list: edit or remove custom sections ([#33283](https://github.com/element-hq/element-web/pull/33283)). Contributed by @florianduros.
|
||||
* Re-generate QR code if the channel expires before scan ([#33303](https://github.com/element-hq/element-web/pull/33303)). Contributed by @t3chguy.
|
||||
* Update toast styles, improve incoming call notifications ([#33043](https://github.com/element-hq/element-web/pull/33043)). Contributed by @robintown.
|
||||
* Add Module Composer API ([#33284](https://github.com/element-hq/element-web/pull/33284)). Contributed by @Half-Shot.
|
||||
* Room list: exclude default section from room list item menu ([#33278](https://github.com/element-hq/element-web/pull/33278)). Contributed by @florianduros.
|
||||
* Show 'Verify this device' toast even if there are no encrypted rooms yet ([#32891](https://github.com/element-hq/element-web/pull/32891)). Contributed by @andybalaam.
|
||||
* Promote "Share encrypted history" from labs ([#33281](https://github.com/element-hq/element-web/pull/33281)). Contributed by @richvdh.
|
||||
* Room list: assign room to section when section is created ([#33240](https://github.com/element-hq/element-web/pull/33240)). Contributed by @florianduros.
|
||||
* Confirm before inviting unknown users to a DM/room ([#33171](https://github.com/element-hq/element-web/pull/33171)). Contributed by @richvdh.
|
||||
* Room list: assign room to custom section ([#33238](https://github.com/element-hq/element-web/pull/33238)). Contributed by @florianduros.
|
||||
* Redesign link previews ([#33061](https://github.com/element-hq/element-web/pull/33061)). Contributed by @Half-Shot.
|
||||
* Room list: scroll to newly creation section ([#33210](https://github.com/element-hq/element-web/pull/33210)). Contributed by @florianduros.
|
||||
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
* Update home page CSS ([#32723](https://github.com/element-hq/element-web/pull/32723)). Contributed by @wolterkam.
|
||||
* Web: Fix typo in `152x152` icon source of `manifest.json` ([#33369](https://github.com/element-hq/element-web/pull/33369)). Contributed by @bartvdbraak.
|
||||
* prevent replay hover from restarting playback ([#33364](https://github.com/element-hq/element-web/pull/33364)). Contributed by @ZacksBot.
|
||||
* Properly save `undefined` id tokens from OIDC login ([#33345](https://github.com/element-hq/element-web/pull/33345)). Contributed by @gingershaped.
|
||||
* Show the right cursor when hovering over a space ([#33351](https://github.com/element-hq/element-web/pull/33351)). Contributed by @robintown.
|
||||
* Set `type` in auth dict for `m.oauth` UIA stage ([#33344](https://github.com/element-hq/element-web/pull/33344)). Contributed by @gingershaped.
|
||||
* Remove duplicated UI in appearance settings ([#33336](https://github.com/element-hq/element-web/pull/33336)). Contributed by @t3chguy.
|
||||
* Move playwright-common wait-on from devDependencies to dependencies ([#33272](https://github.com/element-hq/element-web/pull/33272)). Contributed by @t3chguy.
|
||||
|
||||
|
||||
Changes in [1.12.17](https://github.com/element-hq/element-web/releases/tag/v1.12.17) (2026-04-30)
|
||||
==================================================================================================
|
||||
## 🐛 Bug Fixes
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
"productName": "Element",
|
||||
"main": "lib/electron-main.js",
|
||||
"exports": "./lib/electron-main.js",
|
||||
"version": "1.12.17",
|
||||
"version": "1.12.18",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": {
|
||||
"name": "Element",
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "element-web",
|
||||
"version": "1.12.17",
|
||||
"version": "1.12.18",
|
||||
"description": "Element: the future of secure communication",
|
||||
"author": "New Vector Ltd.",
|
||||
"repository": {
|
||||
|
||||
@ -208,7 +208,7 @@ test.describe("Composer", () => {
|
||||
});
|
||||
await app.viewRoomByName("Bob");
|
||||
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
|
||||
await expect(page.locator(".mx_MImageBody")).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -198,7 +198,7 @@ test.describe("Composer", () => {
|
||||
|
||||
test("can paste a file", async ({ page, bot, app }) => {
|
||||
await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png");
|
||||
await expect(page.locator(".mx_MImageBody")).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
||||
});
|
||||
|
||||
test("can paste a file in a thread", async ({ page, app }) => {
|
||||
@ -213,7 +213,7 @@ test.describe("Composer", () => {
|
||||
await tile.getByRole("button", { name: "Reply in thread" }).click();
|
||||
|
||||
await app.composerDragAndPasteFile("thread", getSampleFilePath("riot.png"), "image/png");
|
||||
await expect(page.locator(".mx_MImageBody")).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody")).toBeVisible();
|
||||
});
|
||||
|
||||
test.describe("when Control+Enter is required to send", () => {
|
||||
|
||||
@ -37,7 +37,7 @@ test.describe("Image Upload", () => {
|
||||
test("should allow upload via drag and drop", { tag: "@screenshot" }, async ({ page, app }) => {
|
||||
await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png");
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { type Page } from "@playwright/test";
|
||||
|
||||
import { expect, test } from "../../../element-web-test";
|
||||
import { getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
|
||||
import { assertRoomInSection, getRoomList, getRoomListHeader, getSectionHeader } from "./utils";
|
||||
|
||||
test.describe("Room list custom sections", () => {
|
||||
test.use({
|
||||
@ -40,22 +40,6 @@ test.describe("Room list custom sections", () => {
|
||||
await expect(dialog).not.toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
|
||||
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
|
||||
* Verifies that the closest preceding aria-level=1 row is the expected section header.
|
||||
*/
|
||||
async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
|
||||
const roomList = getRoomList(page);
|
||||
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
|
||||
// Room row must be at aria-level=2 (i.e. inside a section)
|
||||
await expect(roomRow).toHaveAttribute("aria-level", "2");
|
||||
// The closest preceding aria-level=1 row must be the expected section header.
|
||||
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
|
||||
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
|
||||
await expect(closestSectionHeader).toContainText(sectionName);
|
||||
}
|
||||
|
||||
test.beforeEach(async ({ page, app, user }) => {
|
||||
// The notification toast is displayed above the search section
|
||||
await app.closeNotificationToast();
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { expect, test } from "../../../element-web-test";
|
||||
import { getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
|
||||
import { assertRoomInSection, dragRoomToSection, getPrimaryFilters, getRoomList, getSectionHeader } from "./utils";
|
||||
|
||||
test.describe("Room list sections", () => {
|
||||
test.use({
|
||||
@ -182,6 +182,37 @@ test.describe("Room list sections", () => {
|
||||
roomItem = roomList.getByRole("row", { name: "Open room my room" });
|
||||
await expect(roomItem).toBeVisible();
|
||||
});
|
||||
|
||||
test("should move a room from Chats to Favourites when using dnd", async ({ page, app }) => {
|
||||
await app.client.createRoom({ name: "my room" });
|
||||
|
||||
const favouriteId = await app.client.createRoom({ name: "favourite room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId);
|
||||
|
||||
await dragRoomToSection(page, "my room", "Favourites");
|
||||
await assertRoomInSection(page, "Favourites", "my room");
|
||||
});
|
||||
|
||||
test("should move a room from Favourites to Chats when using dnd", async ({ page, app }) => {
|
||||
const favouriteId = await app.client.createRoom({ name: "my room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId);
|
||||
|
||||
// Create a second favourite room to ensure we stay in section mode (not flat list)
|
||||
const favouriteId2 = await app.client.createRoom({ name: "favourite room" });
|
||||
await app.client.evaluate(async (client, roomId) => {
|
||||
await client.setRoomTag(roomId, "m.favourite");
|
||||
}, favouriteId2);
|
||||
|
||||
// Ensure the Chats section is visible by creating a room in it
|
||||
await app.client.createRoom({ name: "room in chats" });
|
||||
|
||||
await dragRoomToSection(page, "my room", "Chats");
|
||||
await assertRoomInSection(page, "Chats", "my room");
|
||||
});
|
||||
});
|
||||
|
||||
test("should show unread indicator on section header", async ({ page, app, bot }) => {
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Locator, type Page } from "@playwright/test";
|
||||
import { expect, type Locator, type Page } from "@playwright/test";
|
||||
|
||||
/**
|
||||
* Get the room list
|
||||
@ -35,6 +35,49 @@ export function getSectionHeader(page: Page, sectionName: string, isUnread = fal
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Asserts a room is nested under a specific section using the treegrid aria-level hierarchy.
|
||||
* Section header rows sit at aria-level=1; room rows nested within a section sit at aria-level=2.
|
||||
* Verifies that the closest preceding aria-level=1 row is the expected section header.
|
||||
*/
|
||||
export async function assertRoomInSection(page: Page, sectionName: string, roomName: string): Promise<void> {
|
||||
const roomList = getRoomList(page);
|
||||
const roomRow = roomList.getByRole("row", { name: `Open room ${roomName}` });
|
||||
// Room row must be at aria-level=2 (i.e. inside a section)
|
||||
await expect(roomRow).toHaveAttribute("aria-level", "2");
|
||||
// The closest preceding aria-level=1 row must be the expected section header.
|
||||
// XPath preceding:: axis returns nodes before the context in document order; [1] picks the nearest one.
|
||||
const closestSectionHeader = roomRow.locator(`xpath=preceding::*[@role="row" and @aria-level="1"][1]`);
|
||||
await expect(closestSectionHeader).toContainText(sectionName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drag and drop a room row onto a section header
|
||||
* @param page
|
||||
* @param roomName
|
||||
* @param sectionName
|
||||
*/
|
||||
export async function dragRoomToSection(page: Page, roomName: string, sectionName: string): Promise<void> {
|
||||
const sourceRow = getRoomList(page).getByRole("row", { name: `Open room ${roomName}` });
|
||||
const source = sourceRow.locator("button").first();
|
||||
const target = getSectionHeader(page, sectionName);
|
||||
|
||||
const sourceBox = await source.boundingBox();
|
||||
const targetBox = await target.boundingBox();
|
||||
|
||||
const sourceX = sourceBox.x + sourceBox.width / 2;
|
||||
const sourceY = sourceBox.y + sourceBox.height / 2;
|
||||
const targetY = targetBox.y + targetBox.height / 2;
|
||||
|
||||
// Grab the room
|
||||
await page.mouse.move(sourceX, sourceY);
|
||||
await page.mouse.down();
|
||||
// Move the room on the section header
|
||||
await page.mouse.move(sourceX, targetY, { steps: 10 });
|
||||
// Drop the room
|
||||
await page.mouse.up();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the primary filters container
|
||||
* @param page
|
||||
|
||||
@ -105,12 +105,16 @@ test.describe("Custom Component API", () => {
|
||||
});
|
||||
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
const image = imgTile.getByRole("img", { name: "bad.png" });
|
||||
await expect(image).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible();
|
||||
await imgTile.click();
|
||||
await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible();
|
||||
await image.click();
|
||||
const imageView = page.getByLabel("Image view");
|
||||
await expect(imageView).toBeVisible();
|
||||
await expect(imageView.getByLabel("Download")).not.toBeVisible();
|
||||
});
|
||||
test("should allow downloading media when the allowDownloading hint is set to true", async ({
|
||||
page,
|
||||
@ -127,12 +131,16 @@ test.describe("Custom Component API", () => {
|
||||
});
|
||||
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
const image = imgTile.getByRole("img", { name: "good.png" });
|
||||
await expect(image).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await expect(page.getByRole("button", { name: "Download" })).toBeVisible();
|
||||
await imgTile.click();
|
||||
await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible();
|
||||
await image.click();
|
||||
const imageView = page.getByLabel("Image view");
|
||||
await expect(imageView).toBeVisible();
|
||||
await expect(imageView.getByLabel("Download")).toBeVisible();
|
||||
});
|
||||
test(
|
||||
"should render the next registered component if the filter function throws",
|
||||
|
||||
@ -87,9 +87,9 @@ test.describe("FilePanel", () => {
|
||||
await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3);
|
||||
|
||||
// Detect the image file
|
||||
const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody");
|
||||
const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody");
|
||||
// Assert that the image is specified as thumbnail and has the alt string
|
||||
await expect(image.locator("img[class='mx_MImageBody_thumbnail']")).toBeVisible();
|
||||
await expect(image.locator("img.mx_ImageBody_image")).toBeVisible();
|
||||
await expect(image.locator("img[alt='riot.png']")).toBeVisible();
|
||||
|
||||
// Detect the audio file
|
||||
@ -113,7 +113,7 @@ test.describe("FilePanel", () => {
|
||||
"flex-end",
|
||||
);
|
||||
// Assert that all of the file tiles are visible before taking a snapshot
|
||||
await expect(filePanelMessageList.locator(".mx_MImageBody")).toBeVisible(); // top
|
||||
await expect(filePanelMessageList.locator(".mx_ImageBody")).toBeVisible(); // top
|
||||
await expect(filePanelMessageList.locator(".mx_MAudioBody")).toBeVisible(); // middle
|
||||
const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails");
|
||||
await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible();
|
||||
@ -184,7 +184,7 @@ test.describe("FilePanel", () => {
|
||||
|
||||
// Detect the image file on the panel
|
||||
const imageBody = page.locator(
|
||||
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody",
|
||||
".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody",
|
||||
);
|
||||
|
||||
const link = imageBody.locator(".mx_MFileBody a");
|
||||
|
||||
@ -907,7 +907,7 @@ test.describe("Timeline", () => {
|
||||
|
||||
await sendImage(bot, room.roomId, NEW_AVATAR);
|
||||
await app.timeline.scrollToBottom();
|
||||
const imgTile = page.locator(".mx_MImageBody").first();
|
||||
const imgTile = page.locator(".mx_ImageBody").first();
|
||||
await expect(imgTile).toBeVisible();
|
||||
await imgTile.hover();
|
||||
await page.getByRole("button", { name: "Hide" }).click();
|
||||
@ -1314,7 +1314,7 @@ test.describe("Timeline", () => {
|
||||
|
||||
await sendImage(app.client, room.roomId, NEW_AVATAR);
|
||||
await app.timeline.scrollToBottom();
|
||||
await expect(page.locator(".mx_MImageBody").first()).toBeVisible();
|
||||
await expect(page.locator(".mx_ImageBody").first()).toBeVisible();
|
||||
|
||||
// Exclude timestamp and read marker from snapshot
|
||||
const screenshotOptions = {
|
||||
|
||||
|
Before Width: | Height: | Size: 76 KiB After Width: | Height: | Size: 75 KiB |
|
Before Width: | Height: | Size: 71 KiB After Width: | Height: | Size: 71 KiB |
|
Before Width: | Height: | Size: 92 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 6.2 KiB After Width: | Height: | Size: 6.2 KiB |
@ -224,7 +224,6 @@
|
||||
@import "./views/messages/_DisambiguatedProfile.pcss";
|
||||
@import "./views/messages/_LegacyCallEvent.pcss";
|
||||
@import "./views/messages/_MFileBody.pcss";
|
||||
@import "./views/messages/_MImageBody.pcss";
|
||||
@import "./views/messages/_MImageReplyBody.pcss";
|
||||
@import "./views/messages/_MLocationBody.pcss";
|
||||
@import "./views/messages/_MPollBody.pcss";
|
||||
|
||||
@ -45,6 +45,10 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-top: var(--cpd-space-4x);
|
||||
}
|
||||
|
||||
.mx_ImageBody {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
/* anchor link as wrapper */
|
||||
.mx_EventTile_senderDetailsLink {
|
||||
text-decoration: none;
|
||||
|
||||
@ -1,81 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2015, 2016 OpenMarket 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_MImageBody_banner {
|
||||
position: absolute;
|
||||
bottom: $spacing-4;
|
||||
left: $spacing-4;
|
||||
padding: $spacing-4;
|
||||
border-radius: var(--MBody-border-radius);
|
||||
font-size: $font-15px;
|
||||
user-select: none; /* prevent banner text from being selected */
|
||||
pointer-events: none; /* let the cursor go through to the media underneath */
|
||||
|
||||
/* Trying to match the width of the image is surprisingly difficult, so arbitrarily break it off early. */
|
||||
max-width: min(100%, 350px);
|
||||
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
|
||||
/* Hardcoded colours because it's the same on all themes */
|
||||
background-color: rgb(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.mx_MImageBody_placeholder {
|
||||
/* Position the placeholder on top of the thumbnail, so that the reveal animation can work */
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
|
||||
background-color: $background;
|
||||
|
||||
.mx_Blurhash > canvas {
|
||||
animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
border-radius: var(--MBody-border-radius);
|
||||
|
||||
/* Necessary for the border radius to apply correctly to the placeholder */
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail {
|
||||
display: block;
|
||||
|
||||
/* Force the image to be the full size of the container, even if the */
|
||||
/* pixel size is smaller. The problem here is that we don't know what */
|
||||
/* thumbnail size the HS is going to give us, but we have to commit to */
|
||||
/* a container size immediately and not change it when the image loads */
|
||||
/* or we'll get a scroll jump (or have to leave blank space). */
|
||||
/* This will obviously result in an upscaled image which will be a bit */
|
||||
/* blurry. The best fix would be for the HS to advertise what size thumbnails */
|
||||
/* it guarantees to produce. */
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx_MImageBody_gifLabel {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0px;
|
||||
left: 14px;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: $imagebody-giflabel;
|
||||
border: 2px solid $imagebody-giflabel-border;
|
||||
color: $imagebody-giflabel-color;
|
||||
pointer-events: none;
|
||||
}
|
||||
@ -6,6 +6,64 @@ 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.
|
||||
*/
|
||||
|
||||
.mx_MImageReplyBody,
|
||||
.mx_MStickerBody_wrapper {
|
||||
.mx_MImageBody_banner {
|
||||
position: absolute;
|
||||
bottom: $spacing-4;
|
||||
left: $spacing-4;
|
||||
padding: $spacing-4;
|
||||
border-radius: var(--MBody-border-radius);
|
||||
font-size: $font-15px;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
max-width: min(100%, 350px);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
background-color: rgb(0, 0, 0, 0.6);
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.mx_MImageBody_placeholder {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
background-color: $background;
|
||||
|
||||
.mx_Blurhash > canvas {
|
||||
animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
border-radius: var(--MBody-border-radius);
|
||||
overflow: hidden;
|
||||
contain: paint;
|
||||
}
|
||||
|
||||
.mx_MImageBody_thumbnail {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.mx_MImageBody_gifLabel {
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: 0px;
|
||||
left: 14px;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
background: $imagebody-giflabel;
|
||||
border: 2px solid $imagebody-giflabel-border;
|
||||
color: $imagebody-giflabel-color;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageReplyBody {
|
||||
display: flex;
|
||||
column-gap: $spacing-4;
|
||||
|
||||
@ -156,8 +156,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
padding-right: 48px !important;
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
.mx_ImageBody {
|
||||
.mx_ImageBody_container {
|
||||
justify-content: center;
|
||||
min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
|
||||
min-width: calc(1.8rem + var(--gutterSize) + var(--gutterSize));
|
||||
@ -181,8 +181,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
.mx_EventTile_line {
|
||||
border-bottom-right-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
@ -220,8 +220,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
margin-inline-start: auto;
|
||||
border-bottom-left-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
@ -334,16 +334,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_ImageBody {
|
||||
width: 100%;
|
||||
|
||||
.mx_MImageBody_thumbnail.mx_MImageBody_thumbnail--blurhash {
|
||||
position: unset;
|
||||
}
|
||||
}
|
||||
|
||||
/* noinspection CssReplaceWithShorthandSafely */
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MediaBody {
|
||||
border-radius: unset;
|
||||
@ -375,9 +371,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_continuation[data-self="false"] .mx_EventTile_line {
|
||||
border-top-left-radius: 0;
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
@ -387,9 +383,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_lastInSection[data-self="false"] .mx_EventTile_line {
|
||||
border-bottom-left-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
@ -400,9 +396,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_continuation[data-self="true"] .mx_EventTile_line {
|
||||
border-top-right-radius: 0;
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
@ -412,9 +408,9 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_EventTile_lastInSection[data-self="true"] .mx_EventTile_line {
|
||||
border-bottom-right-radius: var(--cornerRadius);
|
||||
|
||||
.mx_MImageBody .mx_MImageBody_thumbnail_container,
|
||||
.mx_ImageBody .mx_ImageBody_container,
|
||||
.mx_MVideoBody .mx_MVideoBody_container,
|
||||
.mx_MImageBody::before,
|
||||
.mx_ImageBody::before,
|
||||
.mx_MediaBody,
|
||||
.mx_MLocationBody_map,
|
||||
.mx_MBeaconBody {
|
||||
|
||||
@ -78,8 +78,8 @@ $left-gutter: 64px;
|
||||
min-width: 100px;
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
.mx_ImageBody {
|
||||
.mx_ImageBody_container {
|
||||
display: flex;
|
||||
align-items: center; /* on every layout */
|
||||
}
|
||||
@ -156,8 +156,8 @@ $left-gutter: 64px;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.mx_MImageBody {
|
||||
.mx_MImageBody_thumbnail_container {
|
||||
.mx_ImageBody {
|
||||
.mx_ImageBody_container {
|
||||
justify-content: flex-start;
|
||||
min-height: $font-44px;
|
||||
min-width: $font-44px;
|
||||
|
||||
@ -66,6 +66,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
display: inline;
|
||||
}
|
||||
|
||||
// The annotation wrapper is flex in normal timeline messages, but that stops
|
||||
// -webkit-line-clamp from trimming long edited quotes down to two lines.
|
||||
[data-textual-body-annotation-wrapper] {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
// Hide line numbers and edited indicator
|
||||
.mx_EventTile_lineNumbers,
|
||||
[data-textual-body-edited-marker] {
|
||||
|
||||
@ -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 { percentageOf } from "@element-hq/web-shared-components";
|
||||
import { percentageOf } from "@element-hq/web-shared-components/numbers";
|
||||
|
||||
import { type IAmplitudePayload, type ITimingPayload, PayloadEvent, WORKLET_NAME } from "./consts";
|
||||
|
||||
|
||||
@ -7,9 +7,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import React, { type JSX, type RefObject, useContext, useEffect, useRef } from "react";
|
||||
import { MsgType } from "matrix-js-sdk/src/matrix";
|
||||
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import {
|
||||
DecryptionFailureBodyView,
|
||||
FileBodyView,
|
||||
ImageBodyView,
|
||||
RedactedBodyView,
|
||||
VideoBodyView,
|
||||
useCreateAutoDisposedViewModel,
|
||||
@ -21,8 +23,10 @@ import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDevi
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel";
|
||||
import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel";
|
||||
import { ImageBodyViewModel } from "../../../viewmodels/message-body/ImageBodyViewModel";
|
||||
import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel";
|
||||
import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs";
|
||||
|
||||
type MBodyComponent = React.ComponentType<IBodyProps>;
|
||||
|
||||
@ -134,6 +138,122 @@ export function VideoBodyFactory({
|
||||
);
|
||||
}
|
||||
|
||||
export function ImageBodyFactory({
|
||||
mxEvent,
|
||||
mediaEventHelper,
|
||||
forExport,
|
||||
maxImageHeight,
|
||||
permalinkCreator,
|
||||
showFileInfo,
|
||||
}: Readonly<
|
||||
Pick<
|
||||
IBodyProps,
|
||||
"mxEvent" | "mediaEventHelper" | "forExport" | "maxImageHeight" | "permalinkCreator" | "showFileInfo"
|
||||
>
|
||||
>): JSX.Element {
|
||||
const { timelineRenderingType } = useContext(RoomContext);
|
||||
const [mediaVisible, setMediaVisible] = useMediaVisible(mxEvent);
|
||||
const imageRef = useRef<HTMLImageElement>(null);
|
||||
const content = mxEvent.getContent<ImageContent>();
|
||||
const shouldFallbackToFileBody =
|
||||
mediaEventHelper?.media.isEncrypted === true &&
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info;
|
||||
|
||||
const vm = useCreateAutoDisposedViewModel(
|
||||
() =>
|
||||
new ImageBodyViewModel({
|
||||
mxEvent,
|
||||
mediaEventHelper,
|
||||
forExport,
|
||||
maxImageHeight,
|
||||
mediaVisible,
|
||||
permalinkCreator,
|
||||
timelineRenderingType,
|
||||
imageRef,
|
||||
setMediaVisible,
|
||||
}),
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.loadInitialMediaIfVisible();
|
||||
}, [shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setEvent(mxEvent, mediaEventHelper);
|
||||
}, [mediaEventHelper, mxEvent, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setForExport(forExport);
|
||||
}, [forExport, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setMaxImageHeight(maxImageHeight);
|
||||
}, [maxImageHeight, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setMediaVisible(mediaVisible);
|
||||
}, [mediaVisible, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setPermalinkCreator(permalinkCreator);
|
||||
}, [permalinkCreator, shouldFallbackToFileBody, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setTimelineRenderingType(timelineRenderingType);
|
||||
}, [shouldFallbackToFileBody, timelineRenderingType, vm]);
|
||||
|
||||
useEffect(() => {
|
||||
if (shouldFallbackToFileBody) return;
|
||||
vm.setSetMediaVisible(setMediaVisible);
|
||||
}, [setMediaVisible, shouldFallbackToFileBody, vm]);
|
||||
|
||||
const showFileBody =
|
||||
!forExport &&
|
||||
timelineRenderingType !== TimelineRenderingType.Room &&
|
||||
timelineRenderingType !== TimelineRenderingType.Pinned &&
|
||||
timelineRenderingType !== TimelineRenderingType.Search &&
|
||||
timelineRenderingType !== TimelineRenderingType.Thread &&
|
||||
timelineRenderingType !== TimelineRenderingType.ThreadsList;
|
||||
|
||||
if (shouldFallbackToFileBody) {
|
||||
return (
|
||||
<FileBodyFactory
|
||||
mxEvent={mxEvent}
|
||||
mediaEventHelper={mediaEventHelper}
|
||||
forExport={forExport}
|
||||
showFileInfo={showFileInfo}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ImageBodyView
|
||||
vm={vm}
|
||||
className="mx_ImageBody"
|
||||
containerClassName="mx_ImageBody_container"
|
||||
imageClassName="mx_ImageBody_image"
|
||||
imageRef={imageRef}
|
||||
>
|
||||
{showFileBody ? (
|
||||
<FileBodyFactory
|
||||
mxEvent={mxEvent}
|
||||
mediaEventHelper={mediaEventHelper}
|
||||
forExport={forExport}
|
||||
showFileInfo={false}
|
||||
/>
|
||||
) : null}
|
||||
</ImageBodyView>
|
||||
);
|
||||
}
|
||||
|
||||
export function RedactedBodyFactory({ mxEvent, ref }: Pick<IBodyProps, "mxEvent" | "ref">): JSX.Element {
|
||||
const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent }));
|
||||
|
||||
@ -164,6 +284,7 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick<IBodyProps,
|
||||
|
||||
// Message body factory registry for bodies that already route through view-model-backed wrappers.
|
||||
const MESSAGE_BODY_TYPES = new Map<string, MBodyComponent>([
|
||||
[MsgType.Image, ImageBodyFactory],
|
||||
[MsgType.File, FileBodyFactory],
|
||||
[MsgType.Video, VideoBodyFactory],
|
||||
]);
|
||||
|
||||
@ -1,714 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2015-2021 The Matrix.org Foundation C.I.C.
|
||||
Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react";
|
||||
import { Blurhash } from "react-blurhash";
|
||||
import classNames from "classnames";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components";
|
||||
|
||||
import Modal from "../../../Modal";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { type Media, mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { createReconnectedListener } from "../../../utils/connection";
|
||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
enum Placeholder {
|
||||
NoImage,
|
||||
Blurhash,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contentUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
isAnimated?: boolean;
|
||||
error?: unknown;
|
||||
imgError: boolean;
|
||||
imgLoaded: boolean;
|
||||
loadedImageDimensions?: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
hover: boolean;
|
||||
focus: boolean;
|
||||
placeholder: Placeholder;
|
||||
}
|
||||
|
||||
interface IProps extends IBodyProps {
|
||||
/**
|
||||
* Should the media be behind a preview.
|
||||
*/
|
||||
mediaVisible: boolean;
|
||||
/**
|
||||
* Set the visibility of the media event.
|
||||
* @param visible Should the event be visible.
|
||||
*/
|
||||
setMediaVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private Only use for inheritance. Use the default export for presentation.
|
||||
*/
|
||||
export class MImageBodyInner extends React.Component<IProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private unmounted = false;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
private placeholder = createRef<HTMLDivElement>();
|
||||
private timeout?: number;
|
||||
private sizeWatcher?: string;
|
||||
|
||||
public state: IState = {
|
||||
contentUrl: null,
|
||||
thumbUrl: null,
|
||||
imgError: false,
|
||||
imgLoaded: false,
|
||||
hover: false,
|
||||
focus: false,
|
||||
placeholder: Placeholder.NoImage,
|
||||
};
|
||||
|
||||
protected onClick = (ev: React.MouseEvent): void => {
|
||||
if (ev.button === 0 && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
if (!this.props.mediaVisible) {
|
||||
this.props.setMediaVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
let httpUrl = this.state.contentUrl;
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
|
||||
) {
|
||||
// contentUrl will be a blob URI mime-type=application/octet-stream so fall back to the thumbUrl instead
|
||||
httpUrl = this.state.thumbUrl;
|
||||
}
|
||||
|
||||
if (!httpUrl) return;
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
||||
if (content.info) {
|
||||
params.width = content.info.w;
|
||||
params.height = content.info.h;
|
||||
params.fileSize = content.info.size;
|
||||
}
|
||||
|
||||
if (this.image.current) {
|
||||
const clientRect = this.image.current.getBoundingClientRect();
|
||||
|
||||
params.thumbnailInfo = {
|
||||
width: clientRect.width,
|
||||
height: clientRect.height,
|
||||
positionX: clientRect.x,
|
||||
positionY: clientRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}
|
||||
};
|
||||
|
||||
private get shouldAutoplay(): boolean {
|
||||
return !(
|
||||
!this.state.contentUrl ||
|
||||
!this.props.mediaVisible ||
|
||||
!this.state.isAnimated ||
|
||||
SettingsStore.getValue("autoplayGifs")
|
||||
);
|
||||
}
|
||||
|
||||
protected onImageEnter = (): void => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
protected onImageLeave = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
private onFocus = (): void => {
|
||||
this.setState({ focus: true });
|
||||
};
|
||||
|
||||
private onBlur = (): void => {
|
||||
this.setState({ focus: false });
|
||||
};
|
||||
|
||||
private reconnectedListener = createReconnectedListener((): void => {
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.setState({ imgError: false });
|
||||
});
|
||||
|
||||
private onImageError = (): void => {
|
||||
// If the thumbnail failed to load then try again using the contentUrl
|
||||
if (this.state.thumbUrl) {
|
||||
this.setState({
|
||||
thumbUrl: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
this.setState({
|
||||
imgError: true,
|
||||
});
|
||||
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
|
||||
};
|
||||
|
||||
private onImageLoad = (): void => {
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
let loadedImageDimensions: IState["loadedImageDimensions"];
|
||||
|
||||
if (this.image.current) {
|
||||
const { naturalWidth, naturalHeight } = this.image.current;
|
||||
// this is only used as a fallback in case content.info.w/h is missing
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
this.setState({ imgLoaded: true, loadedImageDimensions });
|
||||
};
|
||||
|
||||
private getContentUrl(): string | null {
|
||||
// During export, the content url will point to the MSC, which will later point to a local url
|
||||
if (this.props.forExport) return this.media.srcMxc;
|
||||
return this.media.srcHttp;
|
||||
}
|
||||
|
||||
private get media(): Media {
|
||||
return mediaFromContent(this.props.mxEvent.getContent());
|
||||
}
|
||||
|
||||
private getThumbUrl(): string | null {
|
||||
// FIXME: we let images grow as wide as you like, rather than capped to 800x600.
|
||||
// So either we need to support custom timeline widths here, or reimpose the cap, otherwise the
|
||||
// thumbnail resolution will be unnecessarily reduced.
|
||||
// custom timeline widths seems preferable.
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
const media = mediaFromContent(content);
|
||||
const info = content.info;
|
||||
|
||||
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
|
||||
// Special-case to return clientside sender-generated thumbnails for SVGs, if any,
|
||||
// given we deliberately don't thumbnail them serverside to prevent billion lol attacks and similar.
|
||||
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
|
||||
}
|
||||
|
||||
// we try to download the correct resolution for hi-res images (like retina screenshots).
|
||||
// Synapse only supports 800x600 thumbnails for now though,
|
||||
// so we'll need to download the original image for this to work well for now.
|
||||
// First, let's try a few cases that let us avoid downloading the original, including:
|
||||
// - When displaying a GIF, we always want to thumbnail so that we can
|
||||
// properly respect the user's GIF autoplay setting (which relies on
|
||||
// thumbnailing to produce the static preview image)
|
||||
// - On a low DPI device, always thumbnail to save bandwidth
|
||||
// - If there's no sizing info in the event, default to thumbnail
|
||||
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
// We should only request thumbnails if the image is bigger than 800x600 (or 1600x1200 on retina) otherwise
|
||||
// the image in the timeline will just end up resampled and de-retina'd for no good reason.
|
||||
// Ideally the server would pre-gen 1600x1200 thumbnails in order to provide retina thumbnails,
|
||||
// but we don't do this currently in synapse for fear of disk space.
|
||||
// As a compromise, let's switch to non-retina thumbnails only if the original image is both
|
||||
// physically too large and going to be massive to load in the timeline (e.g. >1MB).
|
||||
|
||||
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
|
||||
const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
// image is too large physically and byte-wise to clutter our timeline so,
|
||||
// we ask for a thumbnail, despite knowing that it will be max 800x600
|
||||
// despite us being retina (as synapse doesn't do 1600x1200 thumbs yet).
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
// download the original image otherwise, so we can scale it client side to take pixelRatio into account.
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<void> {
|
||||
if (this.state.contentUrl) return; // already downloaded
|
||||
|
||||
let thumbUrl: string | null;
|
||||
let contentUrl: string | null;
|
||||
if (this.props.mediaEventHelper?.media.isEncrypted) {
|
||||
try {
|
||||
[contentUrl, thumbUrl] = await Promise.all([
|
||||
this.props.mediaEventHelper.sourceUrl.value,
|
||||
this.props.mediaEventHelper.thumbnailUrl.value,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (error instanceof DecryptError) {
|
||||
logger.error("Unable to decrypt attachment: ", error);
|
||||
} else if (error instanceof DownloadError) {
|
||||
logger.error("Unable to download attachment to decrypt it: ", error);
|
||||
} else {
|
||||
logger.error("Error encountered when downloading encrypted attachment: ", error);
|
||||
}
|
||||
|
||||
// Set a placeholder image when we can't decrypt the image.
|
||||
this.setState({ error });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
thumbUrl = this.getThumbUrl();
|
||||
contentUrl = this.getContentUrl();
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype);
|
||||
|
||||
// If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server
|
||||
// because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail.
|
||||
if (isAnimated && !SettingsStore.getValue("autoplayGifs")) {
|
||||
if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
|
||||
const img = document.createElement("img");
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
img.crossOrigin = "Anonymous"; // CORS allow canvas access
|
||||
img.src = contentUrl ?? "";
|
||||
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch (error) {
|
||||
logger.error("Unable to download attachment: ", error);
|
||||
this.setState({ error: error as Error });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// If we didn't receive the MSC4230 is_animated flag
|
||||
// then we need to check if the image is animated by downloading it.
|
||||
if (
|
||||
content.info?.["org.matrix.msc4230.is_animated"] === false ||
|
||||
(await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false
|
||||
) {
|
||||
isAnimated = false;
|
||||
}
|
||||
|
||||
if (isAnimated) {
|
||||
const thumb = await createThumbnail(
|
||||
img,
|
||||
img.width,
|
||||
img.height,
|
||||
content.info?.mimetype ?? "image/jpeg",
|
||||
false,
|
||||
);
|
||||
thumbUrl = URL.createObjectURL(thumb.thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
// This is a non-critical failure, do not surface the error or bail the method here
|
||||
logger.warn("Unable to generate thumbnail for animated image: ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
contentUrl,
|
||||
thumbUrl,
|
||||
isAnimated,
|
||||
});
|
||||
}
|
||||
|
||||
private clearBlurhashTimeout(): void {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
|
||||
if (this.props.mediaVisible) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.downloadImage();
|
||||
}
|
||||
|
||||
// Add a 150ms timer for blurhash to first appear.
|
||||
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
|
||||
this.clearBlurhashTimeout();
|
||||
this.timeout = window.setTimeout(() => {
|
||||
if (!this.state.imgLoaded || !this.state.imgError) {
|
||||
this.setState({
|
||||
placeholder: Placeholder.Blurhash,
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
|
||||
this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<IProps>): void {
|
||||
if (!prevProps.mediaVisible && this.props.mediaVisible) {
|
||||
// noinspection JSIgnoredPromiseFromCall
|
||||
this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.clearBlurhashTimeout();
|
||||
SettingsStore.unwatchSetting(this.sizeWatcher);
|
||||
if (this.state.isAnimated && this.state.thumbUrl) {
|
||||
URL.revokeObjectURL(this.state.thumbUrl);
|
||||
}
|
||||
}
|
||||
|
||||
protected getBanner(content: ImageContent): ReactNode {
|
||||
// Hide it for the threads list & the file panel where we show it as text anyway.
|
||||
if (
|
||||
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="mx_MImageBody_banner">
|
||||
{presentableTextForFile(content, _t("common|image"), true, true)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
protected messageContent(
|
||||
contentUrl: string | null,
|
||||
thumbUrl: string | null,
|
||||
content: ImageContent,
|
||||
forcedHeight?: number,
|
||||
): ReactNode {
|
||||
if (!thumbUrl) thumbUrl = contentUrl; // fallback
|
||||
|
||||
// magic number
|
||||
// edge case for this not to be set by conditions below
|
||||
let infoWidth = 500;
|
||||
let infoHeight = 500;
|
||||
let infoSvg = false;
|
||||
|
||||
if (content.info?.w && content.info?.h) {
|
||||
infoWidth = content.info.w;
|
||||
infoHeight = content.info.h;
|
||||
infoSvg = content.info.mimetype === "image/svg+xml";
|
||||
} else if (thumbUrl && contentUrl) {
|
||||
// Whilst the image loads, display nothing. We also don't display a blurhash image
|
||||
// because we don't really know what size of image we'll end up with.
|
||||
//
|
||||
// Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`.
|
||||
//
|
||||
// By doing this, the image "pops" into the timeline, but is still restricted
|
||||
// by the same width and height logic below.
|
||||
if (!this.state.loadedImageDimensions) {
|
||||
let imageElement: JSX.Element;
|
||||
if (!this.props.mediaVisible) {
|
||||
imageElement = (
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
);
|
||||
} else {
|
||||
imageElement = (
|
||||
<img
|
||||
style={{ display: "none" }}
|
||||
src={thumbUrl}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.wrapImage(contentUrl, imageElement);
|
||||
}
|
||||
infoWidth = this.state.loadedImageDimensions.naturalWidth;
|
||||
infoHeight = this.state.loadedImageDimensions.naturalHeight;
|
||||
}
|
||||
|
||||
// The maximum size of the thumbnail as it is rendered as an <img>,
|
||||
// accounting for any height constraints
|
||||
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
|
||||
SettingsStore.getValue("Images.size") as ImageSize,
|
||||
{ w: infoWidth, h: infoHeight },
|
||||
forcedHeight ?? this.props.maxImageHeight,
|
||||
);
|
||||
|
||||
let img: JSX.Element | undefined;
|
||||
let placeholder: JSX.Element | undefined;
|
||||
let gifLabel: JSX.Element | undefined;
|
||||
|
||||
if (!this.props.forExport && !this.state.imgLoaded) {
|
||||
const classes = classNames("mx_MImageBody_placeholder", {
|
||||
"mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
|
||||
});
|
||||
|
||||
placeholder = (
|
||||
<div className={classes} ref={this.placeholder}>
|
||||
{this.getPlaceholder(maxWidth, maxHeight)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let showPlaceholder = Boolean(placeholder);
|
||||
|
||||
const hoverOrFocus = this.state.hover || this.state.focus;
|
||||
if (thumbUrl && !this.state.imgError) {
|
||||
let url = thumbUrl;
|
||||
if (hoverOrFocus && this.shouldAutoplay) {
|
||||
url = this.state.contentUrl!;
|
||||
}
|
||||
|
||||
// Restrict the width of the thumbnail here, otherwise it will fill the container
|
||||
// which has the same width as the timeline
|
||||
// mx_MImageBody_thumbnail resizes img to exactly container size
|
||||
img = (
|
||||
<img
|
||||
className="mx_MImageBody_thumbnail"
|
||||
src={url}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.props.mediaVisible) {
|
||||
img = (
|
||||
<div style={{ width: maxWidth, height: maxHeight }}>
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
</div>
|
||||
);
|
||||
showPlaceholder = false; // because we're hiding the image, so don't show the placeholder.
|
||||
}
|
||||
|
||||
if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) {
|
||||
// XXX: Arguably we may want a different label when the animated image is WEBP and not GIF
|
||||
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
||||
}
|
||||
|
||||
let banner: ReactNode | undefined;
|
||||
if (this.props.mediaVisible && hoverOrFocus) {
|
||||
banner = this.getBanner(content);
|
||||
}
|
||||
|
||||
// many SVGs don't have an intrinsic size if used in <img> elements.
|
||||
// due to this we have to set our desired width directly.
|
||||
// this way if the image is forced to shrink, the height adapts appropriately.
|
||||
const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth };
|
||||
|
||||
if (!this.props.forExport) {
|
||||
placeholder = (
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
classNames="mx_rtg--fade"
|
||||
key={`img-${showPlaceholder}`}
|
||||
timeout={300}
|
||||
nodeRef={this.placeholder}
|
||||
>
|
||||
{
|
||||
showPlaceholder ? (
|
||||
placeholder
|
||||
) : (
|
||||
<div ref={this.placeholder} />
|
||||
) /* Transition always expects a child */
|
||||
}
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipProps = this.getTooltipProps();
|
||||
let thumbnail = (
|
||||
<div
|
||||
className="mx_MImageBody_thumbnail_container"
|
||||
style={{ maxHeight, maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}
|
||||
tabIndex={tooltipProps ? 0 : undefined}
|
||||
>
|
||||
{placeholder}
|
||||
|
||||
<div style={sizing}>
|
||||
{img}
|
||||
{gifLabel}
|
||||
{banner}
|
||||
</div>
|
||||
|
||||
{/* HACK: This div fills out space while the image loads, to prevent scroll jumps */}
|
||||
{!this.props.forExport && !this.state.imgLoaded && !placeholder && (
|
||||
<div style={{ height: maxHeight, width: maxWidth }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltipProps) {
|
||||
// We specify isTriggerInteractive=true and make the div interactive manually as a workaround for
|
||||
// https://github.com/element-hq/compound/issues/294
|
||||
thumbnail = (
|
||||
<Tooltip {...tooltipProps} isTriggerInteractive={true}>
|
||||
{thumbnail}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return this.wrapImage(contentUrl, thumbnail);
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode {
|
||||
if (contentUrl) {
|
||||
return (
|
||||
<a
|
||||
href={contentUrl}
|
||||
target={this.props.forExport ? "_blank" : undefined}
|
||||
onClick={this.onClick}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected getPlaceholder(width: number, height: number): ReactNode {
|
||||
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
|
||||
|
||||
if (blurhash) {
|
||||
if (this.state.placeholder === Placeholder.NoImage) {
|
||||
return null;
|
||||
} else if (this.state.placeholder === Placeholder.Blurhash) {
|
||||
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
|
||||
}
|
||||
}
|
||||
return <Spinner size={32} />;
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected getTooltipProps(): ComponentProps<typeof Tooltip> | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Overridden by MStickerBody
|
||||
protected getFileBody(): ReactNode {
|
||||
if (this.props.forExport) return null;
|
||||
/*
|
||||
* In the room timeline or the thread context we don't need the download
|
||||
* link as the message action bar will fulfill that
|
||||
*/
|
||||
const hasMessageActionBar =
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Room ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Pinned ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Search ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
|
||||
if (!hasMessageActionBar) {
|
||||
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
// Fall back to file-body view if we are unable to render this image e.g. in the case of a blob svg
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info
|
||||
) {
|
||||
return renderMBody(this.props, FileBodyFactory);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
let errorText = _t("timeline|m.image|error");
|
||||
if (this.state.error instanceof DecryptError) {
|
||||
errorText = _t("timeline|m.image|error_decrypting");
|
||||
} else if (this.state.error instanceof DownloadError) {
|
||||
errorText = _t("timeline|m.image|error_downloading");
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaProcessingError className="mx_MImageBody" Icon={ImageErrorIcon}>
|
||||
{errorText}
|
||||
</MediaProcessingError>
|
||||
);
|
||||
}
|
||||
|
||||
let contentUrl = this.state.contentUrl;
|
||||
let thumbUrl: string | null;
|
||||
if (this.props.forExport) {
|
||||
contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url;
|
||||
thumbUrl = contentUrl;
|
||||
} else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) {
|
||||
thumbUrl = contentUrl;
|
||||
} else {
|
||||
thumbUrl = this.state.thumbUrl ?? this.state.contentUrl;
|
||||
}
|
||||
|
||||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||
const fileBody = this.getFileBody();
|
||||
|
||||
return (
|
||||
<div className="mx_MImageBody">
|
||||
{thumbnail}
|
||||
{fileBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap MImageBody component so we can use a hook here.
|
||||
const MImageBody: React.FC<IBodyProps> = (props) => {
|
||||
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
|
||||
return <MImageBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
|
||||
};
|
||||
|
||||
export default MImageBody;
|
||||
@ -6,16 +6,621 @@ 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 } from "react";
|
||||
import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react";
|
||||
import { Blurhash } from "react-blurhash";
|
||||
import classNames from "classnames";
|
||||
import { CSSTransition, SwitchTransition } from "react-transition-group";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import { Tooltip } from "@vector-im/compound-web";
|
||||
import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components";
|
||||
|
||||
import { MImageBodyInner } from "./MImageBody";
|
||||
import Modal from "../../../Modal";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import SettingsStore from "../../../settings/SettingsStore";
|
||||
import Spinner from "../elements/Spinner";
|
||||
import { type Media, mediaFromContent } from "../../../customisations/Media";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media";
|
||||
import ImageView from "../elements/ImageView";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize";
|
||||
import { MatrixClientPeg } from "../../../MatrixClientPeg";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext";
|
||||
import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image";
|
||||
import { presentableTextForFile } from "../../../utils/FileUtils";
|
||||
import { createReconnectedListener } from "../../../utils/connection";
|
||||
import MediaProcessingError from "./shared/MediaProcessingError";
|
||||
import { DecryptError, DownloadError } from "../../../utils/DecryptFile";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
import { isMimeTypeAllowed } from "../../../utils/blobs.ts";
|
||||
import { FileBodyFactory, renderMBody } from "./MBodyFactory";
|
||||
|
||||
enum Placeholder {
|
||||
NoImage,
|
||||
Blurhash,
|
||||
}
|
||||
|
||||
interface IState {
|
||||
contentUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
isAnimated?: boolean;
|
||||
error?: unknown;
|
||||
imgError: boolean;
|
||||
imgLoaded: boolean;
|
||||
loadedImageDimensions?: {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
};
|
||||
hover: boolean;
|
||||
focus: boolean;
|
||||
placeholder: Placeholder;
|
||||
}
|
||||
|
||||
export interface ImageBodyBaseProps extends IBodyProps {
|
||||
mediaVisible: boolean;
|
||||
setMediaVisible: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
export class ImageBodyBaseInner extends React.Component<ImageBodyBaseProps, IState> {
|
||||
public static contextType = RoomContext;
|
||||
declare public context: React.ContextType<typeof RoomContext>;
|
||||
|
||||
private unmounted = false;
|
||||
private image = createRef<HTMLImageElement>();
|
||||
private placeholder = createRef<HTMLDivElement>();
|
||||
private timeout?: number;
|
||||
private sizeWatcher?: string;
|
||||
|
||||
public state: IState = {
|
||||
contentUrl: null,
|
||||
thumbUrl: null,
|
||||
imgError: false,
|
||||
imgLoaded: false,
|
||||
hover: false,
|
||||
focus: false,
|
||||
placeholder: Placeholder.NoImage,
|
||||
};
|
||||
|
||||
protected onClick = (ev: React.MouseEvent): void => {
|
||||
if (ev.button === 0 && !ev.metaKey) {
|
||||
ev.preventDefault();
|
||||
if (!this.props.mediaVisible) {
|
||||
this.props.setMediaVisible(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
let httpUrl = this.state.contentUrl;
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
|
||||
) {
|
||||
httpUrl = this.state.thumbUrl;
|
||||
}
|
||||
|
||||
if (!httpUrl) return;
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
||||
if (content.info) {
|
||||
params.width = content.info.w;
|
||||
params.height = content.info.h;
|
||||
params.fileSize = content.info.size;
|
||||
}
|
||||
|
||||
if (this.image.current) {
|
||||
const clientRect = this.image.current.getBoundingClientRect();
|
||||
|
||||
params.thumbnailInfo = {
|
||||
width: clientRect.width,
|
||||
height: clientRect.height,
|
||||
positionX: clientRect.x,
|
||||
positionY: clientRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}
|
||||
};
|
||||
|
||||
private get shouldAutoplay(): boolean {
|
||||
return !(
|
||||
!this.state.contentUrl ||
|
||||
!this.props.mediaVisible ||
|
||||
!this.state.isAnimated ||
|
||||
SettingsStore.getValue("autoplayGifs")
|
||||
);
|
||||
}
|
||||
|
||||
protected onImageEnter = (): void => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
protected onImageLeave = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
private onFocus = (): void => {
|
||||
this.setState({ focus: true });
|
||||
};
|
||||
|
||||
private onBlur = (): void => {
|
||||
this.setState({ focus: false });
|
||||
};
|
||||
|
||||
private reconnectedListener = createReconnectedListener((): void => {
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.setState({ imgError: false });
|
||||
});
|
||||
|
||||
private onImageError = (): void => {
|
||||
if (this.state.thumbUrl) {
|
||||
this.setState({
|
||||
thumbUrl: null,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
this.setState({
|
||||
imgError: true,
|
||||
});
|
||||
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
|
||||
};
|
||||
|
||||
private onImageLoad = (): void => {
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
let loadedImageDimensions: IState["loadedImageDimensions"];
|
||||
|
||||
if (this.image.current) {
|
||||
const { naturalWidth, naturalHeight } = this.image.current;
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
this.setState({ imgLoaded: true, loadedImageDimensions });
|
||||
};
|
||||
|
||||
private getContentUrl(): string | null {
|
||||
if (this.props.forExport) return this.media.srcMxc;
|
||||
return this.media.srcHttp;
|
||||
}
|
||||
|
||||
private get media(): Media {
|
||||
return mediaFromContent(this.props.mxEvent.getContent());
|
||||
}
|
||||
|
||||
private getThumbUrl(): string | null {
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
const media = mediaFromContent(content);
|
||||
const info = content.info;
|
||||
|
||||
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
|
||||
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
|
||||
}
|
||||
|
||||
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
|
||||
const isLargeFileSize = info.size > 1 * 1024 * 1024;
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<void> {
|
||||
if (this.state.contentUrl) return;
|
||||
|
||||
let thumbUrl: string | null;
|
||||
let contentUrl: string | null;
|
||||
if (this.props.mediaEventHelper?.media.isEncrypted) {
|
||||
try {
|
||||
[contentUrl, thumbUrl] = await Promise.all([
|
||||
this.props.mediaEventHelper.sourceUrl.value,
|
||||
this.props.mediaEventHelper.thumbnailUrl.value,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (this.unmounted) return;
|
||||
|
||||
if (error instanceof DecryptError) {
|
||||
logger.error("Unable to decrypt attachment: ", error);
|
||||
} else if (error instanceof DownloadError) {
|
||||
logger.error("Unable to download attachment to decrypt it: ", error);
|
||||
} else {
|
||||
logger.error("Error encountered when downloading encrypted attachment: ", error);
|
||||
}
|
||||
|
||||
this.setState({ error });
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
thumbUrl = this.getThumbUrl();
|
||||
contentUrl = this.getContentUrl();
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype);
|
||||
|
||||
if (isAnimated && !SettingsStore.getValue("autoplayGifs")) {
|
||||
if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
|
||||
const img = document.createElement("img");
|
||||
const loadPromise = new Promise((resolve, reject) => {
|
||||
img.onload = resolve;
|
||||
img.onerror = reject;
|
||||
});
|
||||
img.crossOrigin = "Anonymous";
|
||||
img.src = contentUrl ?? "";
|
||||
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch (error) {
|
||||
logger.error("Unable to download attachment: ", error);
|
||||
this.setState({ error: error as Error });
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
content.info?.["org.matrix.msc4230.is_animated"] === false ||
|
||||
(await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false
|
||||
) {
|
||||
isAnimated = false;
|
||||
}
|
||||
|
||||
if (isAnimated) {
|
||||
const thumb = await createThumbnail(
|
||||
img,
|
||||
img.width,
|
||||
img.height,
|
||||
content.info?.mimetype ?? "image/jpeg",
|
||||
false,
|
||||
);
|
||||
thumbUrl = URL.createObjectURL(thumb.thumbnail);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Unable to generate thumbnail for animated image: ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.unmounted) return;
|
||||
this.setState({
|
||||
contentUrl,
|
||||
thumbUrl,
|
||||
isAnimated,
|
||||
});
|
||||
}
|
||||
|
||||
private clearBlurhashTimeout(): void {
|
||||
if (this.timeout) {
|
||||
clearTimeout(this.timeout);
|
||||
this.timeout = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
public componentDidMount(): void {
|
||||
this.unmounted = false;
|
||||
|
||||
if (this.props.mediaVisible) {
|
||||
void this.downloadImage();
|
||||
}
|
||||
|
||||
if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) {
|
||||
this.clearBlurhashTimeout();
|
||||
this.timeout = window.setTimeout(() => {
|
||||
if (!this.state.imgLoaded || !this.state.imgError) {
|
||||
this.setState({
|
||||
placeholder: Placeholder.Blurhash,
|
||||
});
|
||||
}
|
||||
}, 150);
|
||||
}
|
||||
|
||||
this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => {
|
||||
this.forceUpdate();
|
||||
});
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<ImageBodyBaseProps>): void {
|
||||
if (!prevProps.mediaVisible && this.props.mediaVisible) {
|
||||
void this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.unmounted = true;
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.clearBlurhashTimeout();
|
||||
SettingsStore.unwatchSetting(this.sizeWatcher);
|
||||
if (this.state.isAnimated && this.state.thumbUrl) {
|
||||
URL.revokeObjectURL(this.state.thumbUrl);
|
||||
}
|
||||
}
|
||||
|
||||
protected getBanner(content: ImageContent): ReactNode {
|
||||
if (
|
||||
[TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<span className="mx_MImageBody_banner">
|
||||
{presentableTextForFile(content, _t("common|image"), true, true)}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
protected messageContent(
|
||||
contentUrl: string | null,
|
||||
thumbUrl: string | null,
|
||||
content: ImageContent,
|
||||
forcedHeight?: number,
|
||||
): ReactNode {
|
||||
if (!thumbUrl) thumbUrl = contentUrl;
|
||||
|
||||
let infoWidth = 500;
|
||||
let infoHeight = 500;
|
||||
let infoSvg = false;
|
||||
|
||||
if (content.info?.w && content.info?.h) {
|
||||
infoWidth = content.info.w;
|
||||
infoHeight = content.info.h;
|
||||
infoSvg = content.info.mimetype === "image/svg+xml";
|
||||
} else if (thumbUrl && contentUrl) {
|
||||
if (!this.state.loadedImageDimensions) {
|
||||
let imageElement: JSX.Element;
|
||||
if (!this.props.mediaVisible) {
|
||||
imageElement = (
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
);
|
||||
} else {
|
||||
imageElement = (
|
||||
<img
|
||||
style={{ display: "none" }}
|
||||
src={thumbUrl}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return this.wrapImage(contentUrl, imageElement);
|
||||
}
|
||||
infoWidth = this.state.loadedImageDimensions.naturalWidth;
|
||||
infoHeight = this.state.loadedImageDimensions.naturalHeight;
|
||||
}
|
||||
|
||||
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
|
||||
SettingsStore.getValue("Images.size") as ImageSize,
|
||||
{ w: infoWidth, h: infoHeight },
|
||||
forcedHeight ?? this.props.maxImageHeight,
|
||||
);
|
||||
|
||||
let img: JSX.Element | undefined;
|
||||
let placeholder: JSX.Element | undefined;
|
||||
let gifLabel: JSX.Element | undefined;
|
||||
|
||||
if (!this.props.forExport && !this.state.imgLoaded) {
|
||||
const classes = classNames("mx_MImageBody_placeholder", {
|
||||
"mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD],
|
||||
});
|
||||
|
||||
placeholder = (
|
||||
<div className={classes} ref={this.placeholder}>
|
||||
{this.getPlaceholder(maxWidth, maxHeight)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
let showPlaceholder = Boolean(placeholder);
|
||||
|
||||
const hoverOrFocus = this.state.hover || this.state.focus;
|
||||
if (thumbUrl && !this.state.imgError) {
|
||||
let url = thumbUrl;
|
||||
if (hoverOrFocus && this.shouldAutoplay) {
|
||||
url = this.state.contentUrl!;
|
||||
}
|
||||
|
||||
img = (
|
||||
<img
|
||||
className="mx_MImageBody_thumbnail"
|
||||
src={url}
|
||||
ref={this.image}
|
||||
alt={content.body}
|
||||
onError={this.onImageError}
|
||||
onLoad={this.onImageLoad}
|
||||
onMouseEnter={this.onImageEnter}
|
||||
onMouseLeave={this.onImageLeave}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!this.props.mediaVisible) {
|
||||
img = (
|
||||
<div style={{ width: maxWidth, height: maxHeight }}>
|
||||
<HiddenMediaPlaceholder onClick={this.onClick}>
|
||||
{_t("timeline|m.image|show_image")}
|
||||
</HiddenMediaPlaceholder>
|
||||
</div>
|
||||
);
|
||||
showPlaceholder = false;
|
||||
}
|
||||
|
||||
if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) {
|
||||
gifLabel = <p className="mx_MImageBody_gifLabel">GIF</p>;
|
||||
}
|
||||
|
||||
let banner: ReactNode | undefined;
|
||||
if (this.props.mediaVisible && hoverOrFocus) {
|
||||
banner = this.getBanner(content);
|
||||
}
|
||||
|
||||
const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth };
|
||||
|
||||
if (!this.props.forExport) {
|
||||
placeholder = (
|
||||
<SwitchTransition mode="out-in">
|
||||
<CSSTransition
|
||||
classNames="mx_rtg--fade"
|
||||
key={`img-${showPlaceholder}`}
|
||||
timeout={300}
|
||||
nodeRef={this.placeholder}
|
||||
>
|
||||
{showPlaceholder ? placeholder : <div ref={this.placeholder} />}
|
||||
</CSSTransition>
|
||||
</SwitchTransition>
|
||||
);
|
||||
}
|
||||
|
||||
const tooltipProps = this.getTooltipProps();
|
||||
let thumbnail = (
|
||||
<div
|
||||
className="mx_MImageBody_thumbnail_container"
|
||||
style={{ maxHeight, maxWidth, aspectRatio: `${infoWidth}/${infoHeight}` }}
|
||||
tabIndex={tooltipProps ? 0 : undefined}
|
||||
>
|
||||
{placeholder}
|
||||
|
||||
<div style={sizing}>
|
||||
{img}
|
||||
{gifLabel}
|
||||
{banner}
|
||||
</div>
|
||||
|
||||
{!this.props.forExport && !this.state.imgLoaded && !placeholder && (
|
||||
<div style={{ height: maxHeight, width: maxWidth }} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (tooltipProps) {
|
||||
thumbnail = (
|
||||
<Tooltip {...tooltipProps} isTriggerInteractive={true}>
|
||||
{thumbnail}
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
return this.wrapImage(contentUrl, thumbnail);
|
||||
}
|
||||
|
||||
protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode {
|
||||
if (contentUrl) {
|
||||
return (
|
||||
<a
|
||||
href={contentUrl}
|
||||
target={this.props.forExport ? "_blank" : undefined}
|
||||
onClick={this.onClick}
|
||||
onFocus={this.onFocus}
|
||||
onBlur={this.onBlur}
|
||||
>
|
||||
{children}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return children;
|
||||
}
|
||||
|
||||
protected getPlaceholder(width: number, height: number): ReactNode {
|
||||
const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD];
|
||||
|
||||
if (blurhash) {
|
||||
if (this.state.placeholder === Placeholder.NoImage) {
|
||||
return null;
|
||||
} else if (this.state.placeholder === Placeholder.Blurhash) {
|
||||
return <Blurhash className="mx_Blurhash" hash={blurhash} width={width} height={height} />;
|
||||
}
|
||||
}
|
||||
return <Spinner size={32} />;
|
||||
}
|
||||
|
||||
protected getTooltipProps(): ComponentProps<typeof Tooltip> | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
protected getFileBody(): ReactNode {
|
||||
if (this.props.forExport) return null;
|
||||
const hasMessageActionBar =
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Room ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Pinned ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Search ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.Thread ||
|
||||
this.context.timelineRenderingType === TimelineRenderingType.ThreadsList;
|
||||
if (!hasMessageActionBar) {
|
||||
return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory);
|
||||
}
|
||||
}
|
||||
|
||||
public render(): React.ReactNode {
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(content.info?.mimetype ?? "") &&
|
||||
!content.info?.thumbnail_info
|
||||
) {
|
||||
return renderMBody(this.props, FileBodyFactory);
|
||||
}
|
||||
|
||||
if (this.state.error) {
|
||||
let errorText = _t("timeline|m.image|error");
|
||||
if (this.state.error instanceof DecryptError) {
|
||||
errorText = _t("timeline|m.image|error_decrypting");
|
||||
} else if (this.state.error instanceof DownloadError) {
|
||||
errorText = _t("timeline|m.image|error_downloading");
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaProcessingError className="mx_MImageBody" Icon={ImageErrorIcon}>
|
||||
{errorText}
|
||||
</MediaProcessingError>
|
||||
);
|
||||
}
|
||||
|
||||
let contentUrl = this.state.contentUrl;
|
||||
let thumbUrl: string | null;
|
||||
if (this.props.forExport) {
|
||||
contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url;
|
||||
thumbUrl = contentUrl;
|
||||
} else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) {
|
||||
thumbUrl = contentUrl;
|
||||
} else {
|
||||
thumbUrl = this.state.thumbUrl ?? this.state.contentUrl;
|
||||
}
|
||||
|
||||
const thumbnail = this.messageContent(contentUrl, thumbUrl, content);
|
||||
const fileBody = this.getFileBody();
|
||||
|
||||
return (
|
||||
<div className="mx_MImageBody">
|
||||
{thumbnail}
|
||||
{fileBody}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const FORCED_IMAGE_HEIGHT = 44;
|
||||
|
||||
class MImageReplyBodyInner extends MImageBodyInner {
|
||||
class MImageReplyBodyInner extends ImageBodyBaseInner {
|
||||
public onClick = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
};
|
||||
@ -37,6 +642,7 @@ class MImageReplyBodyInner extends MImageBodyInner {
|
||||
return <div className="mx_MImageReplyBody">{thumbnail}</div>;
|
||||
}
|
||||
}
|
||||
|
||||
const MImageReplyBody: React.FC<IBodyProps> = (props) => {
|
||||
const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent);
|
||||
return <MImageReplyBodyInner mediaVisible={mediaVisible} setMediaVisible={setVisible} {...props} />;
|
||||
|
||||
@ -9,13 +9,13 @@ import React, { type JSX, type ComponentProps, type ReactNode } from "react";
|
||||
import { type Tooltip } from "@vector-im/compound-web";
|
||||
import { type MediaEventContent } from "matrix-js-sdk/src/types";
|
||||
|
||||
import { MImageBodyInner } from "./MImageBody";
|
||||
import { ImageBodyBaseInner } from "./MImageReplyBody";
|
||||
import { BLURHASH_FIELD } from "../../../utils/image-media";
|
||||
import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import { useMediaVisible } from "../../../hooks/useMediaVisible";
|
||||
|
||||
class MStickerBodyInner extends MImageBodyInner {
|
||||
class MStickerBodyInner extends ImageBodyBaseInner {
|
||||
// Mostly empty to prevent default behaviour of MImageBody
|
||||
protected onClick = (ev: React.MouseEvent): void => {
|
||||
ev.preventDefault();
|
||||
|
||||
@ -25,7 +25,6 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir";
|
||||
import { type IMediaBody } from "./IMediaBody";
|
||||
import { MediaEventHelper } from "../../../utils/MediaEventHelper";
|
||||
import { type IBodyProps } from "./IBodyProps";
|
||||
import MImageBody from "./MImageBody";
|
||||
import MVoiceOrAudioBody from "./MVoiceOrAudioBody";
|
||||
import MStickerBody from "./MStickerBody";
|
||||
import MPollBody from "./MPollBody";
|
||||
@ -36,6 +35,7 @@ import { MjolnirBodyViewModel } from "../../../viewmodels/room/timeline/event-ti
|
||||
import {
|
||||
DecryptionFailureBodyFactory,
|
||||
FileBodyFactory,
|
||||
ImageBodyFactory,
|
||||
RedactedBodyFactory,
|
||||
VideoBodyFactory,
|
||||
renderMBody,
|
||||
@ -67,7 +67,7 @@ const baseBodyTypes = new Map<string, React.ComponentType<IBodyProps>>([
|
||||
[MsgType.Text, TextualBodyFactory],
|
||||
[MsgType.Notice, TextualBodyFactory],
|
||||
[MsgType.Emote, TextualBodyFactory],
|
||||
[MsgType.Image, MImageBody],
|
||||
[MsgType.Image, ImageBodyFactory],
|
||||
[MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!],
|
||||
[MsgType.Audio, MVoiceOrAudioBody],
|
||||
[MsgType.Video, VideoBodyFactory],
|
||||
@ -283,7 +283,7 @@ export default class MessageEvent extends React.Component<IProps> implements IMe
|
||||
}
|
||||
|
||||
if (
|
||||
((BodyType === MImageBody || BodyType === VideoBodyFactory) &&
|
||||
((BodyType === ImageBodyFactory || BodyType === VideoBodyFactory) &&
|
||||
!this.validateImageOrVideoMimetype(content)) ||
|
||||
(BodyType === MStickerBody && !this.validateStickerMimetype(content))
|
||||
) {
|
||||
|
||||
@ -338,6 +338,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
|
||||
private unmounted = false;
|
||||
private readonly id = uniqueId();
|
||||
private staleHoverCheckActive = false;
|
||||
|
||||
public constructor(props: EventTileProps, context: React.ContextType<typeof RoomContext>) {
|
||||
super(props, context);
|
||||
@ -472,6 +473,7 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}
|
||||
|
||||
public componentWillUnmount(): void {
|
||||
this.stopStaleHoverCheck();
|
||||
const client = MatrixClientPeg.get();
|
||||
if (client) {
|
||||
client.removeListener(CryptoEvent.UserTrustStatusChanged, this.onUserVerificationChanged);
|
||||
@ -491,6 +493,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}
|
||||
|
||||
public componentDidUpdate(prevProps: Readonly<EventTileProps>, prevState: Readonly<IState>): void {
|
||||
// Some overlays, such as portalled tooltips, can interrupt the normal mouseleave path.
|
||||
// While hover is active, verify it against the browser's real :hover state on mouse movement.
|
||||
if (!prevState.hover && this.state.hover) {
|
||||
this.startStaleHoverCheck();
|
||||
} else if (prevState.hover && !this.state.hover) {
|
||||
this.stopStaleHoverCheck();
|
||||
}
|
||||
|
||||
// If we're not listening for receipts and expect to be, register a listener.
|
||||
if (!this.isListeningForReceipts && (this.shouldShowSentReceipt || this.shouldShowSendingReceipt)) {
|
||||
MatrixClientPeg.safeGet().on(RoomEvent.Receipt, this.onRoomReceipt);
|
||||
@ -502,6 +512,17 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}
|
||||
|
||||
if (this.props.resizeObserver && this.ref.current) this.props.resizeObserver.observe(this.ref.current);
|
||||
|
||||
// Moving between edited messages can remount the editor without a reliable blur event.
|
||||
// Clear stale focus-derived action bar state when focus has actually left this tile.
|
||||
if (
|
||||
this.state.focusWithin &&
|
||||
this.ref.current &&
|
||||
document.activeElement instanceof HTMLElement &&
|
||||
!this.ref.current.contains(document.activeElement)
|
||||
) {
|
||||
this.setState({ focusWithin: false, showActionBarFromFocus: false });
|
||||
}
|
||||
}
|
||||
|
||||
private readonly onNewThread = (thread: Thread): void => {
|
||||
@ -868,6 +889,32 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
}));
|
||||
};
|
||||
|
||||
private startStaleHoverCheck(): void {
|
||||
if (this.staleHoverCheckActive) return;
|
||||
document.addEventListener("mousemove", this.onDocumentMouseMove, true);
|
||||
this.staleHoverCheckActive = true;
|
||||
}
|
||||
|
||||
private stopStaleHoverCheck(): void {
|
||||
if (!this.staleHoverCheckActive) return;
|
||||
document.removeEventListener("mousemove", this.onDocumentMouseMove, true);
|
||||
this.staleHoverCheckActive = false;
|
||||
}
|
||||
|
||||
private readonly onDocumentMouseMove = (): void => {
|
||||
if (this.state.hover && !(this.ref.current?.matches(":hover") ?? false)) {
|
||||
this.setState({ hover: false });
|
||||
}
|
||||
};
|
||||
|
||||
private readonly onMouseEnter = (): void => {
|
||||
this.setState({ hover: true });
|
||||
};
|
||||
|
||||
private readonly onMouseLeave = (): void => {
|
||||
this.setState({ hover: false });
|
||||
};
|
||||
|
||||
private readonly onFocusWithin = (event: FocusEvent<HTMLElement>): void => {
|
||||
// Show the action toolbar for keyboard-visible focus, with what-input as a fallback signal.
|
||||
const target = event.target as HTMLElement;
|
||||
@ -1321,8 +1368,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
"data-layout": this.props.layout,
|
||||
"data-self": isOwnEvent,
|
||||
"data-event-id": this.props.mxEvent.getId(),
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
"onMouseEnter": this.onMouseEnter,
|
||||
"onMouseLeave": this.onMouseLeave,
|
||||
"onFocus": this.onFocusWithin,
|
||||
"onBlur": this.onBlurWithin,
|
||||
},
|
||||
@ -1384,8 +1431,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
"data-shape": this.context.timelineRenderingType,
|
||||
"data-self": isOwnEvent,
|
||||
"data-has-reply": !!replyChain,
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
"onMouseEnter": this.onMouseEnter,
|
||||
"onMouseLeave": this.onMouseLeave,
|
||||
"onFocus": this.onFocusWithin,
|
||||
"onBlur": this.onBlurWithin,
|
||||
"onClick": (ev: MouseEvent) => {
|
||||
@ -1517,8 +1564,8 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
|
||||
"data-self": isOwnEvent,
|
||||
"data-event-id": this.props.mxEvent.getId(),
|
||||
"data-has-reply": !!replyChain,
|
||||
"onMouseEnter": () => this.setState({ hover: true }),
|
||||
"onMouseLeave": () => this.setState({ hover: false }),
|
||||
"onMouseEnter": this.onMouseEnter,
|
||||
"onMouseLeave": this.onMouseLeave,
|
||||
"onFocus": this.onFocusWithin,
|
||||
"onBlur": this.onBlurWithin,
|
||||
},
|
||||
|
||||
@ -40,7 +40,7 @@ import { DefaultTagID } from "./skip-list/tag";
|
||||
import { ExcludeTagsFilter } from "./skip-list/filters/ExcludeTagsFilter";
|
||||
import { TagFilter } from "./skip-list/filters/TagFilter";
|
||||
import { filterBoolean } from "../../utils/arrays";
|
||||
import { createSection, deleteSection, editSection } from "./section";
|
||||
import { CHATS_TAG, createSection, deleteSection, editSection } from "./section";
|
||||
|
||||
/**
|
||||
* These are the filters passed to the room skip list.
|
||||
@ -86,12 +86,6 @@ export interface Section {
|
||||
rooms: Room[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A synthetic tag used to represent the "Chats" section, which contains
|
||||
* every room that does not belong to any other explicit tag section.
|
||||
*/
|
||||
export const CHATS_TAG = "chats";
|
||||
|
||||
export const LISTS_UPDATE_EVENT = RoomListStoreV3Event.ListsUpdate;
|
||||
export const LISTS_LOADED_EVENT = RoomListStoreV3Event.ListsLoaded;
|
||||
export const SECTION_CREATED_EVENT = RoomListStoreV3Event.SectionCreated;
|
||||
|
||||
@ -12,9 +12,16 @@ import SettingsStore from "../../settings/SettingsStore";
|
||||
import Modal from "../../Modal";
|
||||
import { CreateSectionDialog } from "../../components/views/dialogs/CreateSectionDialog";
|
||||
import { RemoveSectionDialog } from "../../components/views/dialogs/RemoveSectionDialog";
|
||||
import { DefaultTagID, type TagID } from "./skip-list/tag";
|
||||
|
||||
type Tag = string;
|
||||
|
||||
/**
|
||||
* A synthetic tag used to represent the "Chats" section, which contains
|
||||
* every room that does not belong to any other explicit tag section.
|
||||
*/
|
||||
export const CHATS_TAG = "chats";
|
||||
|
||||
/**
|
||||
* Prefix for custom section tags.
|
||||
*/
|
||||
@ -29,6 +36,24 @@ export function isCustomSectionTag(tag: string): boolean {
|
||||
return tag.startsWith(CUSTOM_SECTION_TAG_PREFIX);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given tag is a default section tag.
|
||||
* @param tagId - The tag to check.
|
||||
* @returns True if the tag is a default section tag, false otherwise.
|
||||
*/
|
||||
export function isDefaultSectionTag(tagId: TagID): boolean {
|
||||
return tagId === DefaultTagID.Favourite || tagId === DefaultTagID.LowPriority || tagId === CHATS_TAG;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a given tag is a section tag.
|
||||
* @param tagId - The tag to check.
|
||||
* @returns True if the tag is a section tag, false otherwise.
|
||||
*/
|
||||
export function isSectionTag(tagId: TagID): boolean {
|
||||
return isCustomSectionTag(tagId) || isDefaultSectionTag(tagId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Structure of the custom section stored in the settings. The tag is used as a unique identifier for the section, and the name is given by the user.
|
||||
*/
|
||||
|
||||
@ -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 { percentageOf, percentageWithin } from "@element-hq/web-shared-components";
|
||||
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers";
|
||||
|
||||
/**
|
||||
* Quickly resample an array to have less/more data points. If an input which is larger
|
||||
|
||||
21
apps/web/src/utils/room/getSectionTagForRoom.ts
Normal file
@ -0,0 +1,21 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations 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 { type TagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import { getTagsForRoom } from "./getTagsForRoom";
|
||||
import { isSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
/**
|
||||
* Get the section tag for a given room.
|
||||
* @param room The room to get the section tag for.
|
||||
* @returns The section tag ID or null if none found.
|
||||
*/
|
||||
export function getSectionTagForRoom(room: Room): TagID | null {
|
||||
return getTagsForRoom(room).find((t) => isSectionTag(t)) ?? null;
|
||||
}
|
||||
@ -9,11 +9,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
|
||||
import { DefaultTagID, type TagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import { type TagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import RoomListActions from "../../actions/RoomListActions";
|
||||
import dis from "../../dispatcher/dispatcher";
|
||||
import { getTagsForRoom } from "./getTagsForRoom";
|
||||
import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||
import { CHATS_TAG, isSectionTag } from "../../stores/room-list-v3/section";
|
||||
import { getSectionTagForRoom } from "./getSectionTagForRoom";
|
||||
|
||||
/**
|
||||
* Toggle tag for a given room.
|
||||
@ -23,19 +23,19 @@ import { isCustomSectionTag } from "../../stores/room-list-v3/section";
|
||||
* @param tagId The tag to invert
|
||||
*/
|
||||
export function tagRoom(room: Room, tagId: TagID): void {
|
||||
if (tagId !== DefaultTagID.Favourite && tagId !== DefaultTagID.LowPriority && !isCustomSectionTag(tagId)) {
|
||||
logger.warn(`Unexpected tag ${tagId} applied to ${room.roomId}`);
|
||||
const isChatTag = tagId === CHATS_TAG;
|
||||
const tag = isChatTag ? null : tagId;
|
||||
|
||||
if (!isSectionTag(tagId)) {
|
||||
logger.warn(`Unexpected tag ${tag} applied to ${room.roomId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Find the section tag currently applied (Fav, LowPriority, or custom) — at most one exists
|
||||
const currentSectionTag =
|
||||
getTagsForRoom(room).find(
|
||||
(t) => t === DefaultTagID.Favourite || t === DefaultTagID.LowPriority || isCustomSectionTag(t),
|
||||
) ?? null;
|
||||
const currentSectionTag = getSectionTagForRoom(room);
|
||||
|
||||
const isApplied = currentSectionTag === tagId;
|
||||
const isApplied = currentSectionTag === tag;
|
||||
const removeTag = currentSectionTag;
|
||||
const addTag = isApplied ? null : tagId;
|
||||
const addTag = isApplied ? null : tag;
|
||||
dis.dispatch(RoomListActions.tagRoom(room.client, room, removeTag, addTag));
|
||||
}
|
||||
|
||||
674
apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts
Normal file
@ -0,0 +1,674 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations 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 ComponentProps, type MouseEvent, type RefObject } from "react";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type ImageContent } from "matrix-js-sdk/src/types";
|
||||
import {
|
||||
BaseViewModel,
|
||||
ImageBodyViewPlaceholder,
|
||||
ImageBodyViewState,
|
||||
type ImageBodyViewModel as ImageBodyViewModelInterface,
|
||||
type ImageBodyViewSnapshot,
|
||||
} from "@element-hq/web-shared-components";
|
||||
|
||||
import Modal from "../../Modal";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { mediaFromContent } from "../../customisations/Media";
|
||||
import { TimelineRenderingType } from "../../contexts/RoomContext";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { type ImageSize, suggestedSize as suggestedImageSize } from "../../settings/enums/ImageSize";
|
||||
import { presentableTextForFile } from "../../utils/FileUtils";
|
||||
import { type MediaEventHelper } from "../../utils/MediaEventHelper";
|
||||
import { blobIsAnimated, mayBeAnimated } from "../../utils/Image";
|
||||
import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks";
|
||||
import { createReconnectedListener } from "../../utils/connection";
|
||||
import { DecryptError, DownloadError } from "../../utils/DecryptFile";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../utils/image-media";
|
||||
import { isMimeTypeAllowed } from "../../utils/blobs";
|
||||
import ImageView from "../../components/views/elements/ImageView";
|
||||
|
||||
export interface ImageBodyViewModelProps {
|
||||
/**
|
||||
* Image event being rendered.
|
||||
*/
|
||||
mxEvent: MatrixEvent;
|
||||
/**
|
||||
* Helper for resolving encrypted media sources.
|
||||
*/
|
||||
mediaEventHelper?: MediaEventHelper;
|
||||
/**
|
||||
* Whether the image is being rendered for export instead of the live timeline.
|
||||
*/
|
||||
forExport?: boolean;
|
||||
/**
|
||||
* Optional maximum height applied when computing the rendered image dimensions.
|
||||
*/
|
||||
maxImageHeight?: number;
|
||||
/**
|
||||
* Whether the media should currently be shown instead of the hidden-media preview.
|
||||
*/
|
||||
mediaVisible: boolean;
|
||||
/**
|
||||
* Permalink helper passed to the image lightbox.
|
||||
*/
|
||||
permalinkCreator?: RoomPermalinkCreator;
|
||||
/**
|
||||
* Timeline context used to decide which labels and supplemental content should be shown.
|
||||
*/
|
||||
timelineRenderingType: TimelineRenderingType;
|
||||
/**
|
||||
* Ref to the underlying image element used for load dimensions and lightbox animation.
|
||||
*/
|
||||
imageRef: RefObject<HTMLImageElement | null>;
|
||||
/**
|
||||
* Callback invoked when hidden media is revealed.
|
||||
*/
|
||||
setMediaVisible?: (visible: boolean) => void;
|
||||
}
|
||||
|
||||
interface LoadedImageDimensions {
|
||||
naturalWidth: number;
|
||||
naturalHeight: number;
|
||||
}
|
||||
|
||||
interface InternalState {
|
||||
contentUrl: string | null;
|
||||
thumbUrl: string | null;
|
||||
isAnimated: boolean;
|
||||
error: unknown | null;
|
||||
imgError: boolean;
|
||||
imgLoaded: boolean;
|
||||
loadedImageDimensions?: LoadedImageDimensions;
|
||||
placeholder: ImageBodyViewPlaceholder;
|
||||
imageSize: ImageSize;
|
||||
generatedThumbnailUrl: string | null;
|
||||
}
|
||||
|
||||
type ImageInfoWithAnimationFlag = NonNullable<ImageContent["info"]> & {
|
||||
"org.matrix.msc4230.is_animated"?: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* View model for the image message body, encapsulating media loading, sizing,
|
||||
* visibility, animated-image previews, and lightbox interactions.
|
||||
*/
|
||||
export class ImageBodyViewModel
|
||||
extends BaseViewModel<ImageBodyViewSnapshot, ImageBodyViewModelProps>
|
||||
implements ImageBodyViewModelInterface
|
||||
{
|
||||
private state: InternalState;
|
||||
private blurhashTimeout?: number;
|
||||
|
||||
private readonly reconnectedListener = createReconnectedListener((): void => {
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
|
||||
if (!this.state.imgError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imgError: false,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
});
|
||||
|
||||
public constructor(props: ImageBodyViewModelProps) {
|
||||
const initialState = ImageBodyViewModel.createInitialState(props.mxEvent);
|
||||
super(props, ImageBodyViewModel.computeSnapshot(props, initialState));
|
||||
this.state = initialState;
|
||||
|
||||
const imageSizeWatcherRef = SettingsStore.watchSetting("Images.size", null, (_s, _r, _l, _nvl, value) => {
|
||||
this.setImageSize(value as ImageSize);
|
||||
});
|
||||
this.disposables.track(() => SettingsStore.unwatchSetting(imageSizeWatcherRef));
|
||||
}
|
||||
|
||||
private static createInitialState(mxEvent: MatrixEvent): InternalState {
|
||||
return {
|
||||
contentUrl: null,
|
||||
thumbUrl: null,
|
||||
isAnimated: false,
|
||||
error: null,
|
||||
imgError: false,
|
||||
imgLoaded: false,
|
||||
loadedImageDimensions: undefined,
|
||||
placeholder: mxEvent.getContent<ImageContent>().info?.[BLURHASH_FIELD]
|
||||
? ImageBodyViewPlaceholder.NONE
|
||||
: ImageBodyViewPlaceholder.SPINNER,
|
||||
imageSize: SettingsStore.getValue("Images.size") as ImageSize,
|
||||
generatedThumbnailUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
private static getImageDimensions(
|
||||
props: ImageBodyViewModelProps,
|
||||
state: InternalState,
|
||||
): Pick<ImageBodyViewSnapshot, "maxWidth" | "maxHeight" | "aspectRatio" | "isSvg"> {
|
||||
const content = props.mxEvent.getContent<ImageContent>();
|
||||
const info = content.info;
|
||||
const naturalWidth = info?.w ?? state.loadedImageDimensions?.naturalWidth;
|
||||
const naturalHeight = info?.h ?? state.loadedImageDimensions?.naturalHeight;
|
||||
|
||||
if (!naturalWidth || !naturalHeight) {
|
||||
return {
|
||||
maxWidth: undefined,
|
||||
maxHeight: undefined,
|
||||
aspectRatio: undefined,
|
||||
isSvg: info?.mimetype === "image/svg+xml",
|
||||
};
|
||||
}
|
||||
|
||||
const { w: maxWidth, h: maxHeight } = suggestedImageSize(
|
||||
state.imageSize,
|
||||
{ w: naturalWidth, h: naturalHeight },
|
||||
props.maxImageHeight,
|
||||
);
|
||||
|
||||
return {
|
||||
maxWidth,
|
||||
maxHeight,
|
||||
aspectRatio: `${naturalWidth}/${naturalHeight}`,
|
||||
isSvg: info?.mimetype === "image/svg+xml",
|
||||
};
|
||||
}
|
||||
|
||||
private static computeErrorLabel(error: unknown, imgError: boolean): string {
|
||||
if (error instanceof DecryptError) return _t("timeline|m.image|error_decrypting");
|
||||
if (error instanceof DownloadError) return _t("timeline|m.image|error_downloading");
|
||||
if (imgError || error) return _t("timeline|m.image|error");
|
||||
|
||||
return _t("timeline|m.image|error");
|
||||
}
|
||||
|
||||
private static shouldShowBanner(timelineRenderingType: TimelineRenderingType): boolean {
|
||||
return ![TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(timelineRenderingType);
|
||||
}
|
||||
|
||||
private static computeSnapshot(props: ImageBodyViewModelProps, state: InternalState): ImageBodyViewSnapshot {
|
||||
const content = props.mxEvent.getContent<ImageContent>();
|
||||
const dimensions = ImageBodyViewModel.getImageDimensions(props, state);
|
||||
const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean;
|
||||
const contentUrl = ImageBodyViewModel.getContentUrl(props, state);
|
||||
const thumbnailSrc = props.forExport
|
||||
? (contentUrl ?? undefined)
|
||||
: state.isAnimated && autoplayGifs
|
||||
? (contentUrl ?? undefined)
|
||||
: (state.thumbUrl ?? contentUrl ?? undefined);
|
||||
|
||||
if (state.error || state.imgError) {
|
||||
return {
|
||||
state: ImageBodyViewState.ERROR,
|
||||
errorLabel: ImageBodyViewModel.computeErrorLabel(state.error, state.imgError),
|
||||
...dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
if (!props.mediaVisible) {
|
||||
return {
|
||||
state: ImageBodyViewState.HIDDEN,
|
||||
hiddenButtonLabel: _t("timeline|m.image|show_image"),
|
||||
...dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
state: ImageBodyViewState.READY,
|
||||
alt: content.body,
|
||||
src: contentUrl ?? undefined,
|
||||
thumbnailSrc,
|
||||
showAnimatedContentOnHover: state.isAnimated && !autoplayGifs && !!contentUrl,
|
||||
placeholder: !props.forExport && !state.imgLoaded ? state.placeholder : ImageBodyViewPlaceholder.NONE,
|
||||
blurhash: content.info?.[BLURHASH_FIELD],
|
||||
gifLabel: state.isAnimated && !autoplayGifs ? "GIF" : undefined,
|
||||
bannerLabel: ImageBodyViewModel.shouldShowBanner(props.timelineRenderingType)
|
||||
? presentableTextForFile(content, _t("common|image"), true, true)
|
||||
: undefined,
|
||||
linkUrl: contentUrl ?? undefined,
|
||||
linkTarget: props.forExport ? "_blank" : undefined,
|
||||
...dimensions,
|
||||
};
|
||||
}
|
||||
|
||||
private static getContentUrl(props: ImageBodyViewModelProps, state: InternalState): string | null {
|
||||
if (props.forExport) {
|
||||
return (
|
||||
props.mxEvent.getContent<ImageContent>().url ??
|
||||
props.mxEvent.getContent<ImageContent>().file?.url ??
|
||||
null
|
||||
);
|
||||
}
|
||||
|
||||
return state.contentUrl;
|
||||
}
|
||||
|
||||
public loadInitialMediaIfVisible(): void {
|
||||
if (!this.props.mediaVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.scheduleBlurhashPlaceholder();
|
||||
void this.downloadImage();
|
||||
}
|
||||
|
||||
private updateSnapshotFromState(): void {
|
||||
this.snapshot.set(ImageBodyViewModel.computeSnapshot(this.props, this.state));
|
||||
}
|
||||
|
||||
private resetState(mxEvent: MatrixEvent): void {
|
||||
this.clearBlurhashTimeout();
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.revokeGeneratedThumbnailUrl();
|
||||
this.state = ImageBodyViewModel.createInitialState(mxEvent);
|
||||
}
|
||||
|
||||
private revokeGeneratedThumbnailUrl(): void {
|
||||
if (!this.state.generatedThumbnailUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
URL.revokeObjectURL(this.state.generatedThumbnailUrl);
|
||||
this.state = {
|
||||
...this.state,
|
||||
generatedThumbnailUrl: null,
|
||||
};
|
||||
}
|
||||
|
||||
private clearBlurhashTimeout(): void {
|
||||
if (!this.blurhashTimeout) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.blurhashTimeout);
|
||||
this.blurhashTimeout = undefined;
|
||||
}
|
||||
|
||||
private scheduleBlurhashPlaceholder(): void {
|
||||
if (
|
||||
!this.props.mxEvent.getContent<ImageContent>().info?.[BLURHASH_FIELD] ||
|
||||
this.state.imgLoaded ||
|
||||
this.state.imgError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
this.blurhashTimeout = window.setTimeout(() => {
|
||||
if (this.isDisposed || this.state.imgLoaded || this.state.imgError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
placeholder: ImageBodyViewPlaceholder.BLURHASH,
|
||||
};
|
||||
this.snapshot.merge({ placeholder: ImageBodyViewPlaceholder.BLURHASH });
|
||||
}, 150);
|
||||
}
|
||||
|
||||
private getThumbUrl(): string | null {
|
||||
const thumbWidth = 800;
|
||||
const thumbHeight = 600;
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
const media = mediaFromContent(content);
|
||||
const info = content.info;
|
||||
|
||||
if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) {
|
||||
return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale");
|
||||
}
|
||||
|
||||
if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight;
|
||||
const isLargeFileSize = info.size > 1 * 1024 * 1024;
|
||||
|
||||
if (isLargeFileSize && isLargerThanThumbnail) {
|
||||
return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight);
|
||||
}
|
||||
|
||||
return media.srcHttp;
|
||||
}
|
||||
|
||||
private async downloadImage(): Promise<void> {
|
||||
if (this.state.contentUrl || this.props.forExport) {
|
||||
return;
|
||||
}
|
||||
|
||||
let thumbUrl: string | null;
|
||||
let contentUrl: string | null;
|
||||
|
||||
if (this.props.mediaEventHelper?.media.isEncrypted) {
|
||||
try {
|
||||
[contentUrl, thumbUrl] = await Promise.all([
|
||||
this.props.mediaEventHelper.sourceUrl.value,
|
||||
this.props.mediaEventHelper.thumbnailUrl.value,
|
||||
]);
|
||||
} catch (error) {
|
||||
if (this.isDisposed) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (error instanceof DecryptError) {
|
||||
logger.error("Unable to decrypt attachment: ", error);
|
||||
} else if (error instanceof DownloadError) {
|
||||
logger.error("Unable to download attachment to decrypt it: ", error);
|
||||
} else {
|
||||
logger.error("Error encountered when downloading encrypted attachment: ", error);
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
error: error as Error,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
contentUrl = mediaFromContent(this.props.mxEvent.getContent<ImageContent>()).srcHttp;
|
||||
thumbUrl = this.getThumbUrl();
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
let generatedThumbnailUrl: string | null = null;
|
||||
let isAnimated = (content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"];
|
||||
if (isAnimated === undefined) {
|
||||
isAnimated = mayBeAnimated(content.info?.mimetype);
|
||||
}
|
||||
|
||||
const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean;
|
||||
if (isAnimated && !autoplayGifs) {
|
||||
if (!thumbUrl || !content.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) {
|
||||
const image = document.createElement("img");
|
||||
const loadPromise = new Promise<void>((resolve, reject) => {
|
||||
image.onload = (): void => resolve();
|
||||
image.onerror = (): void => reject(new Error("Unable to load image"));
|
||||
});
|
||||
|
||||
image.crossOrigin = "Anonymous";
|
||||
image.src = contentUrl ?? "";
|
||||
|
||||
try {
|
||||
await loadPromise;
|
||||
} catch (error) {
|
||||
logger.error("Unable to download attachment: ", error);
|
||||
this.state = {
|
||||
...this.state,
|
||||
error: error as Error,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (
|
||||
(content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"] ===
|
||||
false ||
|
||||
(this.props.mediaEventHelper &&
|
||||
(await blobIsAnimated(await this.props.mediaEventHelper.sourceBlob.value)) === false)
|
||||
) {
|
||||
isAnimated = false;
|
||||
}
|
||||
|
||||
if (isAnimated) {
|
||||
const thumbnail = await createThumbnail(
|
||||
image,
|
||||
image.width,
|
||||
image.height,
|
||||
content.info?.mimetype ?? "image/jpeg",
|
||||
false,
|
||||
);
|
||||
generatedThumbnailUrl = URL.createObjectURL(thumbnail.thumbnail);
|
||||
thumbUrl = generatedThumbnailUrl;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.warn("Unable to generate thumbnail for animated image: ", error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.isDisposed) {
|
||||
if (generatedThumbnailUrl) {
|
||||
URL.revokeObjectURL(generatedThumbnailUrl);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
this.revokeGeneratedThumbnailUrl();
|
||||
this.state = {
|
||||
...this.state,
|
||||
contentUrl,
|
||||
thumbUrl,
|
||||
isAnimated,
|
||||
error: null,
|
||||
generatedThumbnailUrl,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
private openImageViewer(event: MouseEvent<HTMLAnchorElement>): void {
|
||||
if (event.button !== 0 || event.metaKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
if (!this.props.mediaVisible) {
|
||||
this.props.setMediaVisible?.(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const content = this.props.mxEvent.getContent<ImageContent>();
|
||||
|
||||
let httpUrl = this.state.contentUrl;
|
||||
if (
|
||||
this.props.mediaEventHelper?.media.isEncrypted &&
|
||||
!isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "")
|
||||
) {
|
||||
httpUrl = this.state.thumbUrl;
|
||||
}
|
||||
|
||||
if (!httpUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
const params: Omit<ComponentProps<typeof ImageView>, "onFinished"> = {
|
||||
src: httpUrl,
|
||||
name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"),
|
||||
mxEvent: this.props.mxEvent,
|
||||
permalinkCreator: this.props.permalinkCreator,
|
||||
};
|
||||
|
||||
if (content.info) {
|
||||
params.width = content.info.w;
|
||||
params.height = content.info.h;
|
||||
params.fileSize = content.info.size;
|
||||
}
|
||||
|
||||
if (this.props.imageRef.current) {
|
||||
const clientRect = this.props.imageRef.current.getBoundingClientRect();
|
||||
params.thumbnailInfo = {
|
||||
width: clientRect.width,
|
||||
height: clientRect.height,
|
||||
positionX: clientRect.x,
|
||||
positionY: clientRect.y,
|
||||
};
|
||||
}
|
||||
|
||||
Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true);
|
||||
}
|
||||
|
||||
public onLinkClick = (event: MouseEvent<HTMLAnchorElement>): void => {
|
||||
this.openImageViewer(event);
|
||||
};
|
||||
|
||||
public onHiddenButtonClick = (): void => {
|
||||
this.props.setMediaVisible?.(true);
|
||||
};
|
||||
|
||||
public onImageError = (): void => {
|
||||
if (this.state.thumbUrl && this.state.thumbUrl !== this.state.contentUrl) {
|
||||
this.state = {
|
||||
...this.state,
|
||||
thumbUrl: null,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
return;
|
||||
}
|
||||
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
if (this.state.imgError) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imgError: true,
|
||||
};
|
||||
MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.updateSnapshotFromState();
|
||||
};
|
||||
|
||||
public onImageLoad = (): void => {
|
||||
this.clearBlurhashTimeout();
|
||||
|
||||
let loadedImageDimensions: LoadedImageDimensions | undefined;
|
||||
if (this.props.imageRef.current) {
|
||||
const { naturalWidth, naturalHeight } = this.props.imageRef.current;
|
||||
loadedImageDimensions = { naturalWidth, naturalHeight };
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imgLoaded: true,
|
||||
loadedImageDimensions,
|
||||
placeholder: ImageBodyViewPlaceholder.NONE,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
};
|
||||
|
||||
public setEvent(mxEvent: MatrixEvent, mediaEventHelper?: MediaEventHelper): void {
|
||||
if (this.props.mxEvent === mxEvent && this.props.mediaEventHelper === mediaEventHelper) {
|
||||
return;
|
||||
}
|
||||
|
||||
const previousVisible = this.props.mediaVisible;
|
||||
this.props = {
|
||||
...this.props,
|
||||
mxEvent,
|
||||
mediaEventHelper,
|
||||
};
|
||||
this.resetState(mxEvent);
|
||||
this.updateSnapshotFromState();
|
||||
|
||||
if (previousVisible) {
|
||||
this.scheduleBlurhashPlaceholder();
|
||||
void this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public setForExport(forExport?: boolean): void {
|
||||
if (this.props.forExport === forExport) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
forExport,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
public setMaxImageHeight(maxImageHeight?: number): void {
|
||||
if (this.props.maxImageHeight === maxImageHeight) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
maxImageHeight,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
public setMediaVisible(mediaVisible: boolean): void {
|
||||
if (this.props.mediaVisible === mediaVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
const wasVisible = this.props.mediaVisible;
|
||||
this.props = {
|
||||
...this.props,
|
||||
mediaVisible,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
|
||||
if (!wasVisible && mediaVisible) {
|
||||
this.scheduleBlurhashPlaceholder();
|
||||
void this.downloadImage();
|
||||
}
|
||||
}
|
||||
|
||||
public setPermalinkCreator(permalinkCreator?: RoomPermalinkCreator): void {
|
||||
if (this.props.permalinkCreator === permalinkCreator) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
permalinkCreator,
|
||||
};
|
||||
}
|
||||
|
||||
public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void {
|
||||
if (this.props.timelineRenderingType === timelineRenderingType) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
timelineRenderingType,
|
||||
};
|
||||
this.snapshot.merge(ImageBodyViewModel.computeSnapshot(this.props, this.state));
|
||||
}
|
||||
|
||||
public setSetMediaVisible(setMediaVisible?: (visible: boolean) => void): void {
|
||||
if (this.props.setMediaVisible === setMediaVisible) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.props = {
|
||||
...this.props,
|
||||
setMediaVisible,
|
||||
};
|
||||
}
|
||||
|
||||
private setImageSize(imageSize: ImageSize): void {
|
||||
if (this.state.imageSize === imageSize) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.state = {
|
||||
...this.state,
|
||||
imageSize,
|
||||
};
|
||||
this.updateSnapshotFromState();
|
||||
}
|
||||
|
||||
public dispose(): void {
|
||||
this.clearBlurhashTimeout();
|
||||
MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener);
|
||||
this.revokeGeneratedThumbnailUrl();
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@ -38,8 +38,9 @@ import { Action } from "../../dispatcher/actions";
|
||||
import type { ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import PosthogTrackers from "../../PosthogTrackers";
|
||||
import { type Call, CallEvent } from "../../models/Call";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
interface RoomItemProps {
|
||||
room: Room;
|
||||
@ -429,9 +430,7 @@ export class RoomListItemViewModel
|
||||
RoomListStoreV3.instance.orderedSectionTags
|
||||
// Exclude the Chats because the user toggle the other sections to move rooms in and out of the Chats section.
|
||||
// Also exclude the default sections because they are available as toggles in the main context menu, and we don't want them to be duplicated in the "Move to section" submenu.
|
||||
.filter(
|
||||
(tag) => tag !== CHATS_TAG && tag !== DefaultTagID.Favourite && tag !== DefaultTagID.LowPriority,
|
||||
)
|
||||
.filter((tag) => !isDefaultSectionTag(tag))
|
||||
.map((tag) => ({
|
||||
tag,
|
||||
name: RoomListItemViewModel.getSectionName(tag, customSectionData),
|
||||
|
||||
@ -16,8 +16,8 @@ import { RoomNotificationStateStore } from "../../stores/notifications/RoomNotif
|
||||
import { NotificationStateEvents } from "../../stores/notifications/NotificationState";
|
||||
import { type RoomNotificationState } from "../../stores/notifications/RoomNotificationState";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3 from "../../stores/room-list-v3/RoomListStoreV3";
|
||||
import { isDefaultSectionTag } from "../../stores/room-list-v3/section";
|
||||
|
||||
interface RoomListSectionHeaderViewModelProps {
|
||||
tag: string;
|
||||
@ -45,8 +45,7 @@ export class RoomListSectionHeaderViewModel
|
||||
private readonly expandedBySpace = new Map<string, boolean>();
|
||||
|
||||
public constructor(props: RoomListSectionHeaderViewModelProps) {
|
||||
const isDefaultSection =
|
||||
props.tag === DefaultTagID.Favourite || props.tag === DefaultTagID.LowPriority || props.tag === CHATS_TAG;
|
||||
const isDefaultSection = isDefaultSectionTag(props.tag);
|
||||
super(props, {
|
||||
id: props.tag,
|
||||
title: props.title,
|
||||
|
||||
@ -15,7 +15,7 @@ import {
|
||||
_t,
|
||||
type ToastType,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { type MatrixClient, type Room } from "matrix-js-sdk/src/matrix";
|
||||
import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import dispatcher from "../../dispatcher/dispatcher";
|
||||
@ -24,7 +24,6 @@ import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload"
|
||||
import { type RoomListSectionsCollapseStateChangedPayload } from "../../dispatcher/payloads/RoomListSectionsCollapseStateChangedPayload";
|
||||
import SpaceStore from "../../stores/spaces/SpaceStore";
|
||||
import RoomListStoreV3, {
|
||||
CHATS_TAG,
|
||||
RoomListStoreV3Event,
|
||||
type RoomsResult,
|
||||
type Section,
|
||||
@ -38,6 +37,9 @@ import { keepIfSame } from "../../utils/keepIfSame";
|
||||
import { DefaultTagID } from "../../stores/room-list-v3/skip-list/tag";
|
||||
import { RoomListSectionHeaderViewModel } from "./RoomListSectionHeaderViewModel";
|
||||
import SettingsStore from "../../settings/SettingsStore";
|
||||
import { tagRoom } from "../../utils/room/tagRoom";
|
||||
import { getSectionTagForRoom } from "../../utils/room/getSectionTagForRoom";
|
||||
import { CHATS_TAG } from "../../stores/room-list-v3/section";
|
||||
|
||||
/**
|
||||
* Tracks the position of the active room within a specific section.
|
||||
@ -667,6 +669,17 @@ export class RoomListViewModel
|
||||
this.closeToast();
|
||||
}, 15 * 1000);
|
||||
}
|
||||
|
||||
public changeRoomSection = (roomId: string, tag: string): void => {
|
||||
const room = this.props.client.getRoom(roomId);
|
||||
if (!room) return;
|
||||
|
||||
const currentTag = getSectionTagForRoom(room);
|
||||
// Room is already in the section
|
||||
if (currentTag === tag) return;
|
||||
|
||||
tagRoom(room, tag);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -955,7 +955,7 @@ describe("RoomView", () => {
|
||||
expect(searchResultTile).not.toBeNull();
|
||||
|
||||
await userEvent.hover(searchResultTile!);
|
||||
await userEvent.click(await findByLabelText("Edit"));
|
||||
await userEvent.click(await findByLabelText("Edit"), { skipHover: true });
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelector(".mx_RoomView_searchResultsPanel")).not.toBeInTheDocument();
|
||||
@ -1024,7 +1024,7 @@ describe("RoomView", () => {
|
||||
expect(searchResultTile).not.toBeNull();
|
||||
|
||||
await userEvent.hover(searchResultTile!);
|
||||
await userEvent.click(await findByLabelText("Edit"));
|
||||
await userEvent.click(await findByLabelText("Edit"), { skipHover: true });
|
||||
|
||||
await expect(prom).resolves.toEqual(expect.objectContaining({ room_id: room2.roomId }));
|
||||
});
|
||||
|
||||
@ -59,4 +59,78 @@ describe("ReplyChain", () => {
|
||||
await waitFor(() => expect(setQuoteExpanded).toHaveBeenCalledWith(false));
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("keeps long edited reply quotes collapsible", async () => {
|
||||
// Jest/JSDOM won't set clientHeight/scrollHeight for us so we have to synthesise it
|
||||
jest.spyOn(Element.prototype, "clientHeight", "get").mockReturnValue(100);
|
||||
jest.spyOn(Element.prototype, "scrollHeight", "get").mockReturnValue(150);
|
||||
|
||||
const cli = stubClient();
|
||||
const { room_id: roomId } = await cli.createRoom({});
|
||||
const room = cli.getRoom(roomId)!;
|
||||
const longBody = Array.from({ length: 80 }, (_, index) => `word${index}`).join(" ");
|
||||
const editedLongBody = `${longBody} edited`;
|
||||
|
||||
const targetEv = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.message",
|
||||
user: cli.getUserId()!,
|
||||
room: roomId,
|
||||
id: "$event1",
|
||||
content: {
|
||||
body: longBody,
|
||||
msgtype: "m.text",
|
||||
},
|
||||
});
|
||||
const editEv = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.message",
|
||||
user: cli.getUserId()!,
|
||||
room: roomId,
|
||||
id: "$event1-edit",
|
||||
content: {
|
||||
"body": `* ${editedLongBody}`,
|
||||
"msgtype": "m.text",
|
||||
"m.new_content": {
|
||||
body: editedLongBody,
|
||||
msgtype: "m.text",
|
||||
},
|
||||
},
|
||||
});
|
||||
jest.spyOn(targetEv, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3));
|
||||
targetEv.makeReplaced(editEv);
|
||||
jest.spyOn(room, "findEventById").mockReturnValue(targetEv);
|
||||
|
||||
const parentEv = mkEvent({
|
||||
event: true,
|
||||
type: "m.room.message",
|
||||
user: cli.getUserId()!,
|
||||
room: roomId,
|
||||
id: "$event2",
|
||||
content: {
|
||||
"body": "Reply",
|
||||
"msgtype": "m.text",
|
||||
"m.relates_to": {
|
||||
"m.in_reply_to": {
|
||||
event_id: "$event1",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const setQuoteExpanded = jest.fn();
|
||||
const { container } = render(
|
||||
<ReplyChain parentEv={parentEv} setQuoteExpanded={setQuoteExpanded} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
await waitFor(() => expect(setQuoteExpanded).toHaveBeenCalledWith(false));
|
||||
await waitFor(() => expect(container).toHaveTextContent(editedLongBody));
|
||||
|
||||
const replyTile = container.querySelector(".mx_ReplyTile");
|
||||
expect(replyTile).not.toBeNull();
|
||||
const annotationWrapper = replyTile!.querySelector("[data-textual-body-annotation-wrapper]");
|
||||
expect(annotationWrapper).not.toBeNull();
|
||||
expect(annotationWrapper).toContainElement(replyTile!.querySelector(".mx_EventTile_body"));
|
||||
expect(annotationWrapper).toContainElement(replyTile!.querySelector("[data-textual-body-edited-marker]"));
|
||||
});
|
||||
});
|
||||
|
||||
@ -20,17 +20,26 @@ import {
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import {
|
||||
DecryptionFailureBodyFactory,
|
||||
FileBodyFactory,
|
||||
ImageBodyFactory,
|
||||
RedactedBodyFactory,
|
||||
VideoBodyFactory,
|
||||
renderMBody,
|
||||
} from "../../../../../src/components/views/messages/MBodyFactory";
|
||||
import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
|
||||
|
||||
jest.mock("matrix-encrypt-attachment", () => ({
|
||||
decryptAttachment: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
|
||||
__esModule: true,
|
||||
useMediaVisible: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("MBodyFactory", () => {
|
||||
const userId = "@user:server";
|
||||
const deviceId = "DEADB33F";
|
||||
@ -59,7 +68,7 @@ describe("MBodyFactory", () => {
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
|
||||
};
|
||||
const mkEvent = (msgtype?: string): MatrixEvent =>
|
||||
const mkEvent = (msgtype?: string, content: Record<string, unknown> = {}): MatrixEvent =>
|
||||
new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: userId,
|
||||
@ -68,13 +77,26 @@ describe("MBodyFactory", () => {
|
||||
body: "alt",
|
||||
...(msgtype ? { msgtype } : {}),
|
||||
url: "mxc://server/file",
|
||||
...content,
|
||||
},
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
||||
jest.mocked(useMediaVisible).mockReturnValue([true, jest.fn()]);
|
||||
});
|
||||
|
||||
const encryptedImageHelper = (): MediaEventHelper =>
|
||||
({
|
||||
media: { isEncrypted: true },
|
||||
sourceUrl: { value: Promise.resolve("blob:source") },
|
||||
thumbnailUrl: { value: Promise.resolve("blob:thumbnail") },
|
||||
sourceBlob: {
|
||||
value: Promise.resolve(new Blob(["image"], { type: "image/jpeg" })),
|
||||
cachedValue: new Blob(["image"], { type: "image/jpeg" }),
|
||||
},
|
||||
}) as unknown as MediaEventHelper;
|
||||
|
||||
describe("renderMBody", () => {
|
||||
it("renders download button for m.file in file rendering type", () => {
|
||||
const mediaEvent = mkEvent("m.file");
|
||||
@ -102,6 +124,10 @@ describe("MBodyFactory", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory);
|
||||
});
|
||||
|
||||
it("returns the image body factory for m.image", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent("m.image") })?.type).toBe(ImageBodyFactory);
|
||||
});
|
||||
|
||||
it("returns null when msgtype is missing", () => {
|
||||
expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull();
|
||||
});
|
||||
@ -156,4 +182,160 @@ describe("MBodyFactory", () => {
|
||||
expect(container).toMatchSnapshot();
|
||||
},
|
||||
);
|
||||
|
||||
describe("ImageBodyFactory", () => {
|
||||
const imageContent = {
|
||||
info: {
|
||||
mimetype: "image/jpeg",
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("renders the shared image view in room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.image", imageContent);
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the file fallback child in notification timelines", () => {
|
||||
const mediaEvent = mkEvent("m.image", imageContent);
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("renders only a file body for encrypted unsafe images without thumbnails", () => {
|
||||
const mediaEvent = mkEvent("m.image", {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
},
|
||||
});
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory
|
||||
{...props}
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={{ media: { isEncrypted: true } } as MediaEventHelper}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("button", { name: "alt" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("keeps the image body for encrypted unsafe images when a thumbnail is available", () => {
|
||||
const mediaEvent = mkEvent("m.image", {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
thumbnail_info: { mimetype: "image/jpeg" },
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<ImageBodyFactory {...props} mxEvent={mediaEvent} mediaEventHelper={encryptedImageHelper()} />
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_ImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("VideoBodyFactory", () => {
|
||||
const videoContent = {
|
||||
info: {
|
||||
mimetype: "video/mp4",
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
},
|
||||
};
|
||||
|
||||
it("renders without a file fallback in room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.video", videoContent);
|
||||
|
||||
const { container } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Room } as any)}>
|
||||
<VideoBodyFactory
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
forExport={false}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the file fallback child outside room timelines", () => {
|
||||
const mediaEvent = mkEvent("m.video", videoContent);
|
||||
|
||||
const { container, getByRole } = render(
|
||||
<ScopedRoomContextProvider {...({ timelineRenderingType: TimelineRenderingType.Notification } as any)}>
|
||||
<VideoBodyFactory
|
||||
mxEvent={mediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(mediaEvent)}
|
||||
forExport={false}
|
||||
/>
|
||||
</ScopedRoomContextProvider>,
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MVideoBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it("renders the redacted body wrapper", () => {
|
||||
const mediaEvent = mkEvent("m.text");
|
||||
|
||||
const { container } = render(<RedactedBodyFactory mxEvent={mediaEvent} />);
|
||||
|
||||
expect(container.querySelector(".mx_RedactedBody")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the decryption failure body wrapper", () => {
|
||||
const mediaEvent = mkEvent("m.text");
|
||||
Object.defineProperty(mediaEvent, "decryptionFailureReason", {
|
||||
configurable: true,
|
||||
value: "MEGOLM_UNKNOWN_INBOUND_SESSION_ID",
|
||||
});
|
||||
|
||||
const { container } = render(<DecryptionFailureBodyFactory mxEvent={mediaEvent} />);
|
||||
|
||||
expect(container.querySelector(".mx_DecryptionFailureBody")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@ -1,376 +0,0 @@
|
||||
/*
|
||||
Copyright 2024, 2025 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within } from "jest-matrix-react";
|
||||
import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import fetchMock from "@fetch-mock/jest";
|
||||
import encrypt from "matrix-encrypt-attachment";
|
||||
import { mocked } from "jest-mock";
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import MImageBody from "../../../../../src/components/views/messages/MImageBody";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsCrypto,
|
||||
mockClientMethodsDevice,
|
||||
mockClientMethodsServer,
|
||||
mockClientMethodsUser,
|
||||
withClientContextRenderOptions,
|
||||
} from "../../../../test-utils";
|
||||
import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { MediaPreviewValue } from "../../../../../src/@types/media_preview";
|
||||
|
||||
jest.mock("matrix-encrypt-attachment", () => ({
|
||||
decryptAttachment: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<MImageBody/>", () => {
|
||||
const ourUserId = "@user:server";
|
||||
const senderUserId = "@other_use:server";
|
||||
const deviceId = "DEADB33F";
|
||||
const cli = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(ourUserId),
|
||||
...mockClientMethodsServer(),
|
||||
...mockClientMethodsDevice(deviceId),
|
||||
...mockClientMethodsCrypto(),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getRoom: jest.fn(),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": true,
|
||||
"org.matrix.msc3886": true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
const url = "https://server/_matrix/media/v3/download/server/encrypted-image";
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
cli.mxcUrlToHttp.mockImplementation(
|
||||
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
|
||||
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
|
||||
},
|
||||
);
|
||||
const encryptedMediaEvent = new MatrixEvent({
|
||||
event_id: "$foo:bar",
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/png",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-image",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const props = {
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room(encryptedMediaEvent.getRoomId()!, cli, cli.getUserId()!)),
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.spyOn(SettingsStore, "getValue").mockRestore();
|
||||
fetchMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
SettingsStore.reset();
|
||||
mocked(encrypt.decryptAttachment).mockReset();
|
||||
});
|
||||
|
||||
it("should show a thumbnail while image is being downloaded", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
// thumbnail with dimensions present
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show error when encrypted media cannot be downloaded", async () => {
|
||||
fetchMock.getOnce(url, { status: 500 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(fetchMock).toHaveFetched(url);
|
||||
|
||||
await screen.findByText("Error downloading image");
|
||||
});
|
||||
|
||||
it("should show error when encrypted media cannot be decrypted", async () => {
|
||||
fetchMock.getOnce(url, "thisistotallyanencryptedpng");
|
||||
mocked(encrypt.decryptAttachment).mockRejectedValue(new Error("Failed to decrypt"));
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
await screen.findByText("Error decrypting image");
|
||||
});
|
||||
|
||||
describe("with image previews/thumbnails disabled", () => {
|
||||
beforeEach(() => {
|
||||
const origFn = SettingsStore.getValue;
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => {
|
||||
if (setting === "mediaPreviewConfig") {
|
||||
return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off };
|
||||
}
|
||||
return origFn(setting, ...args);
|
||||
});
|
||||
});
|
||||
|
||||
it("should not download image", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show image")).toBeInTheDocument();
|
||||
|
||||
expect(fetchMock).toHaveFetchedTimes(0, url);
|
||||
});
|
||||
|
||||
it("should render hidden image placeholder", async () => {
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
|
||||
render(
|
||||
<MImageBody
|
||||
{...props}
|
||||
mxEvent={encryptedMediaEvent}
|
||||
mediaEventHelper={new MediaEventHelper(encryptedMediaEvent)}
|
||||
/>,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(screen.getByText("Show image")).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(screen.getByRole("button"));
|
||||
|
||||
expect(fetchMock).toHaveFetched(url);
|
||||
|
||||
// Show image is asynchronous since it applies through a settings watcher hook, so
|
||||
// be sure to wait here.
|
||||
await waitFor(() => {
|
||||
// spinner while downloading image
|
||||
expect(screen.getByRole("progressbar")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("should fall back to /download/ if /thumbnail/ fails", async () => {
|
||||
const thumbUrl = "https://server/_matrix/media/v3/thumbnail/server/image?width=800&height=600&method=scale";
|
||||
const downloadUrl = "https://server/_matrix/media/v3/download/server/image";
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
|
||||
expect(img).toHaveProperty("src", thumbUrl);
|
||||
|
||||
fireEvent.error(img);
|
||||
expect(img).toHaveProperty("src", downloadUrl);
|
||||
});
|
||||
|
||||
it("should generate a thumbnail if one isn't included for animated media", async () => {
|
||||
Object.defineProperty(global.Image.prototype, "src", {
|
||||
set(src) {
|
||||
window.setTimeout(() => this.onload?.());
|
||||
},
|
||||
});
|
||||
Object.defineProperty(global.Image.prototype, "height", {
|
||||
get() {
|
||||
return 600;
|
||||
},
|
||||
});
|
||||
Object.defineProperty(global.Image.prototype, "width", {
|
||||
get() {
|
||||
return 800;
|
||||
},
|
||||
});
|
||||
|
||||
mocked(global.URL.createObjectURL).mockReturnValue("blob:generated-thumb");
|
||||
|
||||
fetchMock.getOnce("https://server/_matrix/media/v3/download/server/image", {
|
||||
body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")),
|
||||
});
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/webp",
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
// Wait for spinners to go away
|
||||
await waitForElementToBeRemoved(screen.getAllByRole("progressbar"));
|
||||
// thumbnail with dimensions present
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should show banner on hover", async () => {
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
body: "alt for a test image",
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
},
|
||||
url: "mxc://server/image",
|
||||
},
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
const img = container.querySelector(".mx_MImageBody_thumbnail")!;
|
||||
await userEvent.hover(img);
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody_banner")).toHaveTextContent("...alt for a test image");
|
||||
});
|
||||
|
||||
it("should render MFileBody for svg with no thumbnail", async () => {
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const { container, asFragment } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={new MediaEventHelper(event)} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
expect(container.querySelector(".mx_MFileBody")).toHaveTextContent("Attachment");
|
||||
expect(asFragment()).toMatchSnapshot();
|
||||
});
|
||||
|
||||
it("should open ImageView using thumbnail for encrypted svg", async () => {
|
||||
const url = "https://server/_matrix/media/v3/download/server/encrypted-svg";
|
||||
fetchMock.getOnce(url, { status: 200 });
|
||||
const thumbUrl = "https://server/_matrix/media/v3/download/server/svg-thumbnail";
|
||||
fetchMock.getOnce(thumbUrl, { status: 200 });
|
||||
|
||||
const event = new MatrixEvent({
|
||||
room_id: "!room:server",
|
||||
sender: senderUserId,
|
||||
type: EventType.RoomMessage,
|
||||
origin_server_ts: 1234567890,
|
||||
content: {
|
||||
info: {
|
||||
w: 40,
|
||||
h: 50,
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_file: {
|
||||
url: "mxc://server/svg-thumbnail",
|
||||
},
|
||||
thumbnail_info: { mimetype: "image/png" },
|
||||
},
|
||||
file: {
|
||||
url: "mxc://server/encrypted-svg",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mediaEventHelper = new MediaEventHelper(event);
|
||||
mediaEventHelper.thumbnailUrl["prom"] = Promise.resolve(thumbUrl);
|
||||
mediaEventHelper.sourceUrl["prom"] = Promise.resolve(url);
|
||||
|
||||
const { findByRole } = render(
|
||||
<MImageBody {...props} mxEvent={event} mediaEventHelper={mediaEventHelper} />,
|
||||
withClientContextRenderOptions(cli),
|
||||
);
|
||||
|
||||
fireEvent.click(await findByRole("link"));
|
||||
|
||||
const dialog = await screen.findByRole("dialog");
|
||||
await expect(within(dialog).findByRole("img")).resolves.toHaveAttribute(
|
||||
"src",
|
||||
"https://server/_matrix/media/v3/download/server/svg-thumbnail",
|
||||
);
|
||||
expect(dialog).toMatchSnapshot();
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,620 @@
|
||||
/*
|
||||
Copyright 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { createRef } from "react";
|
||||
import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react";
|
||||
import { ClientEvent, EventType, getHttpUriForMxc, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import Modal from "../../../../../src/Modal";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import { ImageSize } from "../../../../../src/settings/enums/ImageSize";
|
||||
import { mediaFromContent } from "../../../../../src/customisations/Media";
|
||||
import { BLURHASH_FIELD, createThumbnail } from "../../../../../src/utils/image-media";
|
||||
import { blobIsAnimated } from "../../../../../src/utils/Image";
|
||||
import { DecryptError, DownloadError } from "../../../../../src/utils/DecryptFile";
|
||||
import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext";
|
||||
import MImageReplyBody, { ImageBodyBaseInner } from "../../../../../src/components/views/messages/MImageReplyBody";
|
||||
import {
|
||||
getMockClientWithEventEmitter,
|
||||
mockClientMethodsCrypto,
|
||||
mockClientMethodsDevice,
|
||||
mockClientMethodsServer,
|
||||
mockClientMethodsUser,
|
||||
} from "../../../../test-utils";
|
||||
import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible";
|
||||
|
||||
jest.mock("../../../../../src/customisations/Media", () => ({
|
||||
mediaFromContent: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/Image", () => ({
|
||||
...jest.requireActual("../../../../../src/utils/Image"),
|
||||
blobIsAnimated: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/utils/image-media", () => ({
|
||||
...jest.requireActual("../../../../../src/utils/image-media"),
|
||||
createThumbnail: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/hooks/useMediaVisible", () => ({
|
||||
__esModule: true,
|
||||
useMediaVisible: jest.fn(),
|
||||
}));
|
||||
|
||||
describe("<MImageReplyBody />", () => {
|
||||
const userId = "@user:server";
|
||||
const deviceId = "DEADB33F";
|
||||
const cli = getMockClientWithEventEmitter({
|
||||
...mockClientMethodsUser(userId),
|
||||
...mockClientMethodsServer(),
|
||||
...mockClientMethodsDevice(deviceId),
|
||||
...mockClientMethodsCrypto(),
|
||||
getRoom: jest.fn(),
|
||||
getRooms: jest.fn().mockReturnValue([]),
|
||||
getIgnoredUsers: jest.fn(),
|
||||
getVersions: jest.fn().mockResolvedValue({
|
||||
unstable_features: {
|
||||
"org.matrix.msc3882": true,
|
||||
"org.matrix.msc3886": true,
|
||||
},
|
||||
}),
|
||||
});
|
||||
// eslint-disable-next-line no-restricted-properties
|
||||
cli.mxcUrlToHttp.mockImplementation(
|
||||
(mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => {
|
||||
return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks);
|
||||
},
|
||||
);
|
||||
|
||||
const mockedMediaFromContent = jest.mocked(mediaFromContent);
|
||||
const mockedUseMediaVisible = jest.mocked(useMediaVisible);
|
||||
const mockedBlobIsAnimated = jest.mocked(blobIsAnimated);
|
||||
const mockedCreateThumbnail = jest.mocked(createThumbnail);
|
||||
const originalGetValue = SettingsStore.getValue.bind(SettingsStore);
|
||||
|
||||
const createEvent = ({
|
||||
body = "demo image",
|
||||
content = {},
|
||||
}: {
|
||||
body?: string;
|
||||
content?: Record<string, unknown>;
|
||||
} = {}): MatrixEvent => {
|
||||
const { info: infoOverride, ...restContent } = content;
|
||||
const info =
|
||||
infoOverride === null
|
||||
? undefined
|
||||
: {
|
||||
w: 320,
|
||||
h: 240,
|
||||
size: 48_000,
|
||||
mimetype: "image/jpeg",
|
||||
...(infoOverride as Record<string, unknown> | undefined),
|
||||
};
|
||||
|
||||
return new MatrixEvent({
|
||||
type: EventType.RoomMessage,
|
||||
room_id: "!room:server",
|
||||
event_id: "$image:server",
|
||||
sender: userId,
|
||||
content: {
|
||||
msgtype: "m.image",
|
||||
body,
|
||||
url: "mxc://server/image",
|
||||
...restContent,
|
||||
...(info ? { info } : {}),
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const createMockMedia = (content: Record<string, any>) => ({
|
||||
isEncrypted: !!content.file,
|
||||
srcMxc: content.url ?? content.file?.url ?? "mxc://server/image",
|
||||
srcHttp: "https://server/full.png",
|
||||
thumbnailMxc: content.info?.thumbnail_url ?? "mxc://server/thumb",
|
||||
thumbnailHttp: "https://server/thumb.png",
|
||||
hasThumbnail: content.info?.thumbnail_url !== null,
|
||||
getThumbnailHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
|
||||
getThumbnailOfSourceHttp: jest.fn().mockReturnValue("https://server/thumb.png"),
|
||||
getSquareThumbnailHttp: jest.fn(),
|
||||
downloadSource: jest.fn(),
|
||||
});
|
||||
|
||||
const createMediaEventHelper = ({
|
||||
encrypted = true,
|
||||
thumbnailUrl = "blob:thumbnail",
|
||||
sourceUrl = "blob:source",
|
||||
sourceBlob = new Blob(["image"], { type: "image/jpeg" }),
|
||||
}: {
|
||||
encrypted?: boolean;
|
||||
thumbnailUrl?: string | null | Promise<string | null>;
|
||||
sourceUrl?: string | null | Promise<string | null>;
|
||||
sourceBlob?: Blob | Promise<Blob>;
|
||||
} = {}): MediaEventHelper =>
|
||||
({
|
||||
media: { isEncrypted: encrypted },
|
||||
thumbnailUrl: { value: Promise.resolve(thumbnailUrl) },
|
||||
sourceUrl: { value: Promise.resolve(sourceUrl) },
|
||||
sourceBlob: { value: Promise.resolve(sourceBlob), cachedValue: sourceBlob },
|
||||
}) as unknown as MediaEventHelper;
|
||||
|
||||
const props = {
|
||||
mxEvent: createEvent(),
|
||||
mediaVisible: true,
|
||||
setMediaVisible: jest.fn(),
|
||||
onMessageAllowed: jest.fn(),
|
||||
permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)),
|
||||
};
|
||||
|
||||
const renderBase = ({
|
||||
timelineRenderingType = TimelineRenderingType.Room,
|
||||
overrides = {},
|
||||
}: {
|
||||
timelineRenderingType?: TimelineRenderingType;
|
||||
overrides?: Partial<React.ComponentProps<typeof ImageBodyBaseInner>>;
|
||||
} = {}) => {
|
||||
const ref = createRef<ImageBodyBaseInner>();
|
||||
const result = render(
|
||||
<RoomContext.Provider value={{ timelineRenderingType } as any}>
|
||||
<ImageBodyBaseInner ref={ref} {...props} {...overrides} />
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
return { ...result, ref };
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value: 1,
|
||||
});
|
||||
mockedMediaFromContent.mockImplementation((content: Record<string, any>) => createMockMedia(content) as any);
|
||||
mockedUseMediaVisible.mockReturnValue([true, jest.fn()]);
|
||||
mockedBlobIsAnimated.mockResolvedValue(true);
|
||||
mockedCreateThumbnail.mockResolvedValue({ thumbnail: new Blob(["thumbnail"], { type: "image/jpeg" }) } as any);
|
||||
jest.spyOn(SettingsStore, "getValue").mockImplementation(((setting, ...args) => {
|
||||
if (setting === "Images.size") return ImageSize.Normal;
|
||||
if (setting === "autoplayGifs") return false;
|
||||
return (originalGetValue as any)(setting, ...args);
|
||||
}) as typeof SettingsStore.getValue);
|
||||
jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("image-reply-watch");
|
||||
jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn());
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders a visible unencrypted image and file fallback outside room timelines", async () => {
|
||||
const { container } = renderBase({ timelineRenderingType: TimelineRenderingType.Notification });
|
||||
|
||||
await waitFor(() => expect(screen.getAllByRole("img", { name: "demo image" })).toHaveLength(2));
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_MFileBody")).not.toBeNull();
|
||||
expect(container.querySelector("a[href='https://server/full.png']")).not.toBeNull();
|
||||
expect(container.querySelector("img.mx_MImageBody_thumbnail")).toHaveAttribute(
|
||||
"src",
|
||||
"https://server/thumb.png",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Download/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("reveals hidden media through the supplied setter", () => {
|
||||
const setMediaVisible = jest.fn();
|
||||
renderBase({
|
||||
overrides: {
|
||||
mediaVisible: false,
|
||||
setMediaVisible,
|
||||
},
|
||||
});
|
||||
|
||||
fireEvent.click(screen.getByRole("button", { name: "Show image" }));
|
||||
|
||||
expect(setMediaVisible).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("opens the image viewer with thumbnail geometry", async () => {
|
||||
const { container } = renderBase();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
|
||||
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
|
||||
image.getBoundingClientRect = () => ({ width: 100, height: 80, x: 10, y: 20 }) as DOMRect;
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "demo image" }), { button: 0 });
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
src: "https://server/full.png",
|
||||
name: "demo image",
|
||||
width: 320,
|
||||
height: 240,
|
||||
fileSize: 48_000,
|
||||
thumbnailInfo: {
|
||||
width: 100,
|
||||
height: 80,
|
||||
positionX: 10,
|
||||
positionY: 20,
|
||||
},
|
||||
}),
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("updates load dimensions and toggles hover/focus banner state", async () => {
|
||||
const { container, ref } = renderBase();
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument());
|
||||
const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement;
|
||||
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageLoad"]();
|
||||
ref.current!.setState({ isAnimated: true, imgLoaded: true });
|
||||
});
|
||||
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
|
||||
|
||||
fireEvent.mouseEnter(image);
|
||||
expect(ref.current!.state.hover).toBe(true);
|
||||
expect(container.querySelector(".mx_MImageBody_banner")).not.toBeNull();
|
||||
expect(image).toHaveAttribute("src", "https://server/full.png");
|
||||
|
||||
fireEvent.mouseLeave(image);
|
||||
expect(ref.current!.state.hover).toBe(false);
|
||||
|
||||
const link = screen.getByRole("link", { name: /demo image/ });
|
||||
fireEvent.focus(link);
|
||||
expect(ref.current!.state.focus).toBe(true);
|
||||
fireEvent.blur(link);
|
||||
expect(ref.current!.state.focus).toBe(false);
|
||||
});
|
||||
|
||||
it("uses the decrypted thumbnail in the image viewer when the source mime type is unsafe", async () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
body: "unsafe image",
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_info: { mimetype: "image/jpeg" },
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: "blob:unsafe-source",
|
||||
thumbnailUrl: "blob:safe-thumbnail",
|
||||
sourceBlob: new Blob(["html"], { type: "text/html" }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
jest.spyOn(Modal, "createDialog").mockReturnValue({} as any);
|
||||
await waitFor(() => expect(screen.getByRole("img", { name: "unsafe image" })).toBeInTheDocument());
|
||||
|
||||
fireEvent.click(screen.getByRole("link", { name: "unsafe image" }), { button: 0 });
|
||||
|
||||
expect(Modal.createDialog).toHaveBeenCalledWith(
|
||||
expect.any(Function),
|
||||
expect.objectContaining({
|
||||
src: "blob:safe-thumbnail",
|
||||
name: "unsafe image",
|
||||
}),
|
||||
"mx_Dialog_lightbox",
|
||||
undefined,
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back from thumbnail errors and clears image errors after reconnecting", async () => {
|
||||
const onSpy = jest.spyOn(cli, "on");
|
||||
const offSpy = jest.spyOn(cli, "off");
|
||||
const { ref } = renderBase();
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageError"]();
|
||||
});
|
||||
expect(ref.current!.state.thumbUrl).toBeNull();
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageError"]();
|
||||
});
|
||||
expect(ref.current!.state.imgError).toBe(true);
|
||||
expect(onSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
|
||||
|
||||
const listener = onSpy.mock.calls.at(-1)![1] as (...args: unknown[]) => void;
|
||||
act(() => {
|
||||
listener(SyncState.Syncing, SyncState.Error);
|
||||
});
|
||||
|
||||
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, listener);
|
||||
expect(ref.current!.state.imgError).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
|
||||
[new DownloadError(new Error("download failed")), "Error downloading image"],
|
||||
[new Error("display failed"), "Unable to show image due to error"],
|
||||
])("renders media processing errors for %s", async (error, label) => {
|
||||
const { container, ref } = renderBase();
|
||||
|
||||
act(() => {
|
||||
ref.current!.setState({ error });
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_MImageBody")).not.toBeNull();
|
||||
expect(screen.getByText(label)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it.each([
|
||||
[new DecryptError(new Error("decrypt failed")), "Error decrypting image"],
|
||||
[new DownloadError(new Error("download failed")), "Error downloading image"],
|
||||
[new Error("download failed"), "Unable to show image due to error"],
|
||||
])("renders encrypted download failures for %s", async (error, label) => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: Promise.reject(error),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.getByText(label)).toBeInTheDocument());
|
||||
});
|
||||
|
||||
it("renders export images directly from the event MXC URL", () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
forExport: true,
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
url: undefined,
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute(
|
||||
"href",
|
||||
"mxc://server/encrypted-image",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute("target", "_blank");
|
||||
expect(screen.queryByRole("link", { name: /Download/ })).toBeNull();
|
||||
});
|
||||
|
||||
it("switches blurhash placeholders on after the delay", () => {
|
||||
jest.useFakeTimers();
|
||||
const { container } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
info: {
|
||||
[BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_Blurhash")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
jest.advanceTimersByTime(150);
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_Blurhash")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("downloads media when visibility changes after mount", async () => {
|
||||
const ref = createRef<ImageBodyBaseInner>();
|
||||
const mxEvent = createEvent();
|
||||
const { rerender } = render(
|
||||
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
|
||||
<ImageBodyBaseInner
|
||||
ref={ref}
|
||||
{...props}
|
||||
mxEvent={mxEvent}
|
||||
mediaVisible={false}
|
||||
setMediaVisible={jest.fn()}
|
||||
/>
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
expect(ref.current!.state.contentUrl).toBeNull();
|
||||
|
||||
rerender(
|
||||
<RoomContext.Provider value={{ timelineRenderingType: TimelineRenderingType.Room } as any}>
|
||||
<ImageBodyBaseInner
|
||||
ref={ref}
|
||||
{...props}
|
||||
mxEvent={mxEvent}
|
||||
mediaVisible={true}
|
||||
setMediaVisible={jest.fn()}
|
||||
/>
|
||||
</RoomContext.Provider>,
|
||||
);
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.contentUrl).toBe("https://server/full.png"));
|
||||
});
|
||||
|
||||
it("renders missing-size media after loading natural dimensions", async () => {
|
||||
const { container, ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({ content: { info: null } }),
|
||||
},
|
||||
});
|
||||
await waitFor(() => expect(container.querySelector("img[style*='display: none']")).not.toBeNull());
|
||||
const image = container.querySelector("img[style*='display: none']") as HTMLImageElement;
|
||||
Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 });
|
||||
Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 });
|
||||
|
||||
act(() => {
|
||||
ref.current!["onImageLoad"]();
|
||||
});
|
||||
|
||||
expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 });
|
||||
expect(container.querySelector(".mx_MImageBody_thumbnail_container")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("generates a static thumbnail for animated images without a safe thumbnail", async () => {
|
||||
let createdImage: any;
|
||||
const originalCreateElement = document.createElement.bind(document);
|
||||
const createElementSpy = jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => {
|
||||
if (tagName !== "img") {
|
||||
return originalCreateElement(tagName);
|
||||
}
|
||||
createdImage = originalCreateElement(tagName) as HTMLImageElement;
|
||||
Object.defineProperty(createdImage, "width", { configurable: true, value: 320 });
|
||||
Object.defineProperty(createdImage, "height", { configurable: true, value: 240 });
|
||||
return createdImage;
|
||||
}) as typeof document.createElement);
|
||||
const { ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-image" },
|
||||
url: undefined,
|
||||
info: {
|
||||
"mimetype": "image/gif",
|
||||
"thumbnail_info": { mimetype: "image/gif" },
|
||||
"org.matrix.msc4230.is_animated": true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: createMediaEventHelper({
|
||||
sourceUrl: "blob:animated-source",
|
||||
thumbnailUrl: null,
|
||||
sourceBlob: new Blob(["gif"], { type: "image/gif" }),
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(createdImage).toBeDefined());
|
||||
await act(async () => {
|
||||
createdImage.onload();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("blob"));
|
||||
expect(mockedBlobIsAnimated).toHaveBeenCalled();
|
||||
expect(mockedCreateThumbnail).toHaveBeenCalledWith(expect.any(HTMLImageElement), 320, 240, "image/gif", false);
|
||||
expect(ref.current!.state.isAnimated).toBe(true);
|
||||
createElementSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("uses SVG thumbnails when available", async () => {
|
||||
const { ref } = renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
info: {
|
||||
mimetype: "image/svg+xml",
|
||||
thumbnail_url: "mxc://server/thumb",
|
||||
},
|
||||
},
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png"));
|
||||
|
||||
expect(
|
||||
mockedMediaFromContent.mock.results.some((result: any) =>
|
||||
result.value.getThumbnailHttp.mock.calls.some(
|
||||
(call: unknown[]) => call[0] === 800 && call[1] === 600 && call[2] === "scale",
|
||||
),
|
||||
),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("uses the full source as thumbnail for small high-dpi images", async () => {
|
||||
Object.defineProperty(window, "devicePixelRatio", {
|
||||
configurable: true,
|
||||
value: 2,
|
||||
});
|
||||
|
||||
const { ref } = renderBase();
|
||||
|
||||
await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/full.png"));
|
||||
});
|
||||
|
||||
it("renders the file body instead of unsafe encrypted images without thumbnails", () => {
|
||||
renderBase({
|
||||
overrides: {
|
||||
mxEvent: createEvent({
|
||||
content: {
|
||||
file: { url: "mxc://server/encrypted-file" },
|
||||
url: undefined,
|
||||
info: {
|
||||
mimetype: "text/html",
|
||||
},
|
||||
},
|
||||
}),
|
||||
mediaEventHelper: {
|
||||
media: { isEncrypted: true },
|
||||
sourceUrl: { value: Promise.resolve("blob:source") },
|
||||
thumbnailUrl: { value: Promise.resolve(null) },
|
||||
sourceBlob: {
|
||||
value: Promise.resolve(new Blob(["html"], { type: "text/html" })),
|
||||
cachedValue: new Blob(["html"], { type: "text/html" }),
|
||||
},
|
||||
} as unknown as MediaEventHelper,
|
||||
mediaVisible: false,
|
||||
},
|
||||
});
|
||||
|
||||
expect(screen.getByRole("button", { name: /demo image/ })).toBeInTheDocument();
|
||||
expect(screen.queryByRole("img", { name: "demo image" })).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the compact reply body through the hook wrapper", async () => {
|
||||
const setMediaVisible = jest.fn();
|
||||
mockedUseMediaVisible.mockReturnValue([true, setMediaVisible]);
|
||||
|
||||
const { container } = render(<MImageReplyBody {...props} />);
|
||||
|
||||
await waitFor(() => expect(container.querySelector(".mx_MImageReplyBody")).not.toBeNull());
|
||||
expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("cleans up settings watchers, listeners and generated animated thumbnails on unmount", async () => {
|
||||
const offSpy = jest.spyOn(cli, "off");
|
||||
const { ref, unmount } = renderBase();
|
||||
await waitFor(() => expect(ref.current).not.toBeNull());
|
||||
|
||||
act(() => {
|
||||
ref.current!.setState({
|
||||
isAnimated: true,
|
||||
thumbUrl: "blob:animated-thumbnail",
|
||||
});
|
||||
});
|
||||
|
||||
unmount();
|
||||
|
||||
expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("image-reply-watch");
|
||||
expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function));
|
||||
expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:animated-thumbnail");
|
||||
});
|
||||
});
|
||||
@ -20,15 +20,11 @@ import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permal
|
||||
import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext";
|
||||
import { Mjolnir } from "../../../../../src/mjolnir/Mjolnir";
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({
|
||||
__esModule: true,
|
||||
default: () => <div data-testid="image-body" />,
|
||||
}));
|
||||
|
||||
jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({
|
||||
__esModule: true,
|
||||
DecryptionFailureBodyFactory: () => <div data-testid="decryption-failure-body" />,
|
||||
FileBodyFactory: () => <div data-testid="file-body" />,
|
||||
ImageBodyFactory: () => <div data-testid="image-body" />,
|
||||
RedactedBodyFactory: () => <div className="mx_RedactedBody">Message deleted by Moderator</div>,
|
||||
VideoBodyFactory: () => <video data-testid="video-body" />,
|
||||
renderMBody: () => <div data-testid="file-body" />,
|
||||
|
||||
@ -1,379 +0,0 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`<MImageBody/> should generate a thumbnail if one isn't included for animated media 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_MImageBody"
|
||||
>
|
||||
<a
|
||||
href="https://server/_matrix/media/v3/download/server/image"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_thumbnail_container"
|
||||
style="max-height: 50px; max-width: 40px; aspect-ratio: 40/50;"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_placeholder"
|
||||
>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="max-height: 50px; max-width: 40px;"
|
||||
>
|
||||
<img
|
||||
alt="alt for a test image"
|
||||
class="mx_MImageBody_thumbnail"
|
||||
src="blob:generated-thumb"
|
||||
/>
|
||||
<p
|
||||
class="mx_MImageBody_gifLabel"
|
||||
>
|
||||
GIF
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should open ImageView using thumbnail for encrypted svg 1`] = `
|
||||
<div
|
||||
aria-label="Image view"
|
||||
class="mx_ImageView"
|
||||
data-focus-lock-disabled="false"
|
||||
role="dialog"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_panel"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_info_wrapper"
|
||||
>
|
||||
<button
|
||||
aria-label="Profile picture"
|
||||
aria-live="off"
|
||||
class="_avatar_va14e_8 mx_BaseAvatar mx_Dialog_nonDialogButton _avatar-imageless_va14e_55"
|
||||
data-color="2"
|
||||
data-testid="avatar-img"
|
||||
data-type="round"
|
||||
role="button"
|
||||
style="--cpd-avatar-size: 32px;"
|
||||
>
|
||||
o
|
||||
</button>
|
||||
<div
|
||||
class="mx_ImageView_info"
|
||||
>
|
||||
<div
|
||||
class="mx_ImageView_info_sender"
|
||||
>
|
||||
@other_use:server
|
||||
</div>
|
||||
<a
|
||||
aria-live="off"
|
||||
class="mx_MessageTimestamp _content_1r034_8"
|
||||
href="https://matrix.to/#/!room:server/undefined"
|
||||
>
|
||||
Thu, Jan 15, 1970, 06:56
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_title"
|
||||
>
|
||||
Image
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_toolbar"
|
||||
>
|
||||
<div
|
||||
aria-label="Zoom out"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M13.5 9.5q.425 0 .713.287.288.288.287.713a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287h-6a.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713q0-.425.287-.713A.97.97 0 0 1 7.5 9.5z"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10.5 3a7.5 7.5 0 0 1 5.963 12.049l3.244 3.244a1 1 0 1 1-1.414 1.414l-3.244-3.244A7.5 7.5 0 1 1 10.5 3m0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Zoom in"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#cpd_ZoomInIcon_a)"
|
||||
>
|
||||
<path
|
||||
d="M10.5 6.5q.425 0 .713.287.288.288.287.713v2h2q.425 0 .713.287.288.288.287.713a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287h-2v2a.97.97 0 0 1-.287.713.97.97 0 0 1-.713.287.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713v-2h-2a.97.97 0 0 1-.713-.287.97.97 0 0 1-.287-.713q0-.425.287-.713A.97.97 0 0 1 7.5 9.5h2v-2q0-.425.287-.713A.97.97 0 0 1 10.5 6.5"
|
||||
/>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M10.5 3a7.5 7.5 0 0 1 5.963 12.049l3.244 3.244a1 1 0 1 1-1.414 1.414l-3.244-3.244A7.5 7.5 0 1 1 10.5 3m0 2a5.5 5.5 0 1 0 0 11 5.5 5.5 0 0 0 0-11"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
<path
|
||||
d="M15.05 16.463a7.5 7.5 0 1 1 1.414-1.414l3.243 3.244a1 1 0 0 1-1.414 1.414zM16 10.5a5.5 5.5 0 1 0-11 0 5.5 5.5 0 0 0 11 0"
|
||||
/>
|
||||
<path
|
||||
d="M7.875 11.375h1.75v1.75q0 .372.252.623A.85.85 0 0 0 10.5 14a.85.85 0 0 0 .623-.252.85.85 0 0 0 .252-.623v-1.75h1.75a.85.85 0 0 0 .623-.252A.85.85 0 0 0 14 10.5a.85.85 0 0 0-.252-.623.85.85 0 0 0-.623-.252h-1.75v-1.75a.85.85 0 0 0-.252-.623A.85.85 0 0 0 10.5 7a.85.85 0 0 0-.623.252.85.85 0 0 0-.252.623v1.75h-1.75a.85.85 0 0 0-.623.252A.85.85 0 0 0 7 10.5q0 .372.252.623a.85.85 0 0 0 .623.252"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="cpd_ZoomInIcon_a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Rotate Left"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.56 7.98C6.1 7.52 5.31 7.6 5 8.17c-.28.51-.5 1.03-.67 1.58-.19.63.31 1.25.96 1.25h.01c.43 0 .82-.28.94-.7q.18-.6.48-1.17c.22-.37.15-.84-.16-1.15M5.31 13h-.02c-.65 0-1.15.62-.96 1.25.16.54.38 1.07.66 1.58.31.57 1.11.66 1.57.2.3-.31.38-.77.17-1.15-.2-.37-.36-.76-.48-1.16a.97.97 0 0 0-.94-.72m2.85 6.02q.765.42 1.59.66c.62.18 1.24-.32 1.24-.96v-.03c0-.43-.28-.82-.7-.94-.4-.12-.78-.28-1.15-.48a.97.97 0 0 0-1.16.17l-.03.03c-.45.45-.36 1.24.21 1.55M13 4.07v-.66c0-.89-1.08-1.34-1.71-.71L9.17 4.83c-.4.4-.4 1.04 0 1.43l2.13 2.08c.63.62 1.7.17 1.7-.72V6.09c2.84.48 5 2.94 5 5.91 0 2.73-1.82 5.02-4.32 5.75a.97.97 0 0 0-.68.94v.02c0 .65.61 1.14 1.23.96A7.976 7.976 0 0 0 20 12c0-4.08-3.05-7.44-7-7.93"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Rotate Right"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<g
|
||||
clip-path="url(#cpd_RotateRightIcon_a)"
|
||||
>
|
||||
<path
|
||||
d="M14.83 4.83 12.7 2.7c-.62-.62-1.7-.18-1.7.71v.66C7.06 4.56 4 7.92 4 12c0 3.64 2.43 6.71 5.77 7.68.62.18 1.23-.32 1.23-.96v-.03a.97.97 0 0 0-.68-.94A5.98 5.98 0 0 1 6 12c0-2.97 2.16-5.43 5-5.91v1.53c0 .89 1.07 1.33 1.7.71l2.13-2.08a.99.99 0 0 0 0-1.42m4.84 4.93q-.24-.825-.66-1.59c-.31-.57-1.1-.66-1.56-.2l-.01.01c-.31.31-.38.78-.17 1.16.2.37.36.76.48 1.16.12.42.51.7.94.7h.02c.65 0 1.15-.62.96-1.24M13 18.68v.02c0 .65.62 1.14 1.24.96q.825-.24 1.59-.66c.57-.31.66-1.1.2-1.56l-.02-.02a.97.97 0 0 0-1.16-.17c-.37.21-.76.37-1.16.49-.41.12-.69.51-.69.94m4.44-2.65c.46.46 1.25.37 1.56-.2.28-.51.5-1.04.67-1.59.18-.62-.31-1.24-.96-1.24h-.02c-.44 0-.82.28-.94.7q-.18.6-.48 1.17c-.21.38-.13.86.17 1.16"
|
||||
/>
|
||||
</g>
|
||||
<defs>
|
||||
<clippath
|
||||
id="cpd_RotateRightIcon_a"
|
||||
>
|
||||
<path
|
||||
d="M0 0h24v24H0z"
|
||||
/>
|
||||
</clippath>
|
||||
</defs>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Download"
|
||||
class="mx_AccessibleButton mx_ImageView_button"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M12 15.575q-.2 0-.375-.062a.9.9 0 0 1-.325-.213l-3.6-3.6a.95.95 0 0 1-.275-.7q0-.425.275-.7.274-.275.712-.288t.713.263L11 12.15V5q0-.424.287-.713A.97.97 0 0 1 12 4q.424 0 .713.287Q13 4.576 13 5v7.15l1.875-1.875q.274-.274.713-.263.437.014.712.288a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7l-3.6 3.6q-.15.15-.325.212a1.1 1.1 0 0 1-.375.063M6 20q-.824 0-1.412-.587A1.93 1.93 0 0 1 4 18v-2q0-.424.287-.713A.97.97 0 0 1 5 15q.424 0 .713.287Q6 15.576 6 16v2h12v-2q0-.424.288-.713A.97.97 0 0 1 19 15q.424 0 .712.287.288.288.288.713v2q0 .824-.587 1.413A1.93 1.93 0 0 1 18 20z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-expanded="false"
|
||||
aria-haspopup="true"
|
||||
aria-label="Options"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_more"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6 14q-.824 0-1.412-.588A1.93 1.93 0 0 1 4 12q0-.825.588-1.412A1.93 1.93 0 0 1 6 10q.824 0 1.412.588Q8 11.175 8 12t-.588 1.412A1.93 1.93 0 0 1 6 14m6 0q-.825 0-1.412-.588A1.93 1.93 0 0 1 10 12q0-.825.588-1.412A1.93 1.93 0 0 1 12 10q.825 0 1.412.588Q14 11.175 14 12t-.588 1.412A1.93 1.93 0 0 1 12 14m6 0q-.824 0-1.413-.588A1.93 1.93 0 0 1 16 12q0-.825.587-1.412A1.93 1.93 0 0 1 18 10q.824 0 1.413.588Q20 11.175 20 12t-.587 1.412A1.93 1.93 0 0 1 18 14"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div
|
||||
aria-label="Close"
|
||||
class="mx_AccessibleButton mx_ImageView_button mx_ImageView_button_close"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M6.293 6.293a1 1 0 0 1 1.414 0L12 10.586l4.293-4.293a1 1 0 1 1 1.414 1.414L13.414 12l4.293 4.293a1 1 0 0 1-1.414 1.414L12 13.414l-4.293 4.293a1 1 0 0 1-1.414-1.414L10.586 12 6.293 7.707a1 1 0 0 1 0-1.414"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="mx_ImageView_image_wrapper"
|
||||
>
|
||||
<img
|
||||
alt="Attachment"
|
||||
class="mx_ImageView_image "
|
||||
draggable="true"
|
||||
src="https://server/_matrix/media/v3/download/server/svg-thumbnail"
|
||||
style="transform: translateX(-512px)
|
||||
translateY(NaNpx)
|
||||
scale(0)
|
||||
rotate(0deg); cursor: zoom-out;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should render MFileBody for svg with no thumbnail 1`] = `
|
||||
<DocumentFragment>
|
||||
<span
|
||||
class="_content_1t2mx_8 mx_MFileBody"
|
||||
>
|
||||
<div
|
||||
class="mx_MediaBody _mediaBody_rgndh_8"
|
||||
data-type="info"
|
||||
>
|
||||
<button
|
||||
aria-label="Attachment"
|
||||
class="_button_1nw83_8 _has-icon_1nw83_60"
|
||||
data-kind="secondary"
|
||||
data-size="md"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
>
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
fill="currentColor"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
width="20"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="M11.5 22q-2.3 0-3.9-1.6T6 16.5V6q0-1.65 1.175-2.825T10 2t2.825 1.175T14 6v9.5q0 1.05-.725 1.775T11.5 18t-1.775-.725T9 15.5V6.75A.73.73 0 0 1 9.75 6a.73.73 0 0 1 .75.75v8.75q0 .424.287.712.288.288.713.288.424 0 .713-.288a.97.97 0 0 0 .287-.712V6q0-1.05-.725-1.775T10 3.5t-1.775.725T7.5 6v10.5q0 1.65 1.175 2.825T11.5 20.5t2.825-1.175T15.5 16.5V6.75a.73.73 0 0 1 .75-.75.73.73 0 0 1 .75.75v9.75q0 2.3-1.6 3.9T11.5 22"
|
||||
/>
|
||||
</svg>
|
||||
<span>
|
||||
Attachment
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</span>
|
||||
</DocumentFragment>
|
||||
`;
|
||||
|
||||
exports[`<MImageBody/> should show a thumbnail while image is being downloaded 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="mx_MImageBody"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_thumbnail_container"
|
||||
style="max-height: 50px; max-width: 40px; aspect-ratio: 40/50;"
|
||||
>
|
||||
<div
|
||||
class="mx_MImageBody_placeholder"
|
||||
>
|
||||
<div
|
||||
class="mx_Spinner"
|
||||
>
|
||||
<svg
|
||||
aria-label="Loading…"
|
||||
class="_icon_1855a_18"
|
||||
data-testid="spinner"
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
role="progressbar"
|
||||
style="width: 32px; height: 32px;"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
clip-rule="evenodd"
|
||||
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
|
||||
fill-rule="evenodd"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
style="max-height: 50px; max-width: 40px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@ -15,6 +15,8 @@ import {
|
||||
type IEventDecryptionResult,
|
||||
type MatrixClient,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
MsgType,
|
||||
NotificationCountType,
|
||||
PendingEventOrdering,
|
||||
Room,
|
||||
@ -44,6 +46,60 @@ import PinningUtils from "../../../../../src/utils/PinningUtils";
|
||||
import { Layout } from "../../../../../src/settings/enums/Layout";
|
||||
import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx";
|
||||
import SettingsStore from "../../../../../src/settings/SettingsStore";
|
||||
import EditorStateTransfer from "../../../../../src/utils/EditorStateTransfer";
|
||||
import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks";
|
||||
import PlatformPeg from "../../../../../src/PlatformPeg";
|
||||
|
||||
function getTile(container: HTMLElement): HTMLElement {
|
||||
const tile = container.querySelector(".mx_EventTile");
|
||||
expect(tile).not.toBeNull();
|
||||
return tile as HTMLElement;
|
||||
}
|
||||
|
||||
function getLine(container: HTMLElement): HTMLElement {
|
||||
const line = container.querySelector(".mx_EventTile_line");
|
||||
expect(line).not.toBeNull();
|
||||
return line as HTMLElement;
|
||||
}
|
||||
|
||||
function expectTileClass(container: HTMLElement, className: string): void {
|
||||
expect(getTile(container)).toHaveClass(className);
|
||||
}
|
||||
|
||||
function makeReplyEvent(roomId: string): MatrixEvent {
|
||||
const parentEvent = mkMessage({
|
||||
room: roomId,
|
||||
user: "@alice:example.org",
|
||||
msg: "Original message",
|
||||
event: true,
|
||||
});
|
||||
|
||||
return mkMessage({
|
||||
room: roomId,
|
||||
user: "@bob:example.org",
|
||||
msg: "Reply message",
|
||||
event: true,
|
||||
relatesTo: {
|
||||
"m.in_reply_to": {
|
||||
event_id: parentEvent.getId(),
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function makeThreadReplyEvent(roomId: string): MatrixEvent {
|
||||
return mkMessage({
|
||||
room: roomId,
|
||||
user: "@alice:example.org",
|
||||
msg: "Hello world!",
|
||||
ts: 1234,
|
||||
event: true,
|
||||
relatesTo: {
|
||||
rel_type: "m.thread",
|
||||
event_id: "$thread-root",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("EventTile", () => {
|
||||
const ROOM_ID = "!roomId:example.org";
|
||||
@ -83,11 +139,58 @@ describe("EventTile", () => {
|
||||
return render(<WrappedEventTile roomContext={context} eventTilePropertyOverrides={overrides} />);
|
||||
}
|
||||
|
||||
function makeOwnMessage(overrides: Partial<Parameters<typeof mkMessage>[0]> = {}): MatrixEvent {
|
||||
return mkMessage({
|
||||
...overrides,
|
||||
room: overrides.room ?? room.roomId,
|
||||
user: overrides.user ?? client.getSafeUserId(),
|
||||
msg: overrides.msg ?? "Hello world!",
|
||||
event: overrides.event ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
function makeTimestampedMessage(overrides: Partial<Parameters<typeof mkMessage>[0]> = {}): MatrixEvent {
|
||||
return mkMessage({
|
||||
...overrides,
|
||||
room: overrides.room ?? room.roomId,
|
||||
user: overrides.user ?? "@alice:example.org",
|
||||
msg: overrides.msg ?? "Hello world!",
|
||||
ts: overrides.ts ?? 1234,
|
||||
event: overrides.event ?? true,
|
||||
});
|
||||
}
|
||||
|
||||
function WrappedEventTiles(props: { events: MatrixEvent[]; editEvent?: MatrixEvent }) {
|
||||
const roomContext = getRoomContext(room, {
|
||||
timelineRenderingType: TimelineRenderingType.Room,
|
||||
});
|
||||
|
||||
return (
|
||||
<MatrixClientContext.Provider value={client}>
|
||||
<ScopedRoomContextProvider {...roomContext}>
|
||||
{props.events.map((event) => (
|
||||
<EventTile
|
||||
key={event.getId()}
|
||||
mxEvent={event}
|
||||
replacingEventId={event.replacingEventId()}
|
||||
editState={
|
||||
props.editEvent?.getId() === event.getId() ? new EditorStateTransfer(event) : undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</ScopedRoomContextProvider>
|
||||
</MatrixClientContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
|
||||
stubClient();
|
||||
client = MatrixClientPeg.safeGet();
|
||||
jest.spyOn(DMRoomMap, "shared").mockReturnValue({
|
||||
getUserIdForRoomId: jest.fn().mockReturnValue(undefined),
|
||||
} as unknown as DMRoomMap);
|
||||
|
||||
room = new Room(ROOM_ID, client, client.getSafeUserId(), {
|
||||
pendingEventOrdering: PendingEventOrdering.Detached,
|
||||
@ -110,6 +213,558 @@ describe("EventTile", () => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
describe("layout and tile attributes", () => {
|
||||
it.each([
|
||||
["last", { last: true }, "mx_EventTile_last"],
|
||||
["lastInSection", { lastInSection: true }, "mx_EventTile_lastInSection"],
|
||||
["contextual", { contextual: true }, "mx_EventTile_contextual"],
|
||||
["isSelectedEvent", { isSelectedEvent: true }, "mx_EventTile_selected"],
|
||||
["hideSender", { hideSender: true }, "mx_EventTile_noSender"],
|
||||
["isTwelveHour", { isTwelveHour: true }, "mx_EventTile_12hr"],
|
||||
] as const)("adds the %s class", (_propName, overrides, className) => {
|
||||
const { container } = getComponent(overrides);
|
||||
|
||||
expectTileClass(container, className);
|
||||
});
|
||||
|
||||
it("marks events from other users as non-self events", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("data-self", "false");
|
||||
});
|
||||
|
||||
it("marks events from the current user as self events", () => {
|
||||
const ownEvent = makeOwnMessage();
|
||||
const { container } = getComponent({ mxEvent: ownEvent });
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("data-self", "true");
|
||||
});
|
||||
|
||||
it("exposes the rendered event id in room timelines", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("data-event-id", mxEvent.getId());
|
||||
});
|
||||
|
||||
it("renders the event line inside the tile", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(getTile(container)).toContainElement(getLine(container));
|
||||
});
|
||||
|
||||
it("does not expose a scroll token for local echo events", () => {
|
||||
const localEcho = makeOwnMessage();
|
||||
localEcho.setStatus(EventStatus.SENDING);
|
||||
const { container } = getComponent({ mxEvent: localEcho, eventSendStatus: EventStatus.SENDING });
|
||||
|
||||
expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rendering root attributes", () => {
|
||||
type RootAttribute =
|
||||
| "data-scroll-tokens"
|
||||
| "data-layout"
|
||||
| "data-shape"
|
||||
| "data-self"
|
||||
| "data-event-id"
|
||||
| "data-has-reply";
|
||||
|
||||
it.each([
|
||||
[
|
||||
TimelineRenderingType.Room,
|
||||
["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"],
|
||||
["data-shape"],
|
||||
],
|
||||
[
|
||||
TimelineRenderingType.Thread,
|
||||
["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"],
|
||||
["data-shape"],
|
||||
],
|
||||
[
|
||||
TimelineRenderingType.ThreadsList,
|
||||
["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"],
|
||||
["data-event-id"],
|
||||
],
|
||||
[
|
||||
TimelineRenderingType.Notification,
|
||||
["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"],
|
||||
["data-event-id"],
|
||||
],
|
||||
[
|
||||
TimelineRenderingType.File,
|
||||
["data-scroll-tokens"],
|
||||
["data-layout", "data-shape", "data-self", "data-event-id", "data-has-reply"],
|
||||
],
|
||||
] as const)(
|
||||
"sets root attributes for %s rendering",
|
||||
(renderingType, expectedPresentAttributes, expectedAbsentAttributes) => {
|
||||
const { container } = getComponent({}, renderingType);
|
||||
const tile = getTile(container);
|
||||
const expectedValues: Record<RootAttribute, string> = {
|
||||
"data-scroll-tokens": mxEvent.getId()!,
|
||||
"data-layout": Layout.Group,
|
||||
"data-shape": renderingType,
|
||||
"data-self": "false",
|
||||
"data-event-id": mxEvent.getId()!,
|
||||
"data-has-reply": "false",
|
||||
};
|
||||
|
||||
for (const attribute of expectedPresentAttributes) {
|
||||
expect(tile).toHaveAttribute(attribute, expectedValues[attribute]);
|
||||
}
|
||||
|
||||
for (const attribute of expectedAbsentAttributes) {
|
||||
expect(tile).not.toHaveAttribute(attribute);
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("message type classes", () => {
|
||||
it("adds media and image classes for image messages", () => {
|
||||
const imageEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: {
|
||||
msgtype: MsgType.Image,
|
||||
body: "image.png",
|
||||
url: "mxc://example.org/image",
|
||||
info: {
|
||||
mimetype: "image/png",
|
||||
w: 100,
|
||||
h: 100,
|
||||
size: 1234,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = getComponent({ mxEvent: imageEvent });
|
||||
|
||||
expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine");
|
||||
expect(getLine(container)).toHaveClass("mx_EventTile_image");
|
||||
});
|
||||
|
||||
it("adds emote classes for emote messages", () => {
|
||||
const emoteEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.RoomMessage,
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: {
|
||||
msgtype: MsgType.Emote,
|
||||
body: "waves",
|
||||
},
|
||||
});
|
||||
const { container } = getComponent({ mxEvent: emoteEvent });
|
||||
|
||||
expect(getTile(container)).toHaveClass("mx_EventTile_emote");
|
||||
expect(getLine(container)).toHaveClass("mx_EventTile_emote");
|
||||
});
|
||||
|
||||
it("adds media and sticker classes for sticker events", () => {
|
||||
const stickerEvent = mkEvent({
|
||||
event: true,
|
||||
type: EventType.Sticker,
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
content: {
|
||||
body: "sticker.png",
|
||||
url: "mxc://example.org/sticker",
|
||||
info: {
|
||||
mimetype: "image/png",
|
||||
w: 100,
|
||||
h: 100,
|
||||
size: 1234,
|
||||
},
|
||||
},
|
||||
});
|
||||
const { container } = getComponent({ mxEvent: stickerEvent });
|
||||
|
||||
expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine");
|
||||
expect(getLine(container)).toHaveClass("mx_EventTile_sticker");
|
||||
});
|
||||
});
|
||||
|
||||
describe("timestamps", () => {
|
||||
beforeEach(() => {
|
||||
mxEvent = makeTimestampedMessage();
|
||||
});
|
||||
|
||||
it("hides the timestamp by default in room timelines", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows the timestamp when the tile is hovered", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
|
||||
|
||||
fireEvent.mouseEnter(getTile(container));
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the timestamp when focus is within the tile", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
|
||||
|
||||
act(() => {
|
||||
getTile(container).focus();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the timestamp for the last event", () => {
|
||||
const { container } = getComponent({ last: true });
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("shows the timestamp when timestamps are always shown", () => {
|
||||
const { container } = getComponent({ alwaysShowTimestamps: true });
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the timestamp when timestamps are disabled for the tile", () => {
|
||||
const { container } = getComponent({ alwaysShowTimestamps: true, hideTimestamp: true });
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a placeholder timestamp in IRC layout", () => {
|
||||
const { container } = getComponent({ layout: Layout.IRC });
|
||||
const timestamp = container.querySelector(".mx_MessageTimestamp");
|
||||
|
||||
expect(timestamp).not.toBeNull();
|
||||
expect(timestamp?.tagName).toBe("SPAN");
|
||||
});
|
||||
|
||||
it("dispatches a room view when the linked timestamp is clicked", () => {
|
||||
jest.spyOn(dis, "dispatch").mockImplementation(() => {});
|
||||
const permalinkCreator = new RoomPermalinkCreator(room);
|
||||
const { container } = getComponent({ alwaysShowTimestamps: true, permalinkCreator });
|
||||
const timestamp = container.querySelector<HTMLAnchorElement>("a.mx_MessageTimestamp");
|
||||
|
||||
expect(timestamp).not.toBeNull();
|
||||
fireEvent.click(timestamp!);
|
||||
|
||||
expect(dis.dispatch).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
action: Action.ViewRoom,
|
||||
event_id: mxEvent.getId(),
|
||||
highlighted: true,
|
||||
room_id: room.roomId,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("sender and avatar rendering", () => {
|
||||
it("shows sender and avatar in room timelines", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides sender and avatar for continuation events in room timelines", () => {
|
||||
const { container } = getComponent({ continuation: true });
|
||||
|
||||
expectTileClass(container, "mx_EventTile_continuation");
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull();
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).toBeNull();
|
||||
});
|
||||
|
||||
it("hides sender but keeps avatar when sender display is disabled", () => {
|
||||
const { container } = getComponent({ hideSender: true });
|
||||
|
||||
expectTileClass(container, "mx_EventTile_noSender");
|
||||
expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull();
|
||||
expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders sender details as a permalink in file timelines", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.File);
|
||||
const senderDetailsLink = container.querySelector(".mx_EventTile_senderDetailsLink");
|
||||
|
||||
expect(senderDetailsLink).not.toBeNull();
|
||||
expect(senderDetailsLink).toContainElement(container.querySelector(".mx_DisambiguatedProfile"));
|
||||
expect(senderDetailsLink).toContainElement(container.querySelector(".mx_EventTile_avatar"));
|
||||
});
|
||||
|
||||
it("renders sender details in thread timelines", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.Thread);
|
||||
const senderDetails = container.querySelector(".mx_EventTile_senderDetails");
|
||||
|
||||
expect(senderDetails).not.toBeNull();
|
||||
expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile"));
|
||||
expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("read receipt option", () => {
|
||||
it("shows a sent receipt for the current user's last successful event", () => {
|
||||
const ownEvent = makeOwnMessage();
|
||||
const { getByRole } = getComponent({ mxEvent: ownEvent, lastSuccessful: true });
|
||||
|
||||
expect(getByRole("status")).toHaveAccessibleName("Your message was sent");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[EventStatus.SENDING, "Sending your message…"],
|
||||
[EventStatus.ENCRYPTING, "Encrypting your message…"],
|
||||
[EventStatus.NOT_SENT, "Failed to send"],
|
||||
])("shows the %s receipt for the current user's pending event", (eventSendStatus, label) => {
|
||||
const ownEvent = makeOwnMessage();
|
||||
ownEvent.setStatus(eventSendStatus);
|
||||
const { getByRole } = getComponent({ mxEvent: ownEvent, eventSendStatus });
|
||||
|
||||
expect(getByRole("status")).toHaveAccessibleName(label);
|
||||
});
|
||||
|
||||
it("does not show a sent receipt in the threads list", () => {
|
||||
const ownEvent = makeOwnMessage();
|
||||
const { queryByRole } = getComponent(
|
||||
{ mxEvent: ownEvent, lastSuccessful: true },
|
||||
TimelineRenderingType.ThreadsList,
|
||||
);
|
||||
|
||||
expect(queryByRole("status", { name: "Your message was sent" })).toBeNull();
|
||||
});
|
||||
|
||||
it("shows normal read receipts instead of the sent receipt when other users have read the event", () => {
|
||||
const ownEvent = makeOwnMessage();
|
||||
const { getByRole, queryByRole } = getComponent({
|
||||
mxEvent: ownEvent,
|
||||
lastSuccessful: true,
|
||||
showReadReceipts: true,
|
||||
readReceipts: [
|
||||
{
|
||||
userId: "@bob:example.org",
|
||||
roomMember: null,
|
||||
ts: 1234,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(queryByRole("status", { name: "Your message was sent" })).toBeNull();
|
||||
expect(getByRole("group", { name: "Seen by 1 person" })).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reactions and footer", () => {
|
||||
it("gets annotation relations when reactions are enabled", () => {
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue(null);
|
||||
|
||||
getComponent({ showReactions: true, getRelationsForEvent });
|
||||
|
||||
expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction");
|
||||
});
|
||||
|
||||
it("does not get annotation relations when reactions are disabled", () => {
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue(null);
|
||||
|
||||
getComponent({ getRelationsForEvent });
|
||||
|
||||
expect(getRelationsForEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("refreshes annotation relations when reaction relations are created", () => {
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue(null);
|
||||
getComponent({ showReactions: true, getRelationsForEvent });
|
||||
getRelationsForEvent.mockClear();
|
||||
|
||||
act(() => {
|
||||
mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.annotation", "m.reaction");
|
||||
});
|
||||
|
||||
expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction");
|
||||
});
|
||||
|
||||
it("does not refresh annotation relations for unrelated relations", () => {
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue(null);
|
||||
getComponent({ showReactions: true, getRelationsForEvent });
|
||||
getRelationsForEvent.mockClear();
|
||||
|
||||
act(() => {
|
||||
mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", "m.room.message");
|
||||
});
|
||||
|
||||
expect(getRelationsForEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not render reactions for redacted events", () => {
|
||||
const getRelationsForEvent = jest.fn().mockReturnValue(null);
|
||||
const { container } = getComponent({ showReactions: true, getRelationsForEvent, isRedacted: true });
|
||||
|
||||
expect(container.querySelector(".mx_ReactionsRow")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders a footer for pinned messages", () => {
|
||||
jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true);
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull();
|
||||
expect(screen.getByText("Pinned message")).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("action bar", () => {
|
||||
it("does not render the message action bar by default", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
|
||||
});
|
||||
|
||||
it("renders the message action bar when the tile is hovered", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
fireEvent.mouseEnter(getTile(container));
|
||||
|
||||
expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("renders the message action bar when the tile receives keyboard focus", () => {
|
||||
const matches = HTMLElement.prototype.matches;
|
||||
jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) {
|
||||
if (selector === ":focus-visible") return true;
|
||||
return matches.call(this, selector);
|
||||
});
|
||||
const { container } = getComponent();
|
||||
|
||||
act(() => {
|
||||
getTile(container).focus();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("hides the keyboard-focused message action bar when focus leaves the tile", () => {
|
||||
const matches = HTMLElement.prototype.matches;
|
||||
jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) {
|
||||
if (selector === ":focus-visible") return true;
|
||||
return matches.call(this, selector);
|
||||
});
|
||||
const { container } = getComponent();
|
||||
const tile = getTile(container);
|
||||
|
||||
act(() => {
|
||||
tile.focus();
|
||||
});
|
||||
expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull();
|
||||
|
||||
act(() => {
|
||||
tile.blur();
|
||||
});
|
||||
|
||||
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the message action bar on hover when exporting", () => {
|
||||
const { container } = getComponent({ forExport: true });
|
||||
|
||||
fireEvent.mouseEnter(getTile(container));
|
||||
|
||||
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the message action bar on hover while editing", () => {
|
||||
const { container } = getComponent({ editState: {} as EventTileProps["editState"] });
|
||||
|
||||
fireEvent.mouseEnter(getTile(container));
|
||||
|
||||
expect(container.querySelector(".mx_MessageActionBar")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("context menu", () => {
|
||||
it("renders the message context menu when the event line is right-clicked", async () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 });
|
||||
|
||||
expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("marks the tile selected when the context menu is open", async () => {
|
||||
const { container } = getComponent();
|
||||
const tile = getTile(container);
|
||||
|
||||
fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 });
|
||||
|
||||
expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument();
|
||||
expect(tile).toHaveClass("mx_EventTile_selected");
|
||||
});
|
||||
|
||||
it("shows the timestamp while the context menu is open", async () => {
|
||||
mxEvent = makeTimestampedMessage();
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).toBeNull();
|
||||
|
||||
fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 });
|
||||
|
||||
expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument();
|
||||
expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the message context menu while editing", () => {
|
||||
const { container } = getComponent({ editState: {} as EventTileProps["editState"] });
|
||||
|
||||
expect(container.querySelector(".mx_EventTile_line")).toBeNull();
|
||||
expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull();
|
||||
});
|
||||
|
||||
it("does not override the native browser context menu for links", () => {
|
||||
const { container } = getComponent();
|
||||
jest.spyOn(PlatformPeg, "get").mockReturnValue({
|
||||
allowOverridingNativeContextMenus: () => false,
|
||||
} as ReturnType<typeof PlatformPeg.get>);
|
||||
const link = document.createElement("a");
|
||||
link.href = "https://example.org/";
|
||||
getLine(container).appendChild(link);
|
||||
|
||||
const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 1, clientY: 2 });
|
||||
link.dispatchEvent(event);
|
||||
|
||||
expect(event.defaultPrevented).toBe(false);
|
||||
expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reply chain", () => {
|
||||
it("marks non-reply events as having no reply", () => {
|
||||
const { container } = getComponent();
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("data-has-reply", "false");
|
||||
expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull();
|
||||
});
|
||||
|
||||
it("marks reply events as having a reply chain", () => {
|
||||
const replyEvent = makeReplyEvent(room.roomId);
|
||||
const { container } = getComponent({ mxEvent: replyEvent });
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("data-has-reply", "true");
|
||||
expect(container.querySelector(".mx_ReplyChain_wrapper")).not.toBeNull();
|
||||
});
|
||||
|
||||
it("does not render the reply chain for redacted reply events", () => {
|
||||
const replyEvent = makeReplyEvent(room.roomId);
|
||||
jest.spyOn(replyEvent, "isRedacted").mockReturnValue(true);
|
||||
const { container } = getComponent({ mxEvent: replyEvent });
|
||||
|
||||
expect(getTile(container)).toHaveAttribute("data-has-reply", "false");
|
||||
expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventTile thread summary", () => {
|
||||
beforeEach(() => {
|
||||
jest.spyOn(client, "supportsThreads").mockReturnValue(true);
|
||||
@ -150,6 +805,43 @@ describe("EventTile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("search thread info", () => {
|
||||
it("renders search thread info for events in a thread", () => {
|
||||
const threadEvent = makeThreadReplyEvent(room.roomId);
|
||||
const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search);
|
||||
|
||||
expect(container.querySelector(".mx_ThreadSummary_icon")).not.toBeNull();
|
||||
expect(container.querySelector(".mx_ThreadSummary_icon")).toHaveTextContent("From a thread");
|
||||
});
|
||||
|
||||
it("renders search thread info as a link when a highlight link is provided", () => {
|
||||
const threadEvent = makeThreadReplyEvent(room.roomId);
|
||||
const { container } = getComponent(
|
||||
{ mxEvent: threadEvent, highlightLink: "https://example.org/thread" },
|
||||
TimelineRenderingType.Search,
|
||||
);
|
||||
const threadInfo = container.querySelector<HTMLAnchorElement>("a.mx_ThreadSummary_icon");
|
||||
|
||||
expect(threadInfo).not.toBeNull();
|
||||
expect(threadInfo).toHaveAttribute("href", "https://example.org/thread");
|
||||
});
|
||||
|
||||
it("renders search thread info as text when no highlight link is provided", () => {
|
||||
const threadEvent = makeThreadReplyEvent(room.roomId);
|
||||
const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search);
|
||||
const threadInfo = container.querySelector(".mx_ThreadSummary_icon");
|
||||
|
||||
expect(threadInfo?.tagName).toBe("P");
|
||||
});
|
||||
|
||||
it("does not render search thread info outside search timelines", () => {
|
||||
const threadEvent = makeThreadReplyEvent(room.roomId);
|
||||
const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Room);
|
||||
|
||||
expect(container.querySelector(".mx_ThreadSummary_icon")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("EventTile renderingType: ThreadsList", () => {
|
||||
it("shows an unread notification badge", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.ThreadsList);
|
||||
@ -246,13 +938,6 @@ describe("EventTile", () => {
|
||||
});
|
||||
|
||||
describe("EventTile in the right panel", () => {
|
||||
beforeAll(() => {
|
||||
const dmRoomMap: DMRoomMap = {
|
||||
getUserIdForRoomId: jest.fn(),
|
||||
} as unknown as DMRoomMap;
|
||||
DMRoomMap.setShared(dmRoomMap);
|
||||
});
|
||||
|
||||
it("renders the room name for notifications", () => {
|
||||
const { container } = getComponent({}, TimelineRenderingType.Notification);
|
||||
expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent(
|
||||
@ -600,6 +1285,43 @@ describe("EventTile", () => {
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("does not highlight when exporting", () => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue({
|
||||
notify: true,
|
||||
tweaks: { [TweakName.Highlight]: true },
|
||||
});
|
||||
const { container } = getComponent({ forExport: true });
|
||||
|
||||
expect(client.getPushActionsForEvent).not.toHaveBeenCalled();
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
});
|
||||
|
||||
it.each([TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])(
|
||||
"does not highlight in %s timelines",
|
||||
(renderingType) => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue({
|
||||
notify: true,
|
||||
tweaks: { [TweakName.Highlight]: true },
|
||||
});
|
||||
const { container } = getComponent({}, renderingType);
|
||||
|
||||
expect(client.getPushActionsForEvent).not.toHaveBeenCalled();
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
},
|
||||
);
|
||||
|
||||
it("does not highlight events sent by the current user", () => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue({
|
||||
notify: true,
|
||||
tweaks: { [TweakName.Highlight]: true },
|
||||
});
|
||||
const ownEvent = makeOwnMessage();
|
||||
const { container } = getComponent({ mxEvent: ownEvent });
|
||||
|
||||
expect(client.getPushActionsForEvent).toHaveBeenCalledWith(ownEvent);
|
||||
expect(isHighlighted(container)).toBeFalsy();
|
||||
});
|
||||
|
||||
it("highlights when message's push actions have a highlight tweak", () => {
|
||||
mocked(client.getPushActionsForEvent).mockReturnValue({
|
||||
notify: true,
|
||||
@ -676,6 +1398,48 @@ describe("EventTile", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("does not leave a stale message action bar when switching edited events", async () => {
|
||||
const firstEvent = mkMessage({
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
msg: "First message",
|
||||
event: true,
|
||||
});
|
||||
const secondEvent = mkMessage({
|
||||
room: room.roomId,
|
||||
user: "@alice:example.org",
|
||||
msg: "Second message",
|
||||
event: true,
|
||||
});
|
||||
const events = [firstEvent, secondEvent];
|
||||
|
||||
const matches = jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (
|
||||
this: HTMLElement,
|
||||
selector: string,
|
||||
) {
|
||||
if (selector === ":focus-visible") {
|
||||
return true;
|
||||
}
|
||||
return Element.prototype.matches.call(this, selector);
|
||||
});
|
||||
|
||||
const { container, rerender } = render(<WrappedEventTiles events={events} editEvent={firstEvent} />);
|
||||
const editingTile = container.querySelector(".mx_EventTile_isEditing");
|
||||
|
||||
expect(editingTile).not.toBeNull();
|
||||
fireEvent.focusIn(editingTile!);
|
||||
expect(container.querySelectorAll(".mx_MessageActionBar")).toHaveLength(0);
|
||||
|
||||
rerender(<WrappedEventTiles events={events} editEvent={secondEvent} />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.querySelectorAll(".mx_EventTile_isEditing")).toHaveLength(1);
|
||||
expect(container.querySelectorAll(".mx_MessageActionBar")).toHaveLength(0);
|
||||
});
|
||||
|
||||
matches.mockRestore();
|
||||
});
|
||||
|
||||
it("should display the not encrypted status for an unencrypted event when the room becomes encrypted", async () => {
|
||||
jest.spyOn(client.getCrypto()!, "getEncryptionInfoForEvent").mockResolvedValue({
|
||||
shieldColour: EventShieldColour.NONE,
|
||||
|
||||
@ -12,7 +12,6 @@ import { mocked } from "jest-mock";
|
||||
import type { MatrixClient } from "matrix-js-sdk/src/matrix";
|
||||
import type { RoomNotificationState } from "../../../../src/stores/notifications/RoomNotificationState";
|
||||
import {
|
||||
CHATS_TAG,
|
||||
LISTS_UPDATE_EVENT,
|
||||
SECTION_CREATED_EVENT,
|
||||
RoomListStoreV3Class,
|
||||
@ -37,6 +36,7 @@ import * as utils from "../../../../src/utils/notifications";
|
||||
import * as utilsRLS from "../../../../src/stores/room-list-v3/utils.ts";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
import { SettingLevel } from "../../../../src/settings/SettingLevel.ts";
|
||||
import { CHATS_TAG } from "../../../../src/stores/room-list-v3/section";
|
||||
|
||||
describe("RoomListStoreV3", () => {
|
||||
async function getRoomListStore() {
|
||||
|
||||
@ -7,9 +7,18 @@
|
||||
|
||||
import Modal from "../../../../src/Modal";
|
||||
import SettingsStore from "../../../../src/settings/SettingsStore";
|
||||
import { createSection, editSection, deleteSection } from "../../../../src/stores/room-list-v3/section";
|
||||
import {
|
||||
CHATS_TAG,
|
||||
CUSTOM_SECTION_TAG_PREFIX,
|
||||
createSection,
|
||||
editSection,
|
||||
deleteSection,
|
||||
isDefaultSectionTag,
|
||||
isSectionTag,
|
||||
} from "../../../../src/stores/room-list-v3/section";
|
||||
import { CreateSectionDialog } from "../../../../src/components/views/dialogs/CreateSectionDialog";
|
||||
import { RemoveSectionDialog } from "../../../../src/components/views/dialogs/RemoveSectionDialog";
|
||||
import { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||
|
||||
describe("section", () => {
|
||||
afterEach(() => {
|
||||
@ -203,4 +212,27 @@ describe("section", () => {
|
||||
expect(customDataCall![3]).not.toHaveProperty(tag);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isDefaultSectionTag", () => {
|
||||
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG])("returns true for %s", (tag) => {
|
||||
expect(isDefaultSectionTag(tag)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => {
|
||||
expect(isDefaultSectionTag(tag)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isSectionTag", () => {
|
||||
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, CHATS_TAG, `${CUSTOM_SECTION_TAG_PREFIX}some-uuid`])(
|
||||
"returns true for %s",
|
||||
(tag) => {
|
||||
expect(isSectionTag(tag)).toBe(true);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([DefaultTagID.Invite, "some.random.tag"])("returns false for %s", (tag) => {
|
||||
expect(isSectionTag(tag)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations 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 { DefaultTagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
|
||||
import { getSectionTagForRoom } from "../../../../src/utils/room/getSectionTagForRoom";
|
||||
import { getTagsForRoom } from "../../../../src/utils/room/getTagsForRoom";
|
||||
|
||||
jest.mock("../../../../src/utils/room/getTagsForRoom");
|
||||
|
||||
const mockGetTagsForRoom = jest.mocked(getTagsForRoom);
|
||||
|
||||
describe("getSectionTagForRoom", () => {
|
||||
const room = {} as Room;
|
||||
|
||||
it("should return null when room has no tags", () => {
|
||||
mockGetTagsForRoom.mockReturnValue([]);
|
||||
expect(getSectionTagForRoom(room)).toBeNull();
|
||||
});
|
||||
|
||||
it("should return null when room only has a non-section tag", () => {
|
||||
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged]);
|
||||
expect(getSectionTagForRoom(room)).toBeNull();
|
||||
});
|
||||
|
||||
it.each([DefaultTagID.Favourite, DefaultTagID.LowPriority, `${CUSTOM_SECTION_TAG_PREFIX}abc-123`])(
|
||||
"should return section tag %s when present",
|
||||
(tag) => {
|
||||
mockGetTagsForRoom.mockReturnValue([tag]);
|
||||
expect(getSectionTagForRoom(room)).toBe(tag);
|
||||
},
|
||||
);
|
||||
|
||||
it("should return the first section tag when multiple are present", () => {
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`;
|
||||
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Favourite, customTag]);
|
||||
expect(getSectionTagForRoom(room)).toBe(DefaultTagID.Favourite);
|
||||
});
|
||||
|
||||
it("should ignore non-section tags and return the section tag", () => {
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}abc-123`;
|
||||
mockGetTagsForRoom.mockReturnValue([DefaultTagID.Untagged, customTag]);
|
||||
expect(getSectionTagForRoom(room)).toBe(customTag);
|
||||
});
|
||||
});
|
||||
@ -11,23 +11,23 @@ import { Room } from "matrix-js-sdk/src/matrix";
|
||||
import RoomListActions from "../../../../src/actions/RoomListActions";
|
||||
import defaultDispatcher from "../../../../src/dispatcher/dispatcher";
|
||||
import { DefaultTagID, type TagID } from "../../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
|
||||
import { CHATS_TAG, CUSTOM_SECTION_TAG_PREFIX } from "../../../../src/stores/room-list-v3/section";
|
||||
import { tagRoom } from "../../../../src/utils/room/tagRoom";
|
||||
import { getMockClientWithEventEmitter } from "../../../test-utils";
|
||||
import * as getTagsForRoomUtils from "../../../../src/utils/room/getTagsForRoom";
|
||||
import * as getSectionTagForRoomUtils from "../../../../src/utils/room/getSectionTagForRoom";
|
||||
|
||||
describe("tagRoom()", () => {
|
||||
const userId = "@alice:server.org";
|
||||
const roomId = "!room:server.org";
|
||||
const customTag = `${CUSTOM_SECTION_TAG_PREFIX}my-section`;
|
||||
|
||||
const makeRoom = (tags: TagID[] = []): Room => {
|
||||
const makeRoom = (currentSectionTag: TagID | null = null): Room => {
|
||||
const client = getMockClientWithEventEmitter({
|
||||
isGuest: jest.fn(),
|
||||
});
|
||||
const room = new Room(roomId, client, userId);
|
||||
|
||||
jest.spyOn(getTagsForRoomUtils, "getTagsForRoom").mockReturnValue(tags);
|
||||
jest.spyOn(getSectionTagForRoomUtils, "getSectionTagForRoom").mockReturnValue(currentSectionTag);
|
||||
|
||||
return room;
|
||||
};
|
||||
@ -51,7 +51,7 @@ describe("tagRoom()", () => {
|
||||
expect(RoomListActions.tagRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("when a room has no tags", () => {
|
||||
describe("when a room has no section tag", () => {
|
||||
it("should tag a room as favourite", () => {
|
||||
const room = makeRoom();
|
||||
|
||||
@ -93,11 +93,25 @@ describe("tagRoom()", () => {
|
||||
customTag, // add
|
||||
);
|
||||
});
|
||||
|
||||
it("should do nothing meaningful when applying CHATS_TAG", () => {
|
||||
const room = makeRoom();
|
||||
|
||||
tagRoom(room, CHATS_TAG);
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
null, // remove
|
||||
null, // add
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a room is tagged as favourite", () => {
|
||||
it("should unfavourite a room", () => {
|
||||
const room = makeRoom([DefaultTagID.Favourite]);
|
||||
const room = makeRoom(DefaultTagID.Favourite);
|
||||
|
||||
tagRoom(room, DefaultTagID.Favourite);
|
||||
|
||||
@ -111,7 +125,7 @@ describe("tagRoom()", () => {
|
||||
});
|
||||
|
||||
it("should tag a room low priority", () => {
|
||||
const room = makeRoom([DefaultTagID.Favourite]);
|
||||
const room = makeRoom(DefaultTagID.Favourite);
|
||||
|
||||
tagRoom(room, DefaultTagID.LowPriority);
|
||||
|
||||
@ -123,10 +137,25 @@ describe("tagRoom()", () => {
|
||||
DefaultTagID.LowPriority, // add
|
||||
);
|
||||
});
|
||||
|
||||
it("should remove the favourite tag when applying CHATS_TAG", () => {
|
||||
const room = makeRoom(DefaultTagID.Favourite);
|
||||
|
||||
tagRoom(room, CHATS_TAG);
|
||||
|
||||
expect(defaultDispatcher.dispatch).toHaveBeenCalled();
|
||||
expect(RoomListActions.tagRoom).toHaveBeenCalledWith(
|
||||
room.client,
|
||||
room,
|
||||
DefaultTagID.Favourite, // remove
|
||||
null, // add
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("when a room is tagged as low priority", () => {
|
||||
it("should favourite a room", () => {
|
||||
const room = makeRoom([DefaultTagID.LowPriority]);
|
||||
const room = makeRoom(DefaultTagID.LowPriority);
|
||||
|
||||
tagRoom(room, DefaultTagID.Favourite);
|
||||
|
||||
@ -140,7 +169,7 @@ describe("tagRoom()", () => {
|
||||
});
|
||||
|
||||
it("should untag a room low priority", () => {
|
||||
const room = makeRoom([DefaultTagID.LowPriority]);
|
||||
const room = makeRoom(DefaultTagID.LowPriority);
|
||||
|
||||
tagRoom(room, DefaultTagID.LowPriority);
|
||||
|
||||
@ -161,8 +190,9 @@ describe("tagRoom()", () => {
|
||||
{ label: "untag the custom section", applyTag: customTag, expectedAdd: null },
|
||||
{ label: "replace with favourite", applyTag: DefaultTagID.Favourite, expectedAdd: DefaultTagID.Favourite },
|
||||
{ label: "replace with another custom section", applyTag: otherCustomTag, expectedAdd: otherCustomTag },
|
||||
{ label: "remove section tag when applying CHATS_TAG", applyTag: CHATS_TAG, expectedAdd: null },
|
||||
])("should $label", ({ applyTag, expectedAdd }) => {
|
||||
const room = makeRoom([customTag]);
|
||||
const room = makeRoom(customTag);
|
||||
|
||||
tagRoom(room, applyTag);
|
||||
|
||||
|
||||
1012
apps/web/test/viewmodels/message-body/ImageBodyViewModel-test.tsx
Normal file
@ -30,8 +30,9 @@ import { Action } from "../../../src/dispatcher/actions";
|
||||
import { CallStore } from "../../../src/stores/CallStore";
|
||||
import { CallEvent, type Call } from "../../../src/models/Call";
|
||||
import { RoomListItemViewModel } from "../../../src/viewmodels/room-list/RoomListItemViewModel";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import * as tagRoomModule from "../../../src/utils/room/tagRoom";
|
||||
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
hasAccessToOptionsMenu: jest.fn().mockReturnValue(true),
|
||||
|
||||
@ -13,8 +13,9 @@ import { RoomNotificationStateStore } from "../../../src/stores/notifications/Ro
|
||||
import { NotificationStateEvents } from "../../../src/stores/notifications/NotificationState";
|
||||
import { createTestClient, mkRoom } from "../../test-utils";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import RoomListStoreV3, { CHATS_TAG } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3 from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||
|
||||
describe("RoomListSectionHeaderViewModel", () => {
|
||||
let onToggleExpanded: jest.Mock;
|
||||
|
||||
@ -10,7 +10,7 @@ import { mocked } from "jest-mock";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import { createTestClient, flushPromises, flushPromisesWithFakeTimers, mkStubRoom, stubClient } from "../../test-utils";
|
||||
import RoomListStoreV3, { CHATS_TAG, RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import RoomListStoreV3, { RoomListStoreV3Event } from "../../../src/stores/room-list-v3/RoomListStoreV3";
|
||||
import SpaceStore from "../../../src/stores/spaces/SpaceStore";
|
||||
import { FilterEnum } from "../../../src/stores/room-list-v3/skip-list/filters";
|
||||
import dispatcher from "../../../src/dispatcher/dispatcher";
|
||||
@ -21,6 +21,17 @@ import { RoomListViewModel } from "../../../src/viewmodels/room-list/RoomListVie
|
||||
import { hasCreateRoomRights } from "../../../src/viewmodels/room-list/utils";
|
||||
import { DefaultTagID } from "../../../src/stores/room-list-v3/skip-list/tag";
|
||||
import SettingsStore from "../../../src/settings/SettingsStore";
|
||||
import { tagRoom } from "../../../src/utils/room/tagRoom";
|
||||
import { getSectionTagForRoom } from "../../../src/utils/room/getSectionTagForRoom";
|
||||
import { CHATS_TAG } from "../../../src/stores/room-list-v3/section";
|
||||
|
||||
jest.mock("../../../src/utils/room/tagRoom", () => ({
|
||||
tagRoom: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/utils/room/getSectionTagForRoom", () => ({
|
||||
getSectionTagForRoom: jest.fn().mockReturnValue(null),
|
||||
}));
|
||||
|
||||
jest.mock("../../../src/viewmodels/room-list/utils", () => ({
|
||||
hasCreateRoomRights: jest.fn().mockReturnValue(false),
|
||||
@ -1108,4 +1119,37 @@ describe("RoomListViewModel", () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("changeRoomSection", () => {
|
||||
beforeEach(() => {
|
||||
viewModel = new RoomListViewModel({ client: matrixClient });
|
||||
mocked(tagRoom).mockClear();
|
||||
});
|
||||
|
||||
it("should call tagRoom with the room and target tag", () => {
|
||||
jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1);
|
||||
mocked(getSectionTagForRoom).mockReturnValue(null);
|
||||
|
||||
viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite);
|
||||
|
||||
expect(tagRoom).toHaveBeenCalledWith(room1, DefaultTagID.Favourite);
|
||||
});
|
||||
|
||||
it("should do nothing when the room is not found", () => {
|
||||
jest.spyOn(matrixClient, "getRoom").mockReturnValue(null);
|
||||
|
||||
viewModel.changeRoomSection("!unknown:server", DefaultTagID.Favourite);
|
||||
|
||||
expect(tagRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should do nothing when the room is already in the target section", () => {
|
||||
jest.spyOn(matrixClient, "getRoom").mockReturnValue(room1);
|
||||
mocked(getSectionTagForRoom).mockReturnValue(DefaultTagID.Favourite);
|
||||
|
||||
viewModel.changeRoomSection(room1.roomId, DefaultTagID.Favourite);
|
||||
|
||||
expect(tagRoom).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -67,7 +67,8 @@
|
||||
"postcss-mixins": "patches/postcss-mixins.patch",
|
||||
"app-builder-lib": "patches/app-builder-lib.patch",
|
||||
"knip": "patches/knip.patch",
|
||||
"plist": "patches/plist.patch"
|
||||
"plist": "patches/plist.patch",
|
||||
"@dnd-kit/abstract": "patches/@dnd-kit__abstract.patch"
|
||||
},
|
||||
"peerDependencyRules": {
|
||||
"allowedVersions": {
|
||||
|
||||
@ -1,7 +1,6 @@
|
||||
ARG PLAYWRIGHT_VERSION
|
||||
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
|
||||
# Expose the argument to this build stage
|
||||
ARG PLAYWRIGHT_VERSION
|
||||
FROM mcr.microsoft.com/playwright:v${PLAYWRIGHT_VERSION}-noble
|
||||
|
||||
WORKDIR /work
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@
|
||||
"platforms": ["linux/amd64", "linux/arm64"],
|
||||
"provenance": "true",
|
||||
"sbom": true,
|
||||
"env-file": ["{projectRoot}/.env.docker:build"],
|
||||
"build-args": ["PLAYWRIGHT_VERSION=$PLAYWRIGHT_VERSION"],
|
||||
"context": "{projectRoot}",
|
||||
"metadata": {
|
||||
|
||||
@ -16,10 +16,12 @@ import "./app-web-root.css";
|
||||
import "./preview.css";
|
||||
import React, { useLayoutEffect } from "react";
|
||||
import { TooltipProvider } from "@vector-im/compound-web";
|
||||
import type { StoryContext } from "storybook/internal/csf";
|
||||
|
||||
import { EventPresentationProvider, type EventDensity, type EventLayout, I18nApi, I18nContext } from "../src";
|
||||
import { setLanguage } from "../src/core/i18n/i18n";
|
||||
import { StoryContext } from "storybook/internal/csf";
|
||||
import { DragDropProvider } from "@dnd-kit/react";
|
||||
import { PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
|
||||
|
||||
export const globalTypes = {
|
||||
theme: {
|
||||
@ -172,7 +174,28 @@ const withEventPresentationProvider: Decorator = (Story, context) => {
|
||||
);
|
||||
};
|
||||
|
||||
const preview = {
|
||||
/**
|
||||
* Wrap all stories in a DragDropProvider that excludes the Accessibility plugin.
|
||||
* dnd-kit's Accessibility plugin adds aria attributes (tabindex, aria-pressed, etc.)
|
||||
* that conflict with the existing ARIA roles used in the room list components.
|
||||
*/
|
||||
const withDragDropProvider: Decorator = (Story) => {
|
||||
return (
|
||||
<DragDropProvider
|
||||
sensors={[
|
||||
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
|
||||
// So we start dragging after the pointer has moved by 5 pixels, to allow for click without dragging
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<Story />
|
||||
</DragDropProvider>
|
||||
);
|
||||
};
|
||||
|
||||
const preview: Preview = {
|
||||
tags: ["autodocs", "snapshot"],
|
||||
initialGlobals: {
|
||||
rootCss: "storybook",
|
||||
@ -181,7 +204,14 @@ const preview = {
|
||||
eventLayout: "group",
|
||||
eventDensity: "default",
|
||||
},
|
||||
decorators: [withRootCss, withThemeProvider, withEventPresentationProvider, withTooltipProvider, withI18nProvider],
|
||||
decorators: [
|
||||
withRootCss,
|
||||
withThemeProvider,
|
||||
withEventPresentationProvider,
|
||||
withTooltipProvider,
|
||||
withI18nProvider,
|
||||
withDragDropProvider,
|
||||
],
|
||||
parameters: {
|
||||
options: {
|
||||
storySort: {
|
||||
|
||||
@ -40,6 +40,21 @@ or in CSS file:
|
||||
@import url("@element-hq/web-shared-components");
|
||||
```
|
||||
|
||||
### Sub-path Imports
|
||||
|
||||
Callers running outside the browser DOM (e.g. inside an `AudioWorkletGlobalScope`
|
||||
or a worker) can pull in the small standalone `numbers` utility bundle without
|
||||
loading the rest of the package bundle, which transitively imports React,
|
||||
dnd-kit, and other code that touches `window` / `document`:
|
||||
|
||||
```javascript
|
||||
import { percentageOf, percentageWithin } from "@element-hq/web-shared-components/numbers";
|
||||
```
|
||||
|
||||
The sub-path exposes the same functions listed under [Formatting](#formatting)
|
||||
and ships as its own ES/CJS bundle in `dist/numbers.{js,umd.cjs}`. Prefer the
|
||||
main package entry for everything else.
|
||||
|
||||
### Using Components
|
||||
|
||||
There are two kinds of components in this library:
|
||||
|
||||
|
After Width: | Height: | Size: 21 KiB |
@ -21,6 +21,16 @@
|
||||
"default": "./dist/element-web-shared-components.js"
|
||||
}
|
||||
},
|
||||
"./numbers": {
|
||||
"require": {
|
||||
"types": "./dist/numbers.d.ts",
|
||||
"default": "./dist/numbers.umd.cjs"
|
||||
},
|
||||
"import": {
|
||||
"types": "./dist/numbers.d.ts",
|
||||
"default": "./dist/numbers.js"
|
||||
}
|
||||
},
|
||||
"./dist/element-web-shared-components.css": {
|
||||
"require": "./dist/element-web-shared-components.css",
|
||||
"import": "./dist/element-web-shared-components.css"
|
||||
@ -51,6 +61,9 @@
|
||||
"lint:types": "nx lint:types"
|
||||
},
|
||||
"dependencies": {
|
||||
"@dnd-kit/abstract": "^0.4.0",
|
||||
"@dnd-kit/dom": "^0.4.0",
|
||||
"@dnd-kit/react": "^0.4.0",
|
||||
"@element-hq/element-web-module-api": "workspace:*",
|
||||
"@matrix-org/spec": "^1.7.0",
|
||||
"@vector-im/compound-design-tokens": "catalog:",
|
||||
|
||||
@ -81,6 +81,13 @@ export interface VirtualizedListProps<Item, Context> extends Omit<
|
||||
*/
|
||||
onKeyDown?: (e: React.KeyboardEvent<HTMLDivElement>) => void;
|
||||
|
||||
/**
|
||||
* When true, keyboard navigation (Arrow keys, Home, End, Page Up/Down) is disabled.
|
||||
* All key events are forwarded directly to `onKeyDown` instead.
|
||||
* Use this to prevent the list from scrolling while an item is being dragged via keyboard.
|
||||
*/
|
||||
disableKeyboardNavigation?: boolean;
|
||||
|
||||
/**
|
||||
* Optional total count of items (for virtualization with partial data loading).
|
||||
* If provided, this will be used instead of items.length for the total count.
|
||||
@ -164,6 +171,7 @@ export function useVirtualizedList<Item, Context>(
|
||||
getItemKey,
|
||||
context,
|
||||
onKeyDown,
|
||||
disableKeyboardNavigation,
|
||||
totalCount,
|
||||
rangeChanged,
|
||||
mapScrollIndex,
|
||||
@ -260,6 +268,13 @@ export function useVirtualizedList<Item, Context>(
|
||||
return;
|
||||
}
|
||||
|
||||
// When keyboard navigation is disabled (e.g. during a keyboard drag),
|
||||
// forward all events to the parent handler without handling navigation.
|
||||
if (disableKeyboardNavigation) {
|
||||
onKeyDown?.(e);
|
||||
return;
|
||||
}
|
||||
|
||||
if (e.code === Key.ARROW_UP && currentIndex !== undefined) {
|
||||
scrollToItem(currentIndex - 1, false);
|
||||
handled = true;
|
||||
@ -300,7 +315,16 @@ export function useVirtualizedList<Item, Context>(
|
||||
onKeyDown?.(e);
|
||||
}
|
||||
},
|
||||
[scrollToIndex, scrollToItem, tabIndexKey, keyToIndexMap, visibleRange, items, onKeyDown],
|
||||
[
|
||||
scrollToIndex,
|
||||
scrollToItem,
|
||||
tabIndexKey,
|
||||
keyToIndexMap,
|
||||
visibleRange,
|
||||
items,
|
||||
onKeyDown,
|
||||
disableKeyboardNavigation,
|
||||
],
|
||||
);
|
||||
|
||||
/**
|
||||
|
||||
@ -40,6 +40,7 @@ const RoomListViewWrapperImpl = ({
|
||||
updateVisibleRooms,
|
||||
renderAvatar: renderAvatarProp,
|
||||
closeToast,
|
||||
changeRoomSection,
|
||||
...rest
|
||||
}: RoomListViewProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
@ -50,6 +51,7 @@ const RoomListViewWrapperImpl = ({
|
||||
getSectionHeaderViewModel,
|
||||
updateVisibleRooms,
|
||||
closeToast,
|
||||
changeRoomSection,
|
||||
});
|
||||
return <RoomListView vm={vm} renderAvatar={renderAvatarProp} />;
|
||||
};
|
||||
@ -102,6 +104,7 @@ const meta = {
|
||||
isFlatList: true,
|
||||
toast: undefined,
|
||||
closeToast: fn(),
|
||||
changeRoomSection: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
|
||||
@ -74,6 +74,8 @@ export interface RoomListViewActions {
|
||||
getSectionHeaderViewModel: (sectionId: string) => RoomListSectionHeaderViewModel;
|
||||
/** Called to close the toast message */
|
||||
closeToast: () => void;
|
||||
/** Called to change the section of a room */
|
||||
changeRoomSection: (roomId: string, tag: string) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -0,0 +1,11 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations 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.
|
||||
*/
|
||||
|
||||
.dragOverlay {
|
||||
--padding-top: 0px;
|
||||
--padding-bottom: 0px;
|
||||
}
|
||||
@ -0,0 +1,81 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX } from "react";
|
||||
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import {
|
||||
type Room,
|
||||
type RoomListItemViewActions,
|
||||
type RoomListItemViewSnapshot,
|
||||
} from "../RoomListItemWrapper/RoomListItemView";
|
||||
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
|
||||
import { useMockedViewModel } from "../../../core/viewmodel";
|
||||
import { withViewDocs } from "../../../../.storybook/withViewDocs";
|
||||
import { defaultSnapshot } from "../RoomListItemWrapper/RoomListItemView/default-snapshot";
|
||||
import { mockedActions } from "../RoomListItemWrapper/RoomListItemView/mocked-actions";
|
||||
import { renderAvatar } from "../../story-mocks";
|
||||
|
||||
type RoomListItemDragOverlayProps = RoomListItemViewSnapshot &
|
||||
RoomListItemViewActions & {
|
||||
renderAvatar: (room: Room) => React.ReactElement;
|
||||
};
|
||||
|
||||
const RoomListItemDragOverlayWrapperImpl = ({
|
||||
onOpenRoom,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onToggleFavorite,
|
||||
onToggleLowPriority,
|
||||
onInvite,
|
||||
onCopyRoomLink,
|
||||
onLeaveRoom,
|
||||
onSetRoomNotifState,
|
||||
onCreateSection,
|
||||
onToggleSection,
|
||||
renderAvatar: renderAvatarProp,
|
||||
...rest
|
||||
}: RoomListItemDragOverlayProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onOpenRoom,
|
||||
onMarkAsRead,
|
||||
onMarkAsUnread,
|
||||
onToggleFavorite,
|
||||
onToggleLowPriority,
|
||||
onInvite,
|
||||
onCopyRoomLink,
|
||||
onLeaveRoom,
|
||||
onSetRoomNotifState,
|
||||
onCreateSection,
|
||||
onToggleSection,
|
||||
});
|
||||
return <RoomListItemDragOverlayView vm={vm} renderAvatar={renderAvatarProp} />;
|
||||
};
|
||||
const RoomListItemDragOverlayWrapper = withViewDocs(RoomListItemDragOverlayWrapperImpl, RoomListItemDragOverlayView);
|
||||
|
||||
const meta = {
|
||||
title: "Room List/RoomListItemDragOverlayView",
|
||||
component: RoomListItemDragOverlayWrapper,
|
||||
tags: ["autodocs"],
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div style={{ width: "320px", padding: "8px" }}>
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
...defaultSnapshot,
|
||||
...mockedActions,
|
||||
renderAvatar,
|
||||
},
|
||||
} satisfies Meta<typeof RoomListItemDragOverlayWrapper>;
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const Default: Story = {};
|
||||
@ -0,0 +1,23 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
|
||||
import * as stories from "./RoomListItemDragOverlayView.stories";
|
||||
import { defaultSnapshot } from "../RoomListItemWrapper/RoomListItemView/default-snapshot";
|
||||
|
||||
const { Default } = composeStories(stories);
|
||||
|
||||
describe("<RoomListItemDragOverlayView />", () => {
|
||||
it("renders the room name from the view model", () => {
|
||||
render(<Default />);
|
||||
expect(screen.getByTestId("room-name")).toHaveTextContent(defaultSnapshot.name);
|
||||
});
|
||||
});
|
||||
@ -0,0 +1,46 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, memo, type ReactNode } from "react";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Flex } from "../../../core/utils/Flex";
|
||||
import { type Room, RoomListItemContent, type RoomListItemViewModel } from "../RoomListItemWrapper/RoomListItemView";
|
||||
import roomListItemStyles from "../RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css";
|
||||
import styles from "./RoomListItemDragOverlayView.module.css";
|
||||
|
||||
/**
|
||||
* Props for {@link RoomListItemDragOverlayView}.
|
||||
*/
|
||||
export interface RoomListItemDragOverlayViewProps {
|
||||
/** The room item view model — same one used by the real list item */
|
||||
vm: RoomListItemViewModel;
|
||||
/** Function to render the room avatar */
|
||||
renderAvatar: (room: Room) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Visual clone of a room list item rendered inside the dnd drag overlay.
|
||||
*
|
||||
* Reuses {@link RoomListItemContent} for the inner layout and adds the outer
|
||||
* wrapper styles that the live list item normally provides (height, width,
|
||||
* typography), so the floating clone matches a real item.
|
||||
*/
|
||||
export const RoomListItemDragOverlayView = memo(function RoomListItemDragOverlayView({
|
||||
vm,
|
||||
renderAvatar,
|
||||
}: RoomListItemDragOverlayViewProps): JSX.Element {
|
||||
return (
|
||||
<Flex
|
||||
className={classNames(roomListItemStyles.roomListItem, styles.dragOverlay)}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="stretch"
|
||||
>
|
||||
<RoomListItemContent vm={vm} renderAvatar={renderAvatar} isDragging={true} />
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@ -0,0 +1,9 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations 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 { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
|
||||
export type { RoomListItemDragOverlayViewProps } from "./RoomListItemDragOverlayView";
|
||||
@ -0,0 +1,79 @@
|
||||
/*
|
||||
* Copyright 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, memo, type ReactNode } from "react";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import classNames from "classnames";
|
||||
|
||||
import { Flex } from "../../../../core/utils/Flex";
|
||||
import { useViewModel } from "../../../../core/viewmodel";
|
||||
import { NotificationDecoration } from "./NotificationDecoration";
|
||||
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
|
||||
import { type Room, type RoomListItemViewModel } from "./RoomListItemView";
|
||||
import styles from "./RoomListItemView.module.css";
|
||||
|
||||
/**
|
||||
* Props for {@link RoomListItemContent}.
|
||||
*/
|
||||
export interface RoomListItemContentProps {
|
||||
/** The room item view model */
|
||||
vm: RoomListItemViewModel;
|
||||
/** Function to render the room avatar */
|
||||
renderAvatar: (room: Room) => ReactNode;
|
||||
/** Whether the item is being dragged */
|
||||
isDragging?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* The inner content of a room list item: avatar, room name, message preview,
|
||||
* hover menu and notification decoration. Used both inside the full
|
||||
* {@link RoomListItemView} and inside the drag overlay.
|
||||
*/
|
||||
export const RoomListItemContent = memo(function RoomListItemContent({
|
||||
vm,
|
||||
renderAvatar,
|
||||
isDragging = false,
|
||||
}: RoomListItemContentProps): JSX.Element {
|
||||
const item = useViewModel(vm);
|
||||
|
||||
return (
|
||||
<Flex
|
||||
className={classNames(styles.container, {
|
||||
[styles.dragging]: isDragging,
|
||||
})}
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="center"
|
||||
>
|
||||
{renderAvatar(item.room)}
|
||||
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<div className={styles.ellipsis}>
|
||||
<div className={styles.roomName} title={item.name} data-testid="room-name">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.messagePreview && (
|
||||
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
|
||||
{item.messagePreview}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{!isDragging && (item.showMoreOptionsMenu || item.showNotificationMenu) && (
|
||||
<RoomListItemHoverMenu
|
||||
showMoreOptionsMenu={item.showMoreOptionsMenu}
|
||||
showNotificationMenu={item.showNotificationMenu}
|
||||
vm={vm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...item.notification} />
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
);
|
||||
});
|
||||
@ -70,6 +70,11 @@
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.dragging {
|
||||
outline: 1px solid var(--cpd-color-border-interactive-hovered);
|
||||
background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 90%, transparent);
|
||||
}
|
||||
|
||||
.content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@ -5,14 +5,14 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type JSX, memo, useEffect, useRef, type ReactNode } from "react";
|
||||
import React, { type JSX, memo, useEffect, useRef, type ReactNode, type Ref } from "react";
|
||||
import classNames from "classnames";
|
||||
import { Text } from "@vector-im/compound-web";
|
||||
import { useMergeRefs } from "react-merge-refs";
|
||||
|
||||
import { Flex } from "../../../../core/utils/Flex";
|
||||
import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration";
|
||||
import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu";
|
||||
import { type NotificationDecorationData } from "./NotificationDecoration";
|
||||
import { RoomListItemContextMenu } from "./RoomListItemContextMenu";
|
||||
import { RoomListItemContent } from "./RoomListItemContent";
|
||||
import { type RoomNotifState } from "./RoomNotifs";
|
||||
import styles from "./RoomListItemView.module.css";
|
||||
import { useViewModel, type ViewModel } from "../../../../core/viewmodel";
|
||||
@ -150,6 +150,7 @@ export interface RoomListItemViewProps extends Omit<React.HTMLAttributes<HTMLBut
|
||||
isLastItem: boolean;
|
||||
/** Function to render the room avatar */
|
||||
renderAvatar: (room: Room) => ReactNode;
|
||||
ref?: Ref<Element>;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -164,14 +165,16 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
isFirstItem,
|
||||
isLastItem,
|
||||
renderAvatar,
|
||||
ref,
|
||||
...props
|
||||
}: RoomListItemViewProps): JSX.Element {
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
const internalRef = useRef<HTMLButtonElement>(null);
|
||||
const mergedRef = useMergeRefs([ref, internalRef]);
|
||||
const item = useViewModel(vm);
|
||||
|
||||
useEffect(() => {
|
||||
if (isFocused) {
|
||||
ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions);
|
||||
internalRef.current?.focus({ preventScroll: true } as FocusOptions);
|
||||
}
|
||||
}, [isFocused]);
|
||||
|
||||
@ -182,7 +185,7 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
<RoomListItemContextMenu vm={vm}>
|
||||
<Flex
|
||||
as="button"
|
||||
ref={ref}
|
||||
ref={mergedRef}
|
||||
className={classNames(styles.roomListItem, "mx_RoomListItemView", {
|
||||
[styles.selected]: isSelected,
|
||||
[styles.bold]: item.isBold,
|
||||
@ -193,41 +196,14 @@ export const RoomListItemView = memo(function RoomListItemView({
|
||||
gap="var(--cpd-space-3x)"
|
||||
align="stretch"
|
||||
type="button"
|
||||
aria-selected={isSelected}
|
||||
aria-label={a11yLabel}
|
||||
onClick={vm.onOpenRoom}
|
||||
onFocus={(e: React.FocusEvent<HTMLButtonElement>) => onFocus(item.id, e)}
|
||||
tabIndex={isFocused ? 0 : -1}
|
||||
aria-selected={props.role === "option" ? isSelected : undefined}
|
||||
{...props}
|
||||
>
|
||||
<Flex className={styles.container} gap="var(--cpd-space-3x)" align="center">
|
||||
{renderAvatar(item.room)}
|
||||
<Flex className={styles.content} gap="var(--cpd-space-2x)" align="center" justify="space-between">
|
||||
{/* We truncate the room name when too long. Title here is to show the full name on hover */}
|
||||
<div className={styles.ellipsis}>
|
||||
<div className={styles.roomName} title={item.name} data-testid="room-name">
|
||||
{item.name}
|
||||
</div>
|
||||
{item.messagePreview && (
|
||||
<Text as="div" size="sm" className={styles.ellipsis} title={item.messagePreview}>
|
||||
{item.messagePreview}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
{(item.showMoreOptionsMenu || item.showNotificationMenu) && (
|
||||
<RoomListItemHoverMenu
|
||||
showMoreOptionsMenu={item.showMoreOptionsMenu}
|
||||
showNotificationMenu={item.showNotificationMenu}
|
||||
vm={vm}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* aria-hidden because we summarise the unread count/notification status in a11yLabel */}
|
||||
<div className={styles.notificationDecoration} aria-hidden={true}>
|
||||
<NotificationDecoration {...item.notification} />
|
||||
</div>
|
||||
</Flex>
|
||||
</Flex>
|
||||
<RoomListItemContent vm={vm} renderAvatar={renderAvatar} />
|
||||
</Flex>
|
||||
</RoomListItemContextMenu>
|
||||
);
|
||||
|
||||
@ -14,6 +14,8 @@ export type {
|
||||
RoomListItemViewProps,
|
||||
Section,
|
||||
} from "./RoomListItemView";
|
||||
export { RoomListItemContent } from "./RoomListItemContent";
|
||||
export type { RoomListItemContentProps } from "./RoomListItemContent";
|
||||
export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu";
|
||||
export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu";
|
||||
export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu";
|
||||
|
||||
@ -6,9 +6,14 @@
|
||||
*/
|
||||
|
||||
import React, { memo, type JSX } from "react";
|
||||
import { useDraggable } from "@dnd-kit/react";
|
||||
import { Feedback } from "@dnd-kit/dom";
|
||||
import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers";
|
||||
import { useMergeRefs } from "react-merge-refs";
|
||||
|
||||
import { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView";
|
||||
import { getItemAccessibleProps } from "../../../core/VirtualizedList";
|
||||
import { useViewModel } from "../../../core/viewmodel";
|
||||
|
||||
export interface RoomListItemWrapperProps extends RoomListItemViewProps {
|
||||
/** Index of this room in the list */
|
||||
@ -22,19 +27,8 @@ export interface RoomListItemWrapperProps extends RoomListItemViewProps {
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around RoomListItemView that adds accessibility props based on the room's position in the list and whether the list is flat or grouped.
|
||||
* In a flat list, each item gets listbox item props. In a grouped list, each item gets treegrid cell props.
|
||||
*
|
||||
* @example
|
||||
* ``
|
||||
* <RoomListItemWrapper
|
||||
* roomIndex={0}
|
||||
* roomIndexInSection={0}
|
||||
* roomCount={10}
|
||||
* isInFlatList={true}
|
||||
* {...otherRoomListItemViewProps}
|
||||
* />
|
||||
* ```
|
||||
* Wraps RoomListItemView with the correct accessibility and drag-and-drop props
|
||||
* based on whether the list is flat (listbox) or grouped (treegrid).
|
||||
*/
|
||||
export const RoomListItemWrapper = memo(function RoomListItemWrapper({
|
||||
roomIndex,
|
||||
@ -43,9 +37,30 @@ export const RoomListItemWrapper = memo(function RoomListItemWrapper({
|
||||
isInFlatList,
|
||||
...rest
|
||||
}: RoomListItemWrapperProps): JSX.Element {
|
||||
const itemA11yProps = isInFlatList ? getItemAccessibleProps("listbox", roomIndex, roomCount) : { role: "gridcell" };
|
||||
const item = <RoomListItemView {...rest} {...itemA11yProps} />;
|
||||
if (isInFlatList) {
|
||||
return <RoomListItemView {...rest} {...getItemAccessibleProps("listbox", roomIndex, roomCount)} />;
|
||||
}
|
||||
|
||||
if (isInFlatList) return item;
|
||||
return <div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>{item}</div>;
|
||||
return (
|
||||
<div {...getItemAccessibleProps("treegrid", roomIndex, roomIndexInSection)}>
|
||||
<div role="gridcell" aria-selected={rest.isSelected}>
|
||||
<DraggableWrapper {...rest} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* Wraps RoomListItemView with the drag-and-drop functionality. This is only used for treegrid mode, as flat list items are not draggable.
|
||||
*/
|
||||
function DraggableWrapper(props: RoomListItemViewProps): JSX.Element {
|
||||
const item = useViewModel(props.vm);
|
||||
const { ref: draggableRef, handleRef } = useDraggable({
|
||||
id: item.id,
|
||||
// We clone the item in the dnd overlay to avoid putting a hole in the list
|
||||
plugins: [Feedback.configure({ feedback: "clone" })],
|
||||
modifiers: [RestrictToVerticalAxis],
|
||||
});
|
||||
const dndRef = useMergeRefs([draggableRef, handleRef]);
|
||||
return <RoomListItemView {...props} ref={dndRef} />;
|
||||
}
|
||||
|
||||
@ -87,6 +87,10 @@
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.dropTarget {
|
||||
box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary);
|
||||
}
|
||||
|
||||
.menu {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@ -10,6 +10,7 @@ import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons
|
||||
import classNames from "classnames";
|
||||
import { IconButton, Menu, MenuItem } from "@vector-im/compound-web";
|
||||
import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { useDroppable } from "@dnd-kit/react";
|
||||
|
||||
import { useViewModel, type ViewModel } from "../../../core/viewmodel";
|
||||
import styles from "./RoomListSectionHeaderView.module.css";
|
||||
@ -103,12 +104,17 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm);
|
||||
const isLastSection = sectionIndex === sectionCount - 1;
|
||||
|
||||
const { ref, isDropTarget } = useDroppable({
|
||||
id,
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-expanded={isExpanded}
|
||||
{...getGroupHeaderAccessibleProps(indexInList, sectionIndex, roomCountInSection)}
|
||||
>
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
role="gridcell"
|
||||
className={classNames(styles.header, {
|
||||
@ -127,7 +133,14 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView
|
||||
: _t("room_list|section_header|toggle", { section: title })
|
||||
}
|
||||
>
|
||||
<Flex className={styles.container} align="center" justify="space-between" gap="var(--cpd-space-2x)">
|
||||
<Flex
|
||||
className={classNames(styles.container, {
|
||||
[styles.dropTarget]: isDropTarget,
|
||||
})}
|
||||
align="center"
|
||||
justify="space-between"
|
||||
gap="var(--cpd-space-2x)"
|
||||
>
|
||||
<Flex align="center" gap="var(--cpd-space-0-5x)">
|
||||
<ChevronRightIcon
|
||||
className={styles.chevron}
|
||||
|
||||
@ -36,6 +36,7 @@ const RoomListWrapperImpl = ({
|
||||
updateVisibleRooms,
|
||||
closeToast,
|
||||
renderAvatar: renderAvatarProp,
|
||||
changeRoomSection,
|
||||
...rest
|
||||
}: RoomListStoryProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
@ -46,6 +47,7 @@ const RoomListWrapperImpl = ({
|
||||
getSectionHeaderViewModel,
|
||||
updateVisibleRooms,
|
||||
closeToast,
|
||||
changeRoomSection,
|
||||
});
|
||||
|
||||
return (
|
||||
@ -85,6 +87,7 @@ const meta = {
|
||||
renderAvatar,
|
||||
isFlatList: true,
|
||||
closeToast: fn(),
|
||||
changeRoomSection: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
|
||||
@ -6,10 +6,11 @@
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render, screen, fireEvent } from "@test-utils";
|
||||
import { render, screen, fireEvent, waitFor } from "@test-utils";
|
||||
import { VirtuosoMockContext } from "react-virtuoso";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { describe, it, expect, beforeEach } from "vitest";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
import * as stories from "./VirtualizedRoomListView.stories";
|
||||
|
||||
@ -65,6 +66,38 @@ describe("<VirtualizedRoomListView />", () => {
|
||||
expect(Default.args.updateVisibleRooms).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
describe("drag and drop", () => {
|
||||
beforeEach(() => {
|
||||
// Storybook fn() spies are shared across tests; vi.clearAllMocks() may not
|
||||
// reach them, so explicitly reset call history for the spy under test.
|
||||
(Sections.args.changeRoomSection as any).mockClear?.();
|
||||
});
|
||||
|
||||
it("should call changeRoomSection when drag ends successfully", async () => {
|
||||
// KeyboardSensor: Space=start, ArrowDown moves position 10px/press, Space=drop.
|
||||
// "General" (room 0) center is ~78px below the container top; "chats" section
|
||||
// header starts ~130px below that. 15 presses × 10px = 150px → drag position
|
||||
// enters the "chats" header area, making it the active droppable target.
|
||||
const user = userEvent.setup();
|
||||
renderWithMockContext(<Sections />);
|
||||
|
||||
const roomButton = await screen.findByRole("button", { name: "Open room General" });
|
||||
roomButton.focus();
|
||||
|
||||
await user.keyboard(" "); // start drag
|
||||
|
||||
for (let i = 0; i < 15; i++) {
|
||||
await user.keyboard("{ArrowDown}"); // move down 10px per press
|
||||
}
|
||||
|
||||
await user.keyboard(" "); // drop onto current target
|
||||
|
||||
await waitFor(() => {
|
||||
expect(Sections.args.changeRoomSection).toHaveBeenCalledWith("!room0:server", "low-priority");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("scrollToSectionTag", () => {
|
||||
it("skips scroll when scrollToSectionTag does not match any section", () => {
|
||||
const roomListState = {
|
||||
|
||||
@ -8,6 +8,8 @@
|
||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, type JSX, type ReactNode } from "react";
|
||||
import { type ScrollIntoViewLocation, type VirtuosoHandle } from "react-virtuoso";
|
||||
import { isEqual } from "lodash";
|
||||
import { DragDropProvider, DragOverlay, useDragOperation } from "@dnd-kit/react";
|
||||
import { KeyboardSensor, PointerActivationConstraints, PointerSensor } from "@dnd-kit/dom";
|
||||
|
||||
import { type Room } from "./RoomListItemWrapper/RoomListItemView";
|
||||
import { useViewModel } from "../../core/viewmodel";
|
||||
@ -18,9 +20,10 @@ import {
|
||||
type VirtualizedListContext,
|
||||
} from "../../core/VirtualizedList";
|
||||
import type { RoomListViewSnapshot, RoomListViewModel } from "../RoomListView";
|
||||
import { GroupedVirtualizedList } from "../../core/VirtualizedList";
|
||||
import { GroupedVirtualizedList, type GroupedVirtualizedListProps } from "../../core/VirtualizedList";
|
||||
import { RoomListSectionHeaderView } from "./RoomListSectionHeaderView";
|
||||
import { RoomListItemWrapper } from "./RoomListItemWrapper";
|
||||
import { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView";
|
||||
import styles from "./VirtualizedRoomListView.module.css";
|
||||
|
||||
/**
|
||||
@ -383,15 +386,78 @@ export function VirtualizedRoomListView({ vm, renderAvatar, onKeyDown }: Virtual
|
||||
}
|
||||
|
||||
return (
|
||||
<GroupedVirtualizedList<string, string, Context>
|
||||
{...commonProps}
|
||||
{...getContainerAccessibleProps("treegrid", totalCount)}
|
||||
scrollHandleRef={setVirtuosoHandle}
|
||||
groups={groups}
|
||||
getHeaderKey={getHeaderKey}
|
||||
getGroupHeaderComponent={getGroupHeaderComponent}
|
||||
getItemComponent={getItemComponentForGroupedList}
|
||||
isGroupHeaderFocusable={isGroupHeaderFocusable}
|
||||
/>
|
||||
<DragDropProvider
|
||||
onDragEnd={(event) => {
|
||||
if (event.canceled) return;
|
||||
const { target, source } = event.operation;
|
||||
if (!source || !target) return;
|
||||
|
||||
vm.changeRoomSection(source.id as string, target.id as string);
|
||||
}}
|
||||
sensors={[
|
||||
// By default, the PointerSensor activates dragging immediately on pointer down, which interferes with keyboard navigation.
|
||||
// So we start dragging after the pointer has moved by 5 pixels, to allow for click without dragging
|
||||
PointerSensor.configure({
|
||||
activationConstraints: [new PointerActivationConstraints.Distance({ value: 5 })],
|
||||
}),
|
||||
// By default, the KeyboardSensor uses both space and enter to start dragging, which interferes with the keyboard enter shortcut to open a room.
|
||||
KeyboardSensor.configure({
|
||||
keyboardCodes: {
|
||||
start: ["Space"],
|
||||
cancel: ["Escape"],
|
||||
end: ["Space"],
|
||||
up: ["ArrowUp"],
|
||||
down: ["ArrowDown"],
|
||||
left: ["ArrowLeft"],
|
||||
right: ["ArrowRight"],
|
||||
},
|
||||
}),
|
||||
]}
|
||||
>
|
||||
<DragOverlay dropAnimation={null}>
|
||||
<DragOverlayContent vm={vm} renderAvatar={renderAvatar} />
|
||||
</DragOverlay>
|
||||
<GroupedRoomList
|
||||
{...commonProps}
|
||||
{...getContainerAccessibleProps("treegrid", totalCount)}
|
||||
scrollHandleRef={setVirtuosoHandle}
|
||||
groups={groups}
|
||||
getHeaderKey={getHeaderKey}
|
||||
getGroupHeaderComponent={getGroupHeaderComponent}
|
||||
getItemComponent={getItemComponentForGroupedList}
|
||||
isGroupHeaderFocusable={isGroupHeaderFocusable}
|
||||
/>
|
||||
</DragDropProvider>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Inner component rendered inside DragDropProvider that renders the grouped virtualized list.
|
||||
* Uses useDragOperation to detect active keyboard drags and disable the list's own keyboard
|
||||
* navigation shortcuts while a drag is in progress, preventing unwanted list scrolling.
|
||||
*/
|
||||
function GroupedRoomList(props: GroupedVirtualizedListProps<string, string, Context>): JSX.Element {
|
||||
const { source } = useDragOperation();
|
||||
|
||||
return <GroupedVirtualizedList<string, string, Context> {...props} disableKeyboardNavigation={source !== null} />;
|
||||
}
|
||||
|
||||
interface DragOverlayContentProps {
|
||||
/** The room list view model */
|
||||
vm: RoomListViewModel;
|
||||
/** Function to render the room avatar */
|
||||
renderAvatar: (room: Room) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* Component rendered in the drag overlay when dragging a room item. Renders a copy of the dragged item to avoid dragging the actual element out of virtualization.
|
||||
*/
|
||||
function DragOverlayContent({ vm, renderAvatar }: DragOverlayContentProps): JSX.Element | null {
|
||||
const { source } = useDragOperation();
|
||||
if (!source) return null;
|
||||
|
||||
const itemVm = vm.getRoomItemViewModel(source.id as string);
|
||||
if (!itemVm) return null;
|
||||
|
||||
return <RoomListItemDragOverlayView vm={itemVm} renderAvatar={renderAvatar} />;
|
||||
}
|
||||
|
||||
@ -9,3 +9,4 @@ export { VirtualizedRoomListView } from "./VirtualizedRoomListView";
|
||||
export type { VirtualizedRoomListViewProps, RoomListViewState, FilterKey } from "./VirtualizedRoomListView";
|
||||
export * from "./RoomListSectionHeaderView";
|
||||
export * from "./RoomListItemWrapper";
|
||||
export * from "./RoomListItemDragOverlayView";
|
||||
|
||||
@ -112,6 +112,38 @@ describe("ImageBodyView", () => {
|
||||
expect(onLinkClick).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("merges supplied class names with module classes", () => {
|
||||
const vm = new TestImageBodyViewModel({
|
||||
state: ImageBodyViewState.READY,
|
||||
alt: "Custom class image",
|
||||
src: "https://example.org/full.png",
|
||||
thumbnailSrc: "https://example.org/thumb.png",
|
||||
maxWidth: 320,
|
||||
maxHeight: 240,
|
||||
aspectRatio: "4 / 3",
|
||||
});
|
||||
|
||||
const { container } = render(
|
||||
<ImageBodyView
|
||||
vm={vm}
|
||||
className="customRoot"
|
||||
containerClassName="customContainer"
|
||||
imageClassName="customImage"
|
||||
/>,
|
||||
);
|
||||
|
||||
const rootClassName = container.querySelector(".customRoot")?.className;
|
||||
const containerClassName = container.querySelector(".customContainer")?.className;
|
||||
const imageClassName = screen.getByRole("img", { name: "Custom class image" }).className;
|
||||
|
||||
expect(rootClassName).toContain("customRoot");
|
||||
expect(rootClassName).not.toBe("customRoot");
|
||||
expect(containerClassName).toContain("customContainer");
|
||||
expect(containerClassName).not.toBe("customContainer");
|
||||
expect(imageClassName).toContain("customImage");
|
||||
expect(imageClassName).not.toBe("customImage");
|
||||
});
|
||||
|
||||
it("swaps to the full source on hover for animated previews", async () => {
|
||||
const user = userEvent.setup();
|
||||
const vm = new TestImageBodyViewModel({
|
||||
|
||||
@ -12,6 +12,7 @@ import React, {
|
||||
type MouseEventHandler,
|
||||
type PropsWithChildren,
|
||||
type ReactEventHandler,
|
||||
type Ref,
|
||||
useState,
|
||||
} from "react";
|
||||
import classNames from "classnames";
|
||||
@ -148,6 +149,18 @@ interface ImageBodyViewProps {
|
||||
* Optional host CSS class.
|
||||
*/
|
||||
className?: string;
|
||||
/**
|
||||
* Optional CSS class applied to the media frame container.
|
||||
*/
|
||||
containerClassName?: string;
|
||||
/**
|
||||
* Optional CSS class applied to the rendered image element.
|
||||
*/
|
||||
imageClassName?: string;
|
||||
/**
|
||||
* Optional ref to the rendered image element.
|
||||
*/
|
||||
imageRef?: Ref<HTMLImageElement>;
|
||||
/**
|
||||
* Optional supplemental content rendered after the media frame.
|
||||
*/
|
||||
@ -202,7 +215,14 @@ function renderPlaceholder({
|
||||
* </ImageBodyView>
|
||||
* ```
|
||||
*/
|
||||
export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyViewProps>): JSX.Element {
|
||||
export function ImageBodyView({
|
||||
vm,
|
||||
className,
|
||||
containerClassName,
|
||||
imageClassName,
|
||||
imageRef,
|
||||
children,
|
||||
}: Readonly<ImageBodyViewProps>): JSX.Element {
|
||||
const { translate: _t } = useI18n();
|
||||
const {
|
||||
state,
|
||||
@ -230,6 +250,8 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
||||
const hoverOrFocus = hover || focus;
|
||||
|
||||
const rootClassName = classNames(className, styles.root);
|
||||
const resolvedContainerClassName = classNames(containerClassName, styles.thumbnailContainer);
|
||||
const resolvedImageClassName = classNames(imageClassName, styles.image);
|
||||
|
||||
if (state === ImageBodyViewState.ERROR) {
|
||||
return (
|
||||
@ -281,9 +303,10 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
||||
</div>
|
||||
) : resolvedImageSrc ? (
|
||||
<img
|
||||
className={styles.image}
|
||||
className={resolvedImageClassName}
|
||||
src={resolvedImageSrc}
|
||||
alt={alt}
|
||||
ref={imageRef}
|
||||
onError={vm.onImageError}
|
||||
onLoad={vm.onImageLoad}
|
||||
onMouseEnter={(): void => setHover(true)}
|
||||
@ -302,7 +325,7 @@ export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyVie
|
||||
) : null;
|
||||
|
||||
let frame = (
|
||||
<div className={styles.thumbnailContainer} style={containerStyle}>
|
||||
<div className={resolvedContainerClassName} style={containerStyle}>
|
||||
{showPlaceholder && (
|
||||
<div
|
||||
className={classNames(styles.placeholder, {
|
||||
|
||||
@ -255,14 +255,16 @@ export function TextualBodyView({
|
||||
[styles.annotatedInline]: kind === TextualBodyViewKind.EMOTE,
|
||||
});
|
||||
|
||||
// Reply quotes need to tweak this wrapper so long edited messages still clamp nicely.
|
||||
// Keep this hook stable so app CSS doesn't have to reach into CSS-module class names.
|
||||
renderedBody =
|
||||
kind === TextualBodyViewKind.EMOTE ? (
|
||||
<span dir="auto" className={annotatedClasses}>
|
||||
<span dir="auto" className={annotatedClasses} data-textual-body-annotation-wrapper="">
|
||||
{renderedBody}
|
||||
{markers}
|
||||
</span>
|
||||
) : (
|
||||
<div dir="auto" className={annotatedClasses}>
|
||||
<div dir="auto" className={annotatedClasses} data-textual-body-annotation-wrapper="">
|
||||
{renderedBody}
|
||||
{markers}
|
||||
</div>
|
||||
|
||||
@ -33,6 +33,7 @@ exports[`TextualBodyView > renders emote messages with annotations 1`] = `
|
||||
|
||||
<span
|
||||
class="TextualBody-module_annotated TextualBody-module_annotatedInline"
|
||||
data-textual-body-annotation-wrapper=""
|
||||
dir="auto"
|
||||
>
|
||||
<span>
|
||||
|
||||
@ -12,12 +12,12 @@ Please see LICENSE files in the repository root for full details.
|
||||
color: var(--cpd-color-text-secondary);
|
||||
font-size: var(--cpd-font-size-body-xs);
|
||||
width: 100%;
|
||||
overflow-x: auto;
|
||||
line-height: normal;
|
||||
}
|
||||
|
||||
.source {
|
||||
flex: 1;
|
||||
overflow-inline: auto;
|
||||
}
|
||||
|
||||
pre.source {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
*
|
||||
*/
|
||||
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { existsSync, readFileSync, renameSync, writeFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { defineConfig, esmExternalRequirePlugin, type Plugin } from "vite";
|
||||
@ -16,23 +16,31 @@ const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const cssLayerOrder = "@layer compound-tokens, compound-web, shared-components, app-web;";
|
||||
const sharedComponentsLayer = "shared-components";
|
||||
|
||||
const cssAssetFileName = "element-web-shared-components.css";
|
||||
|
||||
function layerCssAssets(): Plugin {
|
||||
return {
|
||||
name: "element-web-shared-components-css-layer",
|
||||
writeBundle(_options, bundle): void {
|
||||
for (const asset of Object.values(bundle)) {
|
||||
if (asset.type !== "asset" || asset.fileName !== "element-web-shared-components.css") {
|
||||
continue;
|
||||
}
|
||||
// Rename + layer-wrap the emitted CSS file. With multi-entry lib mode,
|
||||
// vite/rolldown derives CSS filenames from the unscoped package name (dropping
|
||||
// the `element-` prefix), so we rename on disk to keep the path stable for
|
||||
// consumers importing `@element-hq/web-shared-components/.../*.css`.
|
||||
writeBundle(options): void {
|
||||
const outDir = options.dir ?? resolve(__dirname, "dist");
|
||||
const expectedPath = resolve(outDir, cssAssetFileName);
|
||||
const renamedFromPath = resolve(outDir, "web-shared-components.css");
|
||||
|
||||
const cssPath = resolve(__dirname, "dist", asset.fileName);
|
||||
const source = readFileSync(cssPath, "utf-8");
|
||||
if (source.startsWith(cssLayerOrder)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
writeFileSync(cssPath, `${cssLayerOrder}\n@layer ${sharedComponentsLayer} {\n${source}\n}\n`);
|
||||
if (existsSync(renamedFromPath)) {
|
||||
renameSync(renamedFromPath, expectedPath);
|
||||
}
|
||||
|
||||
// No CSS emitted in this build (e.g. storybook's vite build doesn't produce
|
||||
// the library CSS bundle), or already renamed and layered on a prior pass.
|
||||
if (!existsSync(expectedPath)) return;
|
||||
|
||||
const source = readFileSync(expectedPath, "utf-8");
|
||||
if (source.startsWith(cssLayerOrder)) return;
|
||||
writeFileSync(expectedPath, `${cssLayerOrder}\n@layer ${sharedComponentsLayer} {\n${source}\n}\n`);
|
||||
},
|
||||
};
|
||||
}
|
||||
@ -40,10 +48,20 @@ function layerCssAssets(): Plugin {
|
||||
export default defineConfig({
|
||||
build: {
|
||||
lib: {
|
||||
entry: resolve(__dirname, "src/index.ts"),
|
||||
// Two entries: the main bundle and a standalone `numbers` utility that callers
|
||||
// running outside the browser DOM (e.g. AudioWorkletGlobalScope) can import without
|
||||
// pulling in the rest of the package — which transitively loads dnd-kit and
|
||||
// other window/document-dependent code.
|
||||
entry: {
|
||||
"element-web-shared-components": resolve(__dirname, "src/index.ts"),
|
||||
"numbers": resolve(__dirname, "src/core/utils/numbers.ts"),
|
||||
},
|
||||
name: "Element Web Shared Components",
|
||||
// the proper extensions will be added
|
||||
fileName: "element-web-shared-components",
|
||||
// Multi-entry mode needs both formats explicit; UMD doesn't support multi-entry
|
||||
// (single global), so we ship ES + CJS and use the `.umd.cjs` extension for CJS
|
||||
// to keep the existing package.json `require` paths working.
|
||||
formats: ["es", "cjs"],
|
||||
fileName: (format, entryName) => `${entryName}.${format === "es" ? "js" : "umd.cjs"}`,
|
||||
},
|
||||
outDir: "dist",
|
||||
rolldownOptions: {
|
||||
|
||||
39
patches/@dnd-kit__abstract.patch
Normal file
@ -0,0 +1,39 @@
|
||||
diff --git a/modifiers.d.ts b/modifiers.d.ts
|
||||
index 62bf3bccfc29ac73f50f613bb13489751745e77d..21f99a53589fc4a0b88f55f92803036c45160a9d 100644
|
||||
--- a/modifiers.d.ts
|
||||
+++ b/modifiers.d.ts
|
||||
@@ -36,7 +36,7 @@ declare class AxisModifier extends Modifier<DragDropManager<any, any>, Options$1
|
||||
* @param options - The axis restriction options
|
||||
* @returns A configured AxisModifier instance
|
||||
*/
|
||||
- static configure: (options: Options$1) => _dnd_kit_abstract.PluginDescriptor<any, any, typeof AxisModifier>;
|
||||
+ static configure: (options: Options$1) => _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
}
|
||||
/**
|
||||
* A pre-configured modifier that restricts movement to the vertical axis.
|
||||
@@ -44,14 +44,14 @@ declare class AxisModifier extends Modifier<DragDropManager<any, any>, Options$1
|
||||
* @remarks
|
||||
* This modifier fixes the x-axis value to 0, allowing only vertical movement.
|
||||
*/
|
||||
-declare const RestrictToVerticalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, typeof AxisModifier>;
|
||||
+declare const RestrictToVerticalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
/**
|
||||
* A pre-configured modifier that restricts movement to the horizontal axis.
|
||||
*
|
||||
* @remarks
|
||||
* This modifier fixes the y-axis value to 0, allowing only horizontal movement.
|
||||
*/
|
||||
-declare const RestrictToHorizontalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, typeof AxisModifier>;
|
||||
+declare const RestrictToHorizontalAxis: _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
|
||||
/**
|
||||
* Restricts a shape's movement to stay within a bounding rectangle.
|
||||
@@ -128,7 +128,7 @@ declare class SnapModifier extends Modifier<DragDropManager<any, any>, Options>
|
||||
* @param options - The snap grid options
|
||||
* @returns A configured SnapModifier instance
|
||||
*/
|
||||
- static configure: (options: Options) => _dnd_kit_abstract.PluginDescriptor<any, any, typeof SnapModifier>;
|
||||
+ static configure: (options: Options) => _dnd_kit_abstract.PluginDescriptor<any, any, any>;
|
||||
}
|
||||
|
||||
export { AxisModifier, RestrictToHorizontalAxis, RestrictToVerticalAxis, SnapModifier, restrictShapeToBoundingRectangle };
|
||||
102
pnpm-lock.yaml
generated
@ -71,6 +71,9 @@ overrides:
|
||||
packageExtensionsChecksum: sha256-EMEi1vcyzQthk7O/0AcntvnHgJaKCoFBlzp6iX/qNYk=
|
||||
|
||||
patchedDependencies:
|
||||
'@dnd-kit/abstract':
|
||||
hash: a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1
|
||||
path: patches/@dnd-kit__abstract.patch
|
||||
'@matrix-org/react-sdk-module-api':
|
||||
hash: 016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96
|
||||
path: patches/@matrix-org__react-sdk-module-api.patch
|
||||
@ -463,7 +466,7 @@ importers:
|
||||
version: 1.0.3
|
||||
matrix-js-sdk:
|
||||
specifier: github:matrix-org/matrix-js-sdk#develop
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32
|
||||
version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9
|
||||
matrix-widget-api:
|
||||
specifier: ^1.17.0
|
||||
version: 1.17.0
|
||||
@ -1022,6 +1025,15 @@ importers:
|
||||
|
||||
packages/shared-components:
|
||||
dependencies:
|
||||
'@dnd-kit/abstract':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/dom':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0
|
||||
'@dnd-kit/react':
|
||||
specifier: ^0.4.0
|
||||
version: 0.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||
'@element-hq/element-web-module-api':
|
||||
specifier: workspace:*
|
||||
version: link:../module-api
|
||||
@ -1530,7 +1542,6 @@ packages:
|
||||
'@babel/plugin-proposal-private-methods@7.18.6':
|
||||
resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead.
|
||||
peerDependencies:
|
||||
'@babel/core': ^7.0.0-0
|
||||
|
||||
@ -2445,6 +2456,27 @@ packages:
|
||||
resolution: {integrity: sha512-dDlz3W405VMFO4w5kIP9DOmELBcvFQGmLoKSdIRstBDubKFYwaNHV1NnlzMCQpXQFGWVALmeMORAuiLx18AvZQ==}
|
||||
engines: {node: '>=14.17.0'}
|
||||
|
||||
'@dnd-kit/abstract@0.4.0':
|
||||
resolution: {integrity: sha512-loEEJxKT5oLOLeRBJVTO9qpgvvW/Qq902xO20v1JMbpANuN/NLurUdpxIwNpVz+RtOSyzznnbc7lO7psmOhc9A==}
|
||||
|
||||
'@dnd-kit/collision@0.4.0':
|
||||
resolution: {integrity: sha512-oOHHUkH1h9Vl2m8TwLw/mPHA7Blf+s0PYcRoLNWNBVxDzugJKZo8WdpU58EMu9qkqyQGrR/YTOozGiMPhlqZ5Q==}
|
||||
|
||||
'@dnd-kit/dom@0.4.0':
|
||||
resolution: {integrity: sha512-mJDKt0BtlHXetZyrvZXh6++aycleIbYWH/OVC4nlszDh8NvW7q8dfsxFllR5RtLKLcykLaI4o545Figfks/HZQ==}
|
||||
|
||||
'@dnd-kit/geometry@0.4.0':
|
||||
resolution: {integrity: sha512-d1n+CU54V/qF/g792bmJK2oR4f5jOL7Pls2IfC+j9f5UBECpjsYbcPZ/krom/z8LgieqvMh1qrUkdcBjJJ7vpg==}
|
||||
|
||||
'@dnd-kit/react@0.4.0':
|
||||
resolution: {integrity: sha512-J2/N4CpQf98zJBZhMljDNsc+QR4VtUKU9BRO1+Di4OGaB1qafMC4qZ11xKXOkjw+d7h82FRSXmXCo0c8+VWaWg==}
|
||||
peerDependencies:
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
|
||||
'@dnd-kit/state@0.4.0':
|
||||
resolution: {integrity: sha512-vVdwOY9VsYdMNa7Z0xQhTXlzHqCcCugGuoM1kzvZhnZ0tYVPRdmIhWfeO6Y2ZoN92JwYAyJRRNl4ICkEe2mneg==}
|
||||
|
||||
'@docsearch/css@3.8.2':
|
||||
resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==}
|
||||
|
||||
@ -2822,7 +2854,6 @@ packages:
|
||||
'@humanwhocodes/config-array@0.13.0':
|
||||
resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==}
|
||||
engines: {node: '>=10.10.0'}
|
||||
deprecated: Use @eslint/config-array instead
|
||||
|
||||
'@humanwhocodes/module-importer@1.0.1':
|
||||
resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==}
|
||||
@ -2830,7 +2861,6 @@ packages:
|
||||
|
||||
'@humanwhocodes/object-schema@2.0.3':
|
||||
resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==}
|
||||
deprecated: Use @eslint/object-schema instead
|
||||
|
||||
'@iconify-json/simple-icons@1.2.75':
|
||||
resolution: {integrity: sha512-KvcCUbvcBWb0sbqLIxHoY8z5/piXY08wcY9gfMhF+ph3AfzGMaSmZFkUY71HSXAljQngXkgs4bdKdekO0HQWvg==}
|
||||
@ -4049,6 +4079,9 @@ packages:
|
||||
'@posthog/types@1.372.8':
|
||||
resolution: {integrity: sha512-ALpfCnWsMSM9Cw/6kyLPVpd81ZReEdZwmDxOi+DTJuIo7wDxBiu2cAsjOuA6D/AL22v7HOJrHsmBAPAWqS5X7Q==}
|
||||
|
||||
'@preact/signals-core@1.14.2':
|
||||
resolution: {integrity: sha512-RZHdBj9ZF4n40Rp4jS052EHHjBWf96P9oNdXPfhQTovCuWY9iQn3Gq+gOTJSgBO9A/JBuPfMOWsSX/lIU9Pc/A==}
|
||||
|
||||
'@principalstudio/html-webpack-inject-preload@1.2.7':
|
||||
resolution: {integrity: sha512-KJKkiKG63ugBjf8U0e9jUcI9CLPTFIsxXplEDE0oi3mPpxd90X9SJovo3W2l7yh/ARKIYXhQq8fSXUN7M29TzQ==}
|
||||
engines: {node: '>=10.23'}
|
||||
@ -5752,7 +5785,6 @@ packages:
|
||||
|
||||
'@ungap/structured-clone@1.3.0':
|
||||
resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==}
|
||||
deprecated: Potential CWE-502 - Update to 1.3.1 or higher
|
||||
|
||||
'@unrs/resolver-binding-android-arm-eabi@1.11.1':
|
||||
resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==}
|
||||
@ -6649,7 +6681,6 @@ packages:
|
||||
|
||||
boolean@3.2.0:
|
||||
resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==}
|
||||
deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
|
||||
|
||||
brace-expansion@1.1.13:
|
||||
resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==}
|
||||
@ -8183,7 +8214,6 @@ packages:
|
||||
eslint@8.57.1:
|
||||
resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==}
|
||||
engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0}
|
||||
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
hasBin: true
|
||||
|
||||
espree@10.4.0:
|
||||
@ -8611,7 +8641,6 @@ packages:
|
||||
|
||||
glob@10.5.0:
|
||||
resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
hasBin: true
|
||||
|
||||
glob@13.0.6:
|
||||
@ -8620,7 +8649,6 @@ packages:
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
global-agent@3.0.0:
|
||||
resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==}
|
||||
@ -8985,7 +9013,6 @@ packages:
|
||||
|
||||
inflight@1.0.6:
|
||||
resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==}
|
||||
deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.
|
||||
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
@ -9927,9 +9954,9 @@ packages:
|
||||
matrix-events-sdk@0.0.1:
|
||||
resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32:
|
||||
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32}
|
||||
version: 41.4.0
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9:
|
||||
resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9}
|
||||
version: 41.5.0
|
||||
engines: {node: '>=22.0.0'}
|
||||
|
||||
matrix-web-i18n@3.6.0:
|
||||
@ -11365,7 +11392,6 @@ packages:
|
||||
|
||||
react-beautiful-dnd@13.1.1:
|
||||
resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==}
|
||||
deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672'
|
||||
peerDependencies:
|
||||
react: ^16.8.5 || ^17.0.0 || ^18.0.0
|
||||
react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0
|
||||
@ -11701,12 +11727,10 @@ packages:
|
||||
|
||||
rimraf@2.6.3:
|
||||
resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rimraf@3.0.2:
|
||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
hasBin: true
|
||||
|
||||
rimraf@6.1.3:
|
||||
@ -12903,7 +12927,6 @@ packages:
|
||||
|
||||
uuid@10.0.0:
|
||||
resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@11.1.1:
|
||||
@ -12916,7 +12939,6 @@ packages:
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
v8-to-istanbul@9.3.0:
|
||||
@ -13270,7 +13292,6 @@ packages:
|
||||
whatwg-encoding@3.1.1:
|
||||
resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==}
|
||||
engines: {node: '>=18'}
|
||||
deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation
|
||||
|
||||
whatwg-mimetype@4.0.0:
|
||||
resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==}
|
||||
@ -14914,6 +14935,45 @@ snapshots:
|
||||
|
||||
'@discoveryjs/json-ext@1.0.0': {}
|
||||
|
||||
'@dnd-kit/abstract@0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)':
|
||||
dependencies:
|
||||
'@dnd-kit/geometry': 0.4.0
|
||||
'@dnd-kit/state': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/collision@0.4.0':
|
||||
dependencies:
|
||||
'@dnd-kit/abstract': 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/geometry': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/dom@0.4.0':
|
||||
dependencies:
|
||||
'@dnd-kit/abstract': 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/collision': 0.4.0
|
||||
'@dnd-kit/geometry': 0.4.0
|
||||
'@dnd-kit/state': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/geometry@0.4.0':
|
||||
dependencies:
|
||||
'@dnd-kit/state': 0.4.0
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/react@0.4.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)':
|
||||
dependencies:
|
||||
'@dnd-kit/abstract': 0.4.0(patch_hash=a4ddfb7b2d2d0b52c6709cead2e0feef065f2a17a516496679813344f326f9c1)
|
||||
'@dnd-kit/dom': 0.4.0
|
||||
'@dnd-kit/state': 0.4.0
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6(react@19.2.6)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@dnd-kit/state@0.4.0':
|
||||
dependencies:
|
||||
'@preact/signals-core': 1.14.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@docsearch/css@3.8.2': {}
|
||||
|
||||
'@docsearch/js@3.8.2(@algolia/client-search@5.50.0)(@types/react@19.2.14)(search-insights@2.17.3)':
|
||||
@ -16760,6 +16820,8 @@ snapshots:
|
||||
|
||||
'@posthog/types@1.372.8': {}
|
||||
|
||||
'@preact/signals-core@1.14.2': {}
|
||||
|
||||
'@principalstudio/html-webpack-inject-preload@1.2.7(html-webpack-plugin@5.6.7(webpack@5.106.2))(webpack@5.106.2)':
|
||||
dependencies:
|
||||
html-webpack-plugin: 5.6.7(webpack@5.106.2)
|
||||
@ -23486,7 +23548,7 @@ snapshots:
|
||||
|
||||
matrix-events-sdk@0.0.1: {}
|
||||
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32:
|
||||
matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9:
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.2
|
||||
'@matrix-org/matrix-sdk-crypto-wasm': 18.2.0
|
||||
|
||||