diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c53af59eb..2c8720d408 100644 --- a/CHANGELOG.md +++ b/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 diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 81f93a4947..8dcbbcdd12 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/web/package.json b/apps/web/package.json index 93c57ef6af..2a094d9ae6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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": { diff --git a/apps/web/playwright/e2e/composer/CIDER.spec.ts b/apps/web/playwright/e2e/composer/CIDER.spec.ts index 89f4cad276..2b2a241aeb 100644 --- a/apps/web/playwright/e2e/composer/CIDER.spec.ts +++ b/apps/web/playwright/e2e/composer/CIDER.spec.ts @@ -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(); }); }); }); diff --git a/apps/web/playwright/e2e/composer/RTE.spec.ts b/apps/web/playwright/e2e/composer/RTE.spec.ts index 2c5f8071ec..8227c7f9ae 100644 --- a/apps/web/playwright/e2e/composer/RTE.spec.ts +++ b/apps/web/playwright/e2e/composer/RTE.spec.ts @@ -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", () => { diff --git a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts index 67ca01bd09..2af553ed66 100644 --- a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts +++ b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts @@ -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(); }); }); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts index e40b1a6c0d..59cb322021 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-custom-sections.spec.ts @@ -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 { - 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(); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts index d8c12f55da..ad2a9dd848 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list-sections.spec.ts @@ -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 }) => { diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts index 523b268c80..fbf2643ebf 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/utils.ts @@ -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 { + 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 { + 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 diff --git a/apps/web/playwright/e2e/modules/custom-component.spec.ts b/apps/web/playwright/e2e/modules/custom-component.spec.ts index 5d2dc34aef..32615ea6b1 100644 --- a/apps/web/playwright/e2e/modules/custom-component.spec.ts +++ b/apps/web/playwright/e2e/modules/custom-component.spec.ts @@ -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", diff --git a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts index e89c10b20f..94d3c69544 100644 --- a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts +++ b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts @@ -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"); diff --git a/apps/web/playwright/e2e/timeline/timeline.spec.ts b/apps/web/playwright/e2e/timeline/timeline.spec.ts index a9a25bcbb4..75e1c4557d 100644 --- a/apps/web/playwright/e2e/timeline/timeline.spec.ts +++ b/apps/web/playwright/e2e/timeline/timeline.spec.ts @@ -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 = { diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png index 89cf0d7f64..ed06bfd54a 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-more-options-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png index d0025d1fed..3a3d125e36 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-linux.png differ diff --git a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png index 74b18c088b..6e13920c3e 100644 Binary files a/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png and b/apps/web/playwright/snapshots/left-panel/room-list-panel/room-list.spec.ts/room-list-item-open-notification-options-selection-linux.png differ diff --git a/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png b/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png index a7ff75f1cd..4e119e33db 100644 Binary files a/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png and b/apps/web/playwright/snapshots/pinned-messages/pinned-messages.spec.ts/pinned-message-Msg1-linux.png differ diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index a215241bbf..9615dc52f9 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -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"; diff --git a/apps/web/res/css/structures/_FilePanel.pcss b/apps/web/res/css/structures/_FilePanel.pcss index 615e2a32ae..3261c26a1c 100644 --- a/apps/web/res/css/structures/_FilePanel.pcss +++ b/apps/web/res/css/structures/_FilePanel.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; diff --git a/apps/web/res/css/views/messages/_MImageBody.pcss b/apps/web/res/css/views/messages/_MImageBody.pcss deleted file mode 100644 index 0e73c1d55c..0000000000 --- a/apps/web/res/css/views/messages/_MImageBody.pcss +++ /dev/null @@ -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; -} diff --git a/apps/web/res/css/views/messages/_MImageReplyBody.pcss b/apps/web/res/css/views/messages/_MImageReplyBody.pcss index 9576e75fd8..e8c2c4047d 100644 --- a/apps/web/res/css/views/messages/_MImageReplyBody.pcss +++ b/apps/web/res/css/views/messages/_MImageReplyBody.pcss @@ -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; diff --git a/apps/web/res/css/views/rooms/_EventBubbleTile.pcss b/apps/web/res/css/views/rooms/_EventBubbleTile.pcss index 6c4febd10f..18dac9547a 100644 --- a/apps/web/res/css/views/rooms/_EventBubbleTile.pcss +++ b/apps/web/res/css/views/rooms/_EventBubbleTile.pcss @@ -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 { diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index ffe0fcc13d..78c17bf1d8 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -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; diff --git a/apps/web/res/css/views/rooms/_ReplyTile.pcss b/apps/web/res/css/views/rooms/_ReplyTile.pcss index aea4bae626..d1c7e110f6 100644 --- a/apps/web/res/css/views/rooms/_ReplyTile.pcss +++ b/apps/web/res/css/views/rooms/_ReplyTile.pcss @@ -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] { diff --git a/apps/web/src/audio/RecorderWorklet.ts b/apps/web/src/audio/RecorderWorklet.ts index 5d6ce32630..53c99d2b8e 100644 --- a/apps/web/src/audio/RecorderWorklet.ts +++ b/apps/web/src/audio/RecorderWorklet.ts @@ -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"; diff --git a/apps/web/src/components/views/messages/MBodyFactory.tsx b/apps/web/src/components/views/messages/MBodyFactory.tsx index deb10cd822..6da7a95e4e 100644 --- a/apps/web/src/components/views/messages/MBodyFactory.tsx +++ b/apps/web/src/components/views/messages/MBodyFactory.tsx @@ -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; @@ -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(null); + const content = mxEvent.getContent(); + 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 ( + + ); + } + + return ( + + {showFileBody ? ( + + ) : null} + + ); +} + export function RedactedBodyFactory({ mxEvent, ref }: Pick): JSX.Element { const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent })); @@ -164,6 +284,7 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick([ + [MsgType.Image, ImageBodyFactory], [MsgType.File, FileBodyFactory], [MsgType.Video, VideoBodyFactory], ]); diff --git a/apps/web/src/components/views/messages/MImageBody.tsx b/apps/web/src/components/views/messages/MImageBody.tsx deleted file mode 100644 index 351a40e000..0000000000 --- a/apps/web/src/components/views/messages/MImageBody.tsx +++ /dev/null @@ -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 { - public static contextType = RoomContext; - declare public context: React.ContextType; - - private unmounted = false; - private image = createRef(); - private placeholder = createRef(); - 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(); - - 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, "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(); - 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 { - 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(); - 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): 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 ( - - {presentableTextForFile(content, _t("common|image"), true, true)} - - ); - } - - 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 = ( - - {_t("timeline|m.image|show_image")} - - ); - } else { - imageElement = ( - {content.body} - ); - } - 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 , - // 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 = ( -
- {this.getPlaceholder(maxWidth, maxHeight)} -
- ); - } - - 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 = ( - {content.body} - ); - } - - if (!this.props.mediaVisible) { - img = ( -
- - {_t("timeline|m.image|show_image")} - -
- ); - 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 =

GIF

; - } - - let banner: ReactNode | undefined; - if (this.props.mediaVisible && hoverOrFocus) { - banner = this.getBanner(content); - } - - // many SVGs don't have an intrinsic size if used in 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 = ( - - - { - showPlaceholder ? ( - placeholder - ) : ( -
- ) /* Transition always expects a child */ - } - - - ); - } - - const tooltipProps = this.getTooltipProps(); - let thumbnail = ( -
- {placeholder} - -
- {img} - {gifLabel} - {banner} -
- - {/* HACK: This div fills out space while the image loads, to prevent scroll jumps */} - {!this.props.forExport && !this.state.imgLoaded && !placeholder && ( -
- )} -
- ); - - 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 = ( - - {thumbnail} - - ); - } - - return this.wrapImage(contentUrl, thumbnail); - } - - // Overridden by MStickerBody - protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { - if (contentUrl) { - return ( - - {children} - - ); - } - 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 ; - } - } - return ; - } - - // Overridden by MStickerBody - protected getTooltipProps(): ComponentProps | 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(); - - // 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 ( - - {errorText} - - ); - } - - 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 ( -
- {thumbnail} - {fileBody} -
- ); - } -} - -// Wrap MImageBody component so we can use a hook here. -const MImageBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); - return ; -}; - -export default MImageBody; diff --git a/apps/web/src/components/views/messages/MImageReplyBody.tsx b/apps/web/src/components/views/messages/MImageReplyBody.tsx index 5f04df724d..87c6a9666c 100644 --- a/apps/web/src/components/views/messages/MImageReplyBody.tsx +++ b/apps/web/src/components/views/messages/MImageReplyBody.tsx @@ -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 { + public static contextType = RoomContext; + declare public context: React.ContextType; + + private unmounted = false; + private image = createRef(); + private placeholder = createRef(); + 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(); + + 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, "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(); + 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 { + 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(); + 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): 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 ( + + {presentableTextForFile(content, _t("common|image"), true, true)} + + ); + } + + 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 = ( + + {_t("timeline|m.image|show_image")} + + ); + } else { + imageElement = ( + {content.body} + ); + } + 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 = ( +
+ {this.getPlaceholder(maxWidth, maxHeight)} +
+ ); + } + + 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 = ( + {content.body} + ); + } + + if (!this.props.mediaVisible) { + img = ( +
+ + {_t("timeline|m.image|show_image")} + +
+ ); + showPlaceholder = false; + } + + if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) { + gifLabel =

GIF

; + } + + 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 = ( + + + {showPlaceholder ? placeholder :
} + + + ); + } + + const tooltipProps = this.getTooltipProps(); + let thumbnail = ( +
+ {placeholder} + +
+ {img} + {gifLabel} + {banner} +
+ + {!this.props.forExport && !this.state.imgLoaded && !placeholder && ( +
+ )} +
+ ); + + if (tooltipProps) { + thumbnail = ( + + {thumbnail} + + ); + } + + return this.wrapImage(contentUrl, thumbnail); + } + + protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { + if (contentUrl) { + return ( + + {children} + + ); + } + 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 ; + } + } + return ; + } + + protected getTooltipProps(): ComponentProps | 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(); + + 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 ( + + {errorText} + + ); + } + + 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 ( +
+ {thumbnail} + {fileBody} +
+ ); + } +} 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
{thumbnail}
; } } + const MImageReplyBody: React.FC = (props) => { const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); return ; diff --git a/apps/web/src/components/views/messages/MStickerBody.tsx b/apps/web/src/components/views/messages/MStickerBody.tsx index f0beea72aa..a9ae9ed92c 100644 --- a/apps/web/src/components/views/messages/MStickerBody.tsx +++ b/apps/web/src/components/views/messages/MStickerBody.tsx @@ -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(); diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 409a9e0f97..967b37e1a9 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -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>([ [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 implements IMe } if ( - ((BodyType === MImageBody || BodyType === VideoBodyFactory) && + ((BodyType === ImageBodyFactory || BodyType === VideoBodyFactory) && !this.validateImageOrVideoMimetype(content)) || (BodyType === MStickerBody && !this.validateStickerMimetype(content)) ) { diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 171cc38c0c..0f3ba769e7 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -338,6 +338,7 @@ export class UnwrappedEventTile extends React.Component private unmounted = false; private readonly id = uniqueId(); + private staleHoverCheckActive = false; public constructor(props: EventTileProps, context: React.ContextType) { super(props, context); @@ -472,6 +473,7 @@ export class UnwrappedEventTile extends React.Component } 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 } public componentDidUpdate(prevProps: Readonly, prevState: Readonly): 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 } 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 })); }; + 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): 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 "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 "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 "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, }, diff --git a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts index 60b86ddc81..a4a64344b1 100644 --- a/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts +++ b/apps/web/src/stores/room-list-v3/RoomListStoreV3.ts @@ -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; diff --git a/apps/web/src/stores/room-list-v3/section.ts b/apps/web/src/stores/room-list-v3/section.ts index 5cd4f97169..1fceba2c67 100644 --- a/apps/web/src/stores/room-list-v3/section.ts +++ b/apps/web/src/stores/room-list-v3/section.ts @@ -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. */ diff --git a/apps/web/src/utils/arrays.ts b/apps/web/src/utils/arrays.ts index 63b16a6f72..3056738d3c 100644 --- a/apps/web/src/utils/arrays.ts +++ b/apps/web/src/utils/arrays.ts @@ -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 diff --git a/apps/web/src/utils/room/getSectionTagForRoom.ts b/apps/web/src/utils/room/getSectionTagForRoom.ts new file mode 100644 index 0000000000..9dd59fd646 --- /dev/null +++ b/apps/web/src/utils/room/getSectionTagForRoom.ts @@ -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; +} diff --git a/apps/web/src/utils/room/tagRoom.ts b/apps/web/src/utils/room/tagRoom.ts index 62bac2ffca..4e0fb6625c 100644 --- a/apps/web/src/utils/room/tagRoom.ts +++ b/apps/web/src/utils/room/tagRoom.ts @@ -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)); } diff --git a/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts b/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts new file mode 100644 index 0000000000..664615e6f5 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts @@ -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; + /** + * 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 & { + "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 + 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().info?.[BLURHASH_FIELD] + ? ImageBodyViewPlaceholder.NONE + : ImageBodyViewPlaceholder.SPINNER, + imageSize: SettingsStore.getValue("Images.size") as ImageSize, + generatedThumbnailUrl: null, + }; + } + + private static getImageDimensions( + props: ImageBodyViewModelProps, + state: InternalState, + ): Pick { + const content = props.mxEvent.getContent(); + 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(); + 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().url ?? + props.mxEvent.getContent().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().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(); + 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 { + 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()).srcHttp; + thumbUrl = this.getThumbUrl(); + } + + const content = this.props.mxEvent.getContent(); + 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((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): 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(); + + 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, "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): 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(); + } +} diff --git a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts index b247ade51c..0959f962bc 100644 --- a/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListItemViewModel.ts @@ -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), diff --git a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts index a7b8d6dd2d..f20438cf9d 100644 --- a/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListSectionHeaderViewModel.ts @@ -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(); 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, diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 6e28e61ed7..9ffaa3ebae 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -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); + }; } /** diff --git a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx index c00a3573e2..155ad5d508 100644 --- a/apps/web/test/unit-tests/components/structures/RoomView-test.tsx +++ b/apps/web/test/unit-tests/components/structures/RoomView-test.tsx @@ -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 })); }); diff --git a/apps/web/test/unit-tests/components/views/elements/ReplyChain-test.tsx b/apps/web/test/unit-tests/components/views/elements/ReplyChain-test.tsx index 4f750869c6..78b0afd9f9 100644 --- a/apps/web/test/unit-tests/components/views/elements/ReplyChain-test.tsx +++ b/apps/web/test/unit-tests/components/views/elements/ReplyChain-test.tsx @@ -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( + , + 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]")); + }); }); diff --git a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx index 4a25bcb57f..aab11a84aa 100644 --- a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx @@ -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 = {}): 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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( + + + , + ); + + 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(); + + 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(); + + expect(container.querySelector(".mx_DecryptionFailureBody")).not.toBeNull(); + }); }); diff --git a/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx deleted file mode 100644 index 882e1fa5d8..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx +++ /dev/null @@ -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("", () => { - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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( - , - 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(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/MImageReplyBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MImageReplyBody-test.tsx new file mode 100644 index 0000000000..e8b955292d --- /dev/null +++ b/apps/web/test/unit-tests/components/views/messages/MImageReplyBody-test.tsx @@ -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("", () => { + 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; + } = {}): MatrixEvent => { + const { info: infoOverride, ...restContent } = content; + const info = + infoOverride === null + ? undefined + : { + w: 320, + h: 240, + size: 48_000, + mimetype: "image/jpeg", + ...(infoOverride as Record | 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) => ({ + 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; + sourceUrl?: string | null | Promise; + sourceBlob?: Blob | Promise; + } = {}): 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>; + } = {}) => { + const ref = createRef(); + const result = render( + + + , + ); + return { ...result, ref }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, "devicePixelRatio", { + configurable: true, + value: 1, + }); + mockedMediaFromContent.mockImplementation((content: Record) => 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(); + const mxEvent = createEvent(); + const { rerender } = render( + + + , + ); + + expect(ref.current!.state.contentUrl).toBeNull(); + + rerender( + + + , + ); + + 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(); + + 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"); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx index 476a1ba666..4f437b986d 100644 --- a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx @@ -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: () =>
, -})); - jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({ __esModule: true, DecryptionFailureBodyFactory: () =>
, FileBodyFactory: () =>
, + ImageBodyFactory: () =>
, RedactedBodyFactory: () =>
Message deleted by Moderator
, VideoBodyFactory: () =>
> renders LargeSectionList story 1`] = ` aria-rowindex="2" role="row" > - - +
- + + + +
+ +
+ - -
- + +
> renders LargeSectionList story 1`] = ` aria-rowindex="3" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="4" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="5" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="6" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="7" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="8" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="9" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="10" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="11" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="12" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="13" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="14" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="15" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="16" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="17" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="18" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="19" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="20" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="21" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="22" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="23" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="24" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="25" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="26" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="27" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="28" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="29" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="30" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="31" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="32" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="33" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="34" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="35" role="row" > - - +
+
+ + +
+ - - - + + @@ -13663,6 +13771,9 @@ exports[` > renders SmallSectionList story 1`] = ` class="Flex-module_flex RoomListView-module_list" style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" > +
> renders SmallSectionList story 1`] = ` aria-rowindex="1" role="row" > -
+
+ + +
+ -
- - + +
> renders SmallSectionList story 1`] = ` aria-rowindex="2" role="row" > - - +
- + + + + + + + - - - + +
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 ; +}; +const RoomListItemDragOverlayWrapper = withViewDocs(RoomListItemDragOverlayWrapperImpl, RoomListItemDragOverlayView); + +const meta = { + title: "Room List/RoomListItemDragOverlayView", + component: RoomListItemDragOverlayWrapper, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + ...defaultSnapshot, + ...mockedActions, + renderAvatar, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx new file mode 100644 index 0000000000..863fed8b13 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx @@ -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("", () => { + it("renders the room name from the view model", () => { + render(); + expect(screen.getByTestId("room-name")).toHaveTextContent(defaultSnapshot.name); + }); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx new file mode 100644 index 0000000000..a5ff9727d9 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx @@ -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 ( + + + + ); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts new file mode 100644 index 0000000000..0e0b212f74 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts @@ -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"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx new file mode 100644 index 0000000000..4843a3463f --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx @@ -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 ( + + {renderAvatar(item.room)} + + {/* We truncate the room name when too long. Title here is to show the full name on hover */} +
+
+ {item.name} +
+ {item.messagePreview && ( + + {item.messagePreview} + + )} +
+ {!isDragging && (item.showMoreOptionsMenu || item.showNotificationMenu) && ( + + )} + + {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} +
+ +
+
+
+ ); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css index 50b72d2429..fcb2e38ecc 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css @@ -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; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx index 363a25feb0..b043857425 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx @@ -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 ReactNode; + ref?: Ref; } /** @@ -164,14 +165,16 @@ export const RoomListItemView = memo(function RoomListItemView({ isFirstItem, isLastItem, renderAvatar, + ref, ...props }: RoomListItemViewProps): JSX.Element { - const ref = useRef(null); + const internalRef = useRef(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({ ) => onFocus(item.id, e)} tabIndex={isFocused ? 0 : -1} + aria-selected={props.role === "option" ? isSelected : undefined} {...props} > - - {renderAvatar(item.room)} - - {/* We truncate the room name when too long. Title here is to show the full name on hover */} -
-
- {item.name} -
- {item.messagePreview && ( - - {item.messagePreview} - - )} -
- {(item.showMoreOptionsMenu || item.showNotificationMenu) && ( - - )} - - {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} -
- -
-
-
+
); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts index 72f2f98119..9d9b6595d6 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts @@ -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"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx index 1165e7a0b7..c19209d574 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx @@ -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 - * `` - * - * ``` + * 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 = ; + if (isInFlatList) { + return ; + } - if (isInFlatList) return item; - return
{item}
; + return ( +
+
+ +
+
+ ); }); + +/** + * 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 ; +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css index d587c8014f..9bc9982c30 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css @@ -87,6 +87,10 @@ padding-bottom: 0; } +.dropTarget { + box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary); +} + .menu { display: none; } diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx index 50b92c8112..115f112d10 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx @@ -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 (