Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
187 changes: 187 additions & 0 deletions apps/web/playwright/e2e/media/multi-screenshot-composer.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
/*
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 { readFile } from "node:fs/promises";
import { basename } from "node:path";
import { rejectToastIfExists } from "@element-hq/element-web-playwright-common";
import { type Locator } from "@playwright/test";

import { test, expect } from "../../element-web-test";

const ROOM_NAME = "Multi-screenshot composer test";
const SHARED_CONTEXT = "These screenshots share one composer message";
const ASSET_DIR = "playwright/e2e/media/fixtures";
const MEDIA_BATCH_CONTENT_KEY = "io.element.media_batch";

type ImageFile = {
path: string;
type: string;
};

async function pasteImageFiles(composer: Locator, files: ImageFile[]): Promise<void> {
const payload = await Promise.all(
files.map(async (file) => ({
name: basename(file.path),
type: file.type,
base64: (await readFile(file.path)).toString("base64"),
})),
);

await composer.evaluate(async (element, payload) => {
const clipboardData = new DataTransfer();
for (const file of payload) {
clipboardData.items.add(
new File([Uint8Array.fromBase64(file.base64)], file.name, {
type: file.type,
}),
);
}
element.dispatchEvent(
new ClipboardEvent("paste", {
clipboardData,
bubbles: true,
cancelable: true,
}),
);
}, payload);
}

test.describe("multi-screenshot composer", () => {
test.use({
displayName: "Multi-screenshot Composer Test",
room: async ({ app, user: _user }, use) => {
const roomId = await app.client.createRoom({ name: ROOM_NAME });
await app.viewRoomByName(ROOM_NAME);
await use({ roomId });
},
});

test.beforeEach(async ({ app, room }) => {
await rejectToastIfExists(app.page, "Notifications");
await rejectToastIfExists(app.page, "Verify this device");
await app.viewRoomByName(ROOM_NAME);
await app.client.sendMessage(room!.roomId, "multi-screenshot composer room ready");
});

test("uses main composer text as shared context for pasted screenshots", async ({ page, app, room }) => {
const composer = page.getByRole("textbox", { name: "Send an unencrypted message…" });
const tray = page.getByTestId("pending-attachment-tray");

await pasteImageFiles(composer, [
{ path: `${ASSET_DIR}/screenshot-flow-step-1.png`, type: "image/png" },
{ path: `${ASSET_DIR}/screenshot-flow-step-2.png`, type: "image/png" },
]);

await expect(tray).toBeVisible();
await expect(tray.getByRole("img")).toHaveCount(2);
await expect(tray.locator("textarea, input")).toHaveCount(0);
await expect(page.locator(".mx_PendingAttachmentTray_caption")).toHaveCount(0);
await expect(page.locator(".mx_Dialog")).toHaveCount(0);

const firstTrayItem = tray.locator(".mx_PendingAttachmentTray_item").first();
const firstRemoveButton = firstTrayItem.getByRole("button", { name: "Remove screenshot-flow-step-1.png" });
await expect(firstRemoveButton).toHaveCSS("opacity", "0");
await expect(firstRemoveButton).toHaveCSS("pointer-events", "none");

await firstTrayItem.hover();
await expect(firstRemoveButton).toHaveCSS("opacity", "1");
await expect(firstRemoveButton).toHaveCSS("pointer-events", "auto");

await page.mouse.move(0, 0);
await firstRemoveButton.focus();
await expect(firstRemoveButton).toBeFocused();
await expect(firstRemoveButton).toHaveCSS("opacity", "1");

await composer.focus();
await page.mouse.move(0, 0);
await expect(firstRemoveButton).toHaveCSS("opacity", "0");

const trayBox = await tray.boundingBox();
expect(trayBox?.height).toBeLessThanOrEqual(180);
for (const image of await tray.locator(".mx_PendingAttachmentTray_thumbnailImage").all()) {
const imageBox = await image.boundingBox();
expect(imageBox?.width).toBeLessThanOrEqual(160);
expect(imageBox?.height).toBeLessThanOrEqual(120);
}

await composer.pressSequentially(SHARED_CONTEXT);
await expect(composer).toContainText(SHARED_CONTEXT);
await expect(page.locator(".mx_EventTile_body", { hasText: SHARED_CONTEXT })).toHaveCount(0);

await composer.press("Enter");

await expect(tray).toHaveCount(0);
await expect(page.getByTestId("media-batch-body").first()).toBeVisible({ timeout: 30000 });
await expect(page.getByTestId("media-batch-body")).toHaveCount(1);
await expect(page.getByTestId("media-batch-body").locator(".mx_MediaBatchBody_item")).toHaveCount(2);
await expect(page.locator(".mx_EventTile_body", { hasText: SHARED_CONTEXT }).first()).toBeVisible({
timeout: 30000,
});

await expect
.poll(
async () =>
app.client.evaluate(
(client, { roomId }) => {
const room = client.getRoom(roomId);
return (
room
?.getLiveTimeline()
.getEvents()
.filter(
(event) =>
event.getType() === "m.room.message" &&
event.getContent().msgtype === "m.image",
).length ?? 0
);
},
{ roomId: room!.roomId },
),
{ timeout: 30000 },
)
.toBe(2);

const finalEvents = await app.client.evaluate(
(client, { roomId }) => {
const room = client.getRoom(roomId);
return (
room
?.getLiveTimeline()
.getEvents()
.filter((event) => event.getType() === "m.room.message")
.map((event) => event.getContent()) ?? []
);
},
{ roomId: room!.roomId },
);
const imageEvents = finalEvents.filter((content: any) => content.msgtype === "m.image");
const duplicateTextEvents = finalEvents.filter(
(content: any) => content.msgtype === "m.text" && content.body === SHARED_CONTEXT,
);

expect(imageEvents).toHaveLength(2);
expect(duplicateTextEvents).toHaveLength(0);
expect(imageEvents[0]).toMatchObject({
msgtype: "m.image",
body: SHARED_CONTEXT,
filename: "screenshot-flow-step-1.png",
[MEDIA_BATCH_CONTENT_KEY]: {
index: 0,
count: 2,
},
});
expect(imageEvents[1]).toMatchObject({
msgtype: "m.image",
body: "screenshot-flow-step-2.png",
[MEDIA_BATCH_CONTENT_KEY]: {
index: 1,
count: 2,
},
});
expect(imageEvents[0][MEDIA_BATCH_CONTENT_KEY].id).toBe(imageEvents[1][MEDIA_BATCH_CONTENT_KEY].id);
});
});
88 changes: 88 additions & 0 deletions apps/web/res/css/views/dialogs/_UploadConfirmDialog.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,91 @@ Please see LICENSE files in the repository root for full details.
border-radius: 4px;
border: 1px solid $dialog-close-fg-color;
}

.mx_UploadConfirmDialog_thumbnailTray {
display: flex;
gap: var(--cpd-space-3x);
max-width: min(720px, 80vw);
overflow-x: auto;
padding: var(--cpd-space-1x) var(--cpd-space-1x) var(--cpd-space-3x);
text-align: start;
}

.mx_UploadConfirmDialog_thumbnailItem {
position: relative;
flex: 0 0 144px;
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-primary);
border-radius: 8px;
background: var(--cpd-color-bg-subtle-primary);
padding: var(--cpd-space-2x);
}

.mx_UploadConfirmDialog_thumbnailImage {
display: block;
width: 100%;
height: 96px;
object-fit: cover;
border-radius: 6px;
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-primary);
}

.mx_UploadConfirmDialog_thumbnailName {
overflow: hidden;
margin-top: var(--cpd-space-1x);
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-sm-regular);
text-overflow: ellipsis;
white-space: nowrap;
}

.mx_UploadConfirmDialog_removeThumbnail {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
width: 24px;
height: 24px;
border: 0;
border-radius: 999px;
color: var(--cpd-color-text-on-solid-primary);
background: var(--cpd-color-bg-action-primary-rest);
cursor: pointer;
line-height: 1;
transform: translate(35%, -35%);
}

.mx_UploadConfirmDialog_removeThumbnail:hover {
background: var(--cpd-color-bg-action-primary-hovered);
}

.mx_UploadConfirmDialog_caption {
display: flex;
flex-direction: column;
gap: var(--cpd-space-1x);
margin-top: var(--cpd-space-4x);
text-align: left;

label {
color: var(--cpd-color-text-primary);
}

textarea {
box-sizing: border-box;
width: 100%;
font: var(--cpd-font-body-md-regular);
color: var(--cpd-color-text-primary);
background: var(--cpd-color-bg-canvas-default);
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-primary);
border-radius: 0.5rem;
padding: var(--cpd-space-3x) var(--cpd-space-4x);
resize: vertical;
}

textarea::placeholder {
color: var(--cpd-color-text-secondary);
}

textarea:focus-visible {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: var(--cpd-border-width-1);
}
}
44 changes: 44 additions & 0 deletions apps/web/res/css/views/rooms/_EventTile.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,50 @@ $left-gutter: 64px;
}
}

.mx_MediaBatchBody {
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
max-width: min(560px, 100%);
}

.mx_MediaBatchBody_images {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(128px, 240px));
gap: var(--cpd-space-2x);
align-items: start;
max-width: 100%;
}

.mx_MediaBatchBody_item {
min-width: 0;

.mx_ImageBody {
width: fit-content;
max-width: 100%;
}

.mx_ImageBody_container {
justify-content: flex-start;
}

.mx_ImageBody_image {
max-width: min(240px, 100%);
max-height: 180px;
object-fit: contain;
}
}

.mx_MediaBatchBody[data-media-batch-count="1"] {
.mx_MediaBatchBody_images {
display: block;
}
}

.mx_MediaBatchBody .mx_EventTile_caption {
margin-block-start: 0;
}

.mx_DisambiguatedProfile {
color: $primary-content;
font: var(--cpd-font-body-md-regular);
Expand Down
Loading
Loading