diff --git a/apps/web/playwright/e2e/media/fixtures/screenshot-flow-step-1.png b/apps/web/playwright/e2e/media/fixtures/screenshot-flow-step-1.png new file mode 100644 index 00000000000..09bab4d3abe Binary files /dev/null and b/apps/web/playwright/e2e/media/fixtures/screenshot-flow-step-1.png differ diff --git a/apps/web/playwright/e2e/media/fixtures/screenshot-flow-step-2.png b/apps/web/playwright/e2e/media/fixtures/screenshot-flow-step-2.png new file mode 100644 index 00000000000..4fda73c964b Binary files /dev/null and b/apps/web/playwright/e2e/media/fixtures/screenshot-flow-step-2.png differ diff --git a/apps/web/playwright/e2e/media/multi-screenshot-composer.spec.ts b/apps/web/playwright/e2e/media/multi-screenshot-composer.spec.ts new file mode 100644 index 00000000000..166896ab01a --- /dev/null +++ b/apps/web/playwright/e2e/media/multi-screenshot-composer.spec.ts @@ -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 { + 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); + }); +}); diff --git a/apps/web/res/css/views/dialogs/_UploadConfirmDialog.pcss b/apps/web/res/css/views/dialogs/_UploadConfirmDialog.pcss index f50bf5e7d50..7b594324c18 100644 --- a/apps/web/res/css/views/dialogs/_UploadConfirmDialog.pcss +++ b/apps/web/res/css/views/dialogs/_UploadConfirmDialog.pcss @@ -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); + } +} diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index 78c17bf1d85..10dd118c042 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -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); diff --git a/apps/web/res/css/views/rooms/_MessageComposer.pcss b/apps/web/res/css/views/rooms/_MessageComposer.pcss index f31a51b53ad..55aab692211 100644 --- a/apps/web/res/css/views/rooms/_MessageComposer.pcss +++ b/apps/web/res/css/views/rooms/_MessageComposer.pcss @@ -45,6 +45,82 @@ Please see LICENSE files in the repository root for full details. width: 100%; } +.mx_PendingAttachmentTray_bounded { + display: flex; + flex-direction: row; + align-items: flex-start; + gap: var(--cpd-space-3x); + max-width: 100%; + max-height: 180px; + overflow-x: auto; + overflow-y: auto; + padding: var(--cpd-space-2x) 0; +} + +.mx_PendingAttachmentTray_item { + display: flex; + flex: 0 0 auto; + align-items: flex-start; + gap: var(--cpd-space-2x); + max-width: 320px; + + &:hover, + &:focus-within { + .mx_PendingAttachmentTray_removeButton { + opacity: 1; + pointer-events: auto; + } + } +} + +.mx_PendingAttachmentTray_thumbnailFrame { + display: flex; + position: relative; + align-items: center; + justify-content: center; + width: 160px; + height: 120px; + max-width: 160px; + max-height: 120px; + overflow: hidden; + border-radius: 8px; + background-color: $panel-actions; +} + +.mx_PendingAttachmentTray_thumbnailImage { + display: block; + max-width: 160px; + max-height: 120px; + object-fit: contain; +} + +.mx_PendingAttachmentTray_removeButton { + position: absolute; + z-index: 1; + top: 4px; + inset-inline-start: 4px; + display: flex; + align-items: center; + justify-content: center; + width: 24px; + height: 24px; + padding: 0; + border: 0; + border-radius: 50%; + background-color: rgb(0 0 0 / 60%); + color: var(--cpd-color-text-on-solid-primary); + cursor: pointer; + font: var(--cpd-font-body-md-semibold); + line-height: 1; + opacity: 0; + pointer-events: none; + + &:focus-visible { + opacity: 1; + pointer-events: auto; + } +} + .mx_MessageComposer_actions { display: flex; align-items: center; diff --git a/apps/web/src/ContentMessages.ts b/apps/web/src/ContentMessages.ts index 15a43d79b67..fa80b800438 100644 --- a/apps/web/src/ContentMessages.ts +++ b/apps/web/src/ContentMessages.ts @@ -56,11 +56,18 @@ import { createThumbnail } from "./utils/image-media"; import { attachMentions, attachRelation } from "./utils/messages.ts"; import { doMaybeLocalRoomAction } from "./utils/local-room"; import { blobIsAnimated } from "./utils/Image.ts"; +import { MEDIA_BATCH_CONTENT_KEY, type MediaBatchEventContent, type MediaBatchMetadata } from "./utils/MediaBatch"; // scraped out of a macOS hidpi (5660ppm) screenshot png // 5669 px (x-axis) , 5669 px (y-axis) , per metre const PHYS_HIDPI = [0x00, 0x00, 0x16, 0x25, 0x00, 0x00, 0x16, 0x25, 0x01]; +interface SendContentListToRoomOptions { + context?: TimelineRenderingType; + sharedCaption?: string; + skipConfirmation?: boolean; +} + export class UploadCanceledError extends Error {} export class UploadFailedError extends Error { public constructor(cause: any) { @@ -443,8 +450,13 @@ export default class ContentMessages { relation: IEventRelation | undefined, replyToEvent: MatrixEvent | undefined, matrixClient: MatrixClient, - context = TimelineRenderingType.Room, + contextOrOptions: TimelineRenderingType | SendContentListToRoomOptions = TimelineRenderingType.Room, ): Promise { + const options: SendContentListToRoomOptions = + typeof contextOrOptions === "object" ? contextOrOptions : { context: contextOrOptions }; + const context = options.context ?? TimelineRenderingType.Room; + const providedSharedCaption = options.sharedCaption?.trim() || undefined; + const skipConfirmation = options.skipConfirmation ?? false; if (matrixClient.isGuest()) { dis.dispatch({ action: "require_registration" }); return; @@ -484,24 +496,59 @@ export default class ContentMessages { } let uploadAll = false; + let filesToUpload = okFiles; + let sharedCaption: string | undefined; + const allImages = okFiles.length > 1 && okFiles.every((file) => file.type.startsWith("image/")); + + if (skipConfirmation) { + sharedCaption = providedSharedCaption; + uploadAll = true; + } else if (allImages) { + const { finished } = Modal.createDialog(UploadConfirmDialog, { + file: okFiles[0], + files: okFiles, + currentIndex: 0, + totalFiles: okFiles.length, + allowCaption: true, + }); + const [shouldContinue, , uploadCaption, selectedFiles] = await finished; + if (!shouldContinue) return; + filesToUpload = selectedFiles?.length ? selectedFiles : okFiles; + if (filesToUpload.length === 0) return; + sharedCaption = uploadCaption; + uploadAll = true; + } + // Promise to complete before sending next file into room, used for synchronisation of file-sending // to match the order the files were specified in + const mediaBatchId = + filesToUpload.length > 1 && filesToUpload.every((file) => file.type.startsWith("image/")) + ? crypto.randomUUID() + : undefined; let promBefore: Promise = Promise.resolve(); - for (let i = 0; i < okFiles.length; ++i) { - const file = okFiles[i]; + for (let i = 0; i < filesToUpload.length; ++i) { + const file = filesToUpload[i]; const loopPromiseBefore = promBefore; - - if (!uploadAll) { + const mediaBatch: MediaBatchMetadata | undefined = mediaBatchId + ? { id: mediaBatchId, index: i, count: filesToUpload.length } + : undefined; + let caption: string | undefined; + + if (sharedCaption && i === 0 && file.type.startsWith("image/")) { + caption = sharedCaption; + } else if (!uploadAll) { const { finished } = Modal.createDialog(UploadConfirmDialog, { file, currentIndex: i, - totalFiles: okFiles.length, + totalFiles: filesToUpload.length, + allowCaption: true, }); - const [shouldContinue, shouldUploadAll] = await finished; + const [shouldContinue, shouldUploadAll, uploadCaption] = await finished; if (!shouldContinue) break; if (shouldUploadAll) { uploadAll = true; } + caption = filesToUpload.length === 1 && file.type.startsWith("image/") ? uploadCaption : undefined; } promBefore = doMaybeLocalRoomAction( @@ -514,6 +561,8 @@ export default class ContentMessages { matrixClient, replyToEvent ?? undefined, loopPromiseBefore, + caption, + mediaBatch, ), matrixClient, ); @@ -560,15 +609,21 @@ export default class ContentMessages { matrixClient: MatrixClient, replyToEvent: MatrixEvent | undefined, promBefore?: Promise, + caption?: string, + mediaBatch?: MediaBatchMetadata, ): Promise { const fileName = file.name || _t("common|attachment"); + const trimmedCaption = caption?.trim(); const content: Omit & { info: Partial } = { - body: fileName, + body: trimmedCaption || fileName, info: { size: file.size, }, msgtype: MsgType.File, // set more specifically later }; + if (trimmedCaption) { + content.filename = fileName; + } // Attach mentions, which really only applies if there's a replyToEvent. attachMentions(matrixClient.getSafeUserId(), content, null, replyToEvent); @@ -646,6 +701,10 @@ export default class ContentMessages { if (upload.cancelled) throw new UploadCanceledError(); const threadId = relation?.rel_type === THREAD_RELATION_TYPE.name ? relation.event_id : null; + if (mediaBatch && content.msgtype === MsgType.Image) { + (content as MediaEventContent & MediaBatchEventContent)[MEDIA_BATCH_CONTENT_KEY] = mediaBatch; + } + const response = await matrixClient.sendMessage(roomId, threadId ?? null, content as MediaEventContent); if (SettingsStore.getValue("Performance.addSendMessageTimingMetadata")) { diff --git a/apps/web/src/components/structures/MessagePanel.tsx b/apps/web/src/components/structures/MessagePanel.tsx index 73136b16c21..4bbbef4331e 100644 --- a/apps/web/src/components/structures/MessagePanel.tsx +++ b/apps/web/src/components/structures/MessagePanel.tsx @@ -54,6 +54,7 @@ import { hasThreadSummary } from "../../utils/EventUtils"; import { type BaseGrouper } from "./grouper/BaseGrouper"; import { MainGrouper } from "./grouper/MainGrouper"; import { CreationGrouper } from "./grouper/CreationGrouper"; +import { MediaBatchGrouper } from "./grouper/MediaBatchGrouper"; import { _t } from "../../languageHandler"; import { getLateEventInfo } from "./grouper/LateEventGrouper"; import { DateSeparatorViewModel } from "../../viewmodels/room/timeline/DateSeparatorViewModel"; @@ -749,6 +750,7 @@ export default class MessagePanel extends React.Component { isGrouped = false, nextEvent: WrappedEvent | null = null, nextEventWithTile: MatrixEvent | null = null, + mediaBatchEvents?: MatrixEvent[], ): ReactNode[] { const mxEv = wrappedEvent.event; const ret: ReactNode[] = []; @@ -834,6 +836,7 @@ export default class MessagePanel extends React.Component { showReadReceipts={this.props.showReadReceipts} callEventGrouper={callEventGrouper} hideSender={this.state.hideSender} + mediaBatchEvents={mediaBatchEvents} />, ); @@ -1104,7 +1107,7 @@ export interface WrappedEvent { } // all the grouper classes that we use, ordered by priority -const groupers = [CreationGrouper, MainGrouper]; +const groupers = [CreationGrouper, MediaBatchGrouper, MainGrouper]; /** * Look through the supplied list of WrappedEvent, and return the first diff --git a/apps/web/src/components/structures/grouper/MediaBatchGrouper.tsx b/apps/web/src/components/structures/grouper/MediaBatchGrouper.tsx new file mode 100644 index 00000000000..e32695e296a --- /dev/null +++ b/apps/web/src/components/structures/grouper/MediaBatchGrouper.tsx @@ -0,0 +1,99 @@ +/* +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 ReactNode } from "react"; +import { type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { DateSeparatorView, useCreateAutoDisposedViewModel } from "@element-hq/web-shared-components"; + +import { BaseGrouper } from "./BaseGrouper"; +import type MessagePanel from "../MessagePanel"; +import { SeparatorKind, type WrappedEvent } from "../MessagePanel"; +import { DateSeparatorViewModel } from "../../../viewmodels/room/timeline/DateSeparatorViewModel"; +import { isImageMediaBatchEvent, sameMediaBatch } from "../../../utils/MediaBatch"; + +function DateSeparatorWrapper({ roomId, ts }: { roomId: string; ts: number }): ReactNode { + const vm = useCreateAutoDisposedViewModel(() => new DateSeparatorViewModel({ roomId, ts })); + return ; +} + +/** + * Render consecutive standard Matrix image events that share media-batch metadata + * as one visible timeline tile while preserving the underlying per-file events. + */ +export class MediaBatchGrouper extends BaseGrouper { + public static canStartGroup = function (_panel: MessagePanel, { event, shouldShow }: WrappedEvent): boolean { + return !!shouldShow && isImageMediaBatchEvent(event); + }; + + public constructor( + public readonly panel: MessagePanel, + public readonly firstEventAndShouldShow: WrappedEvent, + public readonly prevEvent: MatrixEvent | null, + public readonly lastShownEvent: MatrixEvent | undefined, + nextEvent: WrappedEvent | null, + nextEventTile: MatrixEvent | null, + ) { + super(panel, firstEventAndShouldShow, prevEvent, lastShownEvent, nextEvent, nextEventTile); + this.events = [firstEventAndShouldShow]; + } + + public shouldGroup({ event, shouldShow }: WrappedEvent): boolean { + if (!shouldShow) return false; + if (!isImageMediaBatchEvent(event)) return false; + if (!sameMediaBatch(this.firstEventAndShouldShow.event, event)) return false; + if (event.getSender() !== this.firstEventAndShouldShow.event.getSender()) return false; + if (event.getRoomId() !== this.firstEventAndShouldShow.event.getRoomId()) return false; + if (this.panel.wantsSeparator(this.events[this.events.length - 1].event, event) !== SeparatorKind.None) return false; + + return true; + } + + public add(wrappedEvent: WrappedEvent): void { + const { event } = wrappedEvent; + this.readMarker = this.readMarker || this.panel.readMarkerForEvent(event.getId()!, event === this.lastShownEvent); + this.events.push(wrappedEvent); + } + + public getTiles(): ReactNode[] { + const ret: ReactNode[] = []; + const first = this.events[0]; + const groupedEvents = this.events.map(({ event }) => event); + const lastEvent = groupedEvents[groupedEvents.length - 1]; + + if (this.panel.wantsSeparator(this.prevEvent, first.event) === SeparatorKind.Date) { + const separatorRoomId = first.event.getRoomId()!; + const ts = first.event.getTs(); + ret.push( +
  • + +
  • , + ); + } + + ret.push( + ...this.panel.getTilesForEvent( + this.prevEvent, + first, + lastEvent === this.lastShownEvent, + true, + null, + null, + groupedEvents, + ), + ); + + if (this.readMarker) { + ret.push(this.readMarker); + } + + return ret; + } + + public getNewPrevEvent(): MatrixEvent { + return this.events[this.events.length - 1].event; + } +} diff --git a/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx b/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx index 820b047c38e..175708b7d8f 100644 --- a/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx +++ b/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx @@ -8,6 +8,7 @@ Please see LICENSE files in the repository root for full details. */ import React, { type JSX } from "react"; +import { Field, Label, Root } from "@vector-im/compound-web"; import { FilesIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import { _t } from "../../../languageHandler"; @@ -17,40 +18,72 @@ import { fileSize } from "../../../utils/FileUtils"; interface IProps { file: File; + files?: File[]; currentIndex: number; totalFiles: number; - onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void; + allowCaption?: boolean; + onFinished: (uploadConfirmed: boolean, uploadAll?: boolean, caption?: string, files?: File[]) => void; } interface IState { - objectUrl?: string; + objectUrls: string[]; + caption: string; + files: File[]; } export default class UploadConfirmDialog extends React.Component { public static defaultProps: Partial = { totalFiles: 1, currentIndex: 0, + allowCaption: false, }; + private readonly captionInput = React.createRef(); + public constructor(props: IProps) { super(props); - this.state = {}; + this.state = { + caption: "", + files: this.getInitialFiles(props), + objectUrls: [], + }; } public componentDidMount(): void { - if (this.props.file.type.startsWith("image/") || this.props.file.type.startsWith("video/")) { - this.setState({ - // We do not filter the mimetype using getBlobSafeMimeType here as if the user is uploading the file - // themselves they should be trusting it enough to open/load it, and it will be rendered into a hidden - // canvas for thumbnail generation anyway - objectUrl: URL.createObjectURL(this.props.file), - }); + this.setPreviewObjectUrls(this.state.files); + + if (this.shouldShowCaptionField()) { + this.captionInput.current?.focus(); } } public componentWillUnmount(): void { - if (this.state.objectUrl) URL.revokeObjectURL(this.state.objectUrl); + this.revokeObjectUrls(this.state.objectUrls); + } + + private getInitialFiles(props: IProps): File[] { + return props.files?.length ? [...props.files] : [props.file]; + } + + private revokeObjectUrls(objectUrls: string[]): void { + for (const objectUrl of objectUrls) { + if (objectUrl) URL.revokeObjectURL(objectUrl); + } + } + + private setPreviewObjectUrls(files: File[]): void { + const objectUrls = files.map((file) => { + if (file.type.startsWith("image/") || file.type.startsWith("video/")) { + // We do not filter the mimetype using getBlobSafeMimeType here as if the user is uploading the file + // themselves they should be trusting it enough to open/load it, and it will be rendered into a hidden + // canvas for thumbnail generation anyway + return URL.createObjectURL(file); + } + return ""; + }); + + this.setState({ objectUrls }); } private onCancelClick = (): void => { @@ -58,42 +91,72 @@ export default class UploadConfirmDialog extends React.Component }; private onUploadClick = (): void => { - this.props.onFinished(true); + if (this.shouldShowCaptionField()) { + const caption = this.state.caption.trim(); + if (this.isImageBatchUpload()) { + this.props.onFinished(true, true, caption, [...this.state.files]); + } else { + this.props.onFinished(true, false, caption); + } + } else { + this.props.onFinished(true, false); + } }; private onUploadAllClick = (): void => { this.props.onFinished(true, true); }; - public render(): React.ReactNode { - let title: string; - if (this.props.totalFiles > 1 && this.props.currentIndex !== undefined) { - title = _t("upload_file|title_progress", { - current: this.props.currentIndex + 1, - total: this.props.totalFiles, - }); - } else { - title = _t("upload_file|title"); - } + private onCaptionChange = (ev: React.ChangeEvent): void => { + this.setState({ caption: ev.target.value }); + }; + + private onRemoveFile = (index: number): void => { + const files = this.state.files.filter((_, i) => i !== index); + const objectUrls = this.state.objectUrls.filter((objectUrl, i) => { + if (i === index && objectUrl) URL.revokeObjectURL(objectUrl); + return i !== index; + }); - const fileId = `mx-uploadconfirmdialog-${this.props.file.name}`; - const mimeType = this.props.file.type; + this.setState({ files, objectUrls }); + }; + + private onSubmit = (ev: React.FormEvent): void => { + ev.preventDefault(); + }; + + private shouldShowCaptionField(): boolean { + return ( + !!this.props.allowCaption && + this.state.files.length > 0 && + (this.props.totalFiles === 1 || !!this.props.files?.length) && + this.state.files.every((file) => file.type.startsWith("image/")) + ); + } + + private isImageBatchUpload(): boolean { + return !!this.props.files?.length; + } + + private isMultiImageUpload(): boolean { + return this.isImageBatchUpload() && this.state.files.length > 1; + } + + private renderSingleFilePreview(file: File, objectUrl: string | undefined): JSX.Element { + const fileId = `mx-uploadconfirmdialog-${file.name}`; + const mimeType = file.type; let preview: JSX.Element | undefined; let placeholder: JSX.Element | undefined; if (mimeType.startsWith("image/")) { preview = ( - + ); } else if (mimeType.startsWith("video/")) { preview = (