From 5ba09a5f903f72c76a197d02fd6ace778962abe6 Mon Sep 17 00:00:00 2001 From: Joao Pedro Antunes Borie Date: Thu, 9 Apr 2026 12:07:32 +0100 Subject: [PATCH 01/17] Fix #32727: Ensure VoiceRecording uses the selected microphone (#32887) Voice messages were being recorded using the system default microphone instead of the device selected in Element settings. This was fixed by ensuring the preferred deviceId is correctly passed to the MediaStream constraints in VoiceRecording.ts. Added unit tests in VoiceRecording-test.ts to verify that the application correctly requests the user-selected device. Co-authored-by: Will Hunt <2072976+Half-Shot@users.noreply.github.com> --- apps/web/src/audio/VoiceRecording.ts | 6 ++++- .../unit-tests/audio/VoiceRecording-test.ts | 23 +++++++++++++++++++ 2 files changed, 28 insertions(+), 1 deletion(-) diff --git a/apps/web/src/audio/VoiceRecording.ts b/apps/web/src/audio/VoiceRecording.ts index 705b96375a..44a016b995 100644 --- a/apps/web/src/audio/VoiceRecording.ts +++ b/apps/web/src/audio/VoiceRecording.ts @@ -103,10 +103,14 @@ export class VoiceRecording extends EventEmitter implements IDestroyable { private async makeRecorder(): Promise { try { + const requestedDeviceId = MediaDeviceHandler.getAudioInput(); + const deviceIdConstraint = + requestedDeviceId && requestedDeviceId !== "default" ? { deviceId: { exact: requestedDeviceId } } : {}; + this.recorderStream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: CHANNELS, - deviceId: MediaDeviceHandler.getAudioInput(), + ...deviceIdConstraint, autoGainControl: { ideal: MediaDeviceHandler.getAudioAutoGainControl() }, echoCancellation: { ideal: MediaDeviceHandler.getAudioEchoCancellation() }, noiseSuppression: { ideal: MediaDeviceHandler.getAudioNoiseSuppression() }, diff --git a/apps/web/test/unit-tests/audio/VoiceRecording-test.ts b/apps/web/test/unit-tests/audio/VoiceRecording-test.ts index eb14b9364a..73329ba566 100644 --- a/apps/web/test/unit-tests/audio/VoiceRecording-test.ts +++ b/apps/web/test/unit-tests/audio/VoiceRecording-test.ts @@ -120,6 +120,29 @@ describe("VoiceRecording", () => { }), ); }); + + it("should request the selected microphone as an exact device constraint", async () => { + MediaDeviceHandlerMock.getAudioInput.mockReturnValue("selected-mic"); + await recording.start(); + + expect(navigator.mediaDevices.getUserMedia).toHaveBeenCalledWith( + expect.objectContaining({ + audio: expect.objectContaining({ deviceId: { exact: "selected-mic" } }), + }), + ); + }); + + it("should not force an exact microphone when default device is selected", async () => { + MediaDeviceHandlerMock.getAudioInput.mockReturnValue("default"); + await recording.start(); + + const constraints = mocked(navigator.mediaDevices.getUserMedia).mock.calls[0][0] as MediaStreamConstraints; + expect(constraints.audio).toEqual( + expect.not.objectContaining({ + deviceId: expect.anything(), + }), + ); + }); }); describe("when recording", () => { From 6486a6b5ff15c43c3e4b413506f7870cd5fd7ac5 Mon Sep 17 00:00:00 2001 From: Valere Fedronic Date: Thu, 9 Apr 2026 13:08:08 +0200 Subject: [PATCH 02/17] Add user friendly capability text for `msc4039.download_file` (#32983) * Add user friendly capability test for `msc4039.download_file` * review: remove un-needed experimental copy --- apps/web/src/i18n/strings/en_EN.json | 1 + apps/web/src/widgets/CapabilityText.tsx | 3 +++ 2 files changed, 4 insertions(+) diff --git a/apps/web/src/i18n/strings/en_EN.json b/apps/web/src/i18n/strings/en_EN.json index 3fad87c427..3b20cda580 100644 --- a/apps/web/src/i18n/strings/en_EN.json +++ b/apps/web/src/i18n/strings/en_EN.json @@ -4003,6 +4003,7 @@ "change_name_this_room": "Change the name of this room", "change_topic_active_room": "Change the topic of your active room", "change_topic_this_room": "Change the topic of this room", + "download_file": "Download files from the media repository", "receive_membership_active_room": "See when people join, leave, or are invited to your active room", "receive_membership_this_room": "See when people join, leave, or are invited to this room", "remove_ban_invite_leave_active_room": "Remove, ban, or invite people to your active room, and make you leave", diff --git a/apps/web/src/widgets/CapabilityText.tsx b/apps/web/src/widgets/CapabilityText.tsx index c64e7ffa0f..5f5c8b9a74 100644 --- a/apps/web/src/widgets/CapabilityText.tsx +++ b/apps/web/src/widgets/CapabilityText.tsx @@ -57,6 +57,9 @@ export class CapabilityText { [MatrixCapabilities.MSC2931Navigate]: { [GENERIC_WIDGET_KIND]: _td("widget|capability|switch_room_message_user"), }, + [MatrixCapabilities.MSC4039DownloadFile]: { + [GENERIC_WIDGET_KIND]: _td("widget|capability|download_file"), + }, }; private static stateSendRecvCaps: SendRecvStaticCapText = { From 1721b69017338b630a0b28c6b80b918570b02505 Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 9 Apr 2026 13:36:24 +0200 Subject: [PATCH 03/17] Move TextualBody to shared components (#32868) * Init, refactoring and movement of TextualBody to shared components, adding stories, test and view * migrate TextualBody to shared view + app viewmodel * Update snapshots + prettier fix * Fix Prettier * added new tests to make coverage happy * add comment to attachbodyRef function * Fix: Remove event onkeydown and remove hardcoded mx css * Update enums to const enums * added comment on css to explain 9px * Update comment * Correcting comment, pushed too fast.. * Update Css To Fix (edited) * Update snapshot to reflect css changes * Fix emote into one liner * Update snapshot --- .../caption-with-preview-auto.png | Bin 0 -> 8303 bytes .../TextualBody.stories.tsx/default-auto.png | Bin 0 -> 6476 bytes .../TextualBody.stories.tsx/edited-auto.png | Bin 0 -> 21215 bytes .../TextualBody.stories.tsx/emote-auto.png | Bin 0 -> 21093 bytes .../highlight-link-auto.png | Bin 0 -> 6476 bytes .../TextualBody.stories.tsx/notice-auto.png | Bin 0 -> 6103 bytes .../pending-moderation-auto.png | Bin 0 -> 8939 bytes .../starter-link-auto.png | Bin 0 -> 6166 bytes packages/shared-components/src/index.ts | 1 + .../event-tile/body/TextualBodyView/.gitkeep | 1 - .../TextualBodyView/TextualBody.module.css | 81 +++++ .../TextualBodyView/TextualBody.stories.tsx | 160 ++++++++++ .../body/TextualBodyView/TextualBody.test.tsx | 192 ++++++++++++ .../body/TextualBodyView/TextualBodyView.tsx | 278 ++++++++++++++++++ .../__snapshots__/TextualBody.test.tsx.snap | 76 +++++ .../event-tile/body/TextualBodyView/index.tsx | 17 ++ 16 files changed, 805 insertions(+), 1 deletion(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/caption-with-preview-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/edited-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/highlight-link-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/notice-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/pending-moderation-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/starter-link-auto.png delete mode 100644 packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/.gitkeep create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap create mode 100644 packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/index.tsx diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/caption-with-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/caption-with-preview-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..5250dc03edae96ceeba7e6cfa60d7c7c4ac13857 GIT binary patch literal 8303 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

QP%1SFt@;{Lm`Hcfd; zrP_nHb$IVq;z3T~87GQ&Jm4LlQY`XSrk7X41Im>^U*)F-{45c@+5i|gh|&%wF;&G| zdAnJQfbm5h`M@~q5rtQ!t%q7&!?x!yl=0Sw4DO&4a~B-EYh(eg+-by58jBKv@qTJ3 zmvNV^NFd5p5Cc?)EqRAv+%ARDjV?na7W!enDNp5#4wAj4Xh9HYLcgw%;VT-;o1(QC ze$_<^{cn8Cy)cpcA<%RMIm`vH=e_SK`<^D4t-#Ybh5mNZu;&^>ph}iRr$n7DQpM0|tOY#n zk=(Nt8mY&`>;ctp6i;5RvS$Y@|0Veu)fjCd7s6e^J3YzJR!$jv2I3E{?9qc|`}x=K zxZWjIg#B1Za*qKlJ1!=k{E4}hJ(#ndL;G z-iH2J@PeB;xM`u<_e+U&oU#%ub*0g6M**bJp_TU__xHljru+`-*PAM9kYtg1rP;M9Ln}e|V7W}kv{;Vp&GYoI z8{R8ItM|z4fU(X6{B_9tNQUQ>?H5lSSy`$~BPnSqoCMx!Mr5>wh}qzOzo5)tU3jHuTW}tUc@<QZh zsH5Oqe7hW-JKh&GWiGy5nvVa<>?5?&1m@z2ITf@c>h!N<65+t34zjyJ1n}E&QuVD( z?Y!y%A5$rs3+{Bo(-Q1q57Q6kZ=udT@Dp^-LK2C?8T+PWOd^q{qdGQ?o|DFH< literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/emote-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..f71301034c96576adcb6a9cbabc91030bece6606 GIT binary patch literal 21093 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 z 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 F@n0kbexLvV literal 0 HcmV?d00001 diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/starter-link-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx/starter-link-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..4fb76984cdec2e3f72a1d75a0458408118d25edb GIT binary patch literal 6166 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 literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index c1f53abbb7..c134ab8b57 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -17,6 +17,7 @@ export * from "./room/timeline/event-tile/body/EventContentBodyView"; export * from "./room/timeline/event-tile/body/RedactedBodyView"; export * from "./room/timeline/event-tile/body/MFileBodyView"; export * from "./room/timeline/event-tile/body/MVideoBodyView"; +export * from "./room/timeline/event-tile/body/TextualBodyView"; export * from "./room/timeline/event-tile/EventTileView/TileErrorView"; export * from "./core/pill-input/Pill"; export * from "./core/pill-input/PillInput"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/.gitkeep b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/.gitkeep deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css new file mode 100644 index 0000000000..683c8a8a2a --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.module.css @@ -0,0 +1,81 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +.root { + overflow-y: hidden; + overflow-x: hidden; +} + +.text, +.caption { + white-space: pre-wrap; +} + +.notice { + white-space: pre-wrap; + color: var(--cpd-color-text-secondary); +} + +.emote { + white-space: pre-wrap; + text-align: start; +} + +.annotated { + display: flex; +} + +.annotatedInline { + display: inline-flex; +} + +.annotation { + user-select: none; + display: inline-block; + margin-inline-start: 9px; /* Preserve legacy EventTile spacing for inline annotations like (edited) */ + font: var(--cpd-font-body-xs-regular); + color: var(--cpd-color-text-secondary); +} + +.editedMarker { + appearance: none; + background: none; + border: none; + padding: 0; + cursor: pointer; +} + +.bodyLink, +.bodyAction { + color: inherit; + text-decoration: inherit; +} + +.bodyAction { + appearance: none; + background: none; + border: none; + cursor: pointer; + font: inherit; + padding: 0; + text-align: inherit; +} + +.emoteSender { + all: unset; + font: inherit; + color: inherit; + cursor: pointer; +} + +.editedMarker:focus-visible, +.bodyAction:focus-visible, +.emoteSender:focus-visible { + outline: 2px solid var(--cpd-color-border-focused); + outline-offset: 2px; + border-radius: var(--cpd-space-0-5x); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx new file mode 100644 index 0000000000..26604c0814 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.stories.tsx @@ -0,0 +1,160 @@ +/* + * 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, type ReactElement, type ReactNode } from "react"; +import { fn } from "storybook/test"; + +import type { Meta, StoryObj } from "@storybook/react-vite"; +import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel"; +import { withViewDocs } from "../../../../../../.storybook/withViewDocs"; +import { + TextualBodyView, + TextualBodyViewBodyWrapperKind, + TextualBodyViewKind, + type TextualBodyViewActions, + type TextualBodyViewSnapshot, +} from "./TextualBodyView"; + +type WrapperProps = TextualBodyViewSnapshot & + Partial & { + body: ReactElement; + urlPreviews?: ReactNode; + className?: string; + }; + +const TextualBodyViewWrapperImpl = ({ + body, + urlPreviews, + className, + onRootClick, + onBodyActionClick, + onEditedMarkerClick, + onEmoteSenderClick, + ...snapshotProps +}: WrapperProps): JSX.Element => { + const vm = useMockedViewModel(snapshotProps, { + onRootClick: onRootClick ?? fn(), + onBodyActionClick: onBodyActionClick ?? fn(), + onEditedMarkerClick: onEditedMarkerClick ?? fn(), + onEmoteSenderClick: onEmoteSenderClick ?? fn(), + }); + + return ; +}; + +const TextualBodyViewWrapper = withViewDocs(TextualBodyViewWrapperImpl, TextualBodyView); + +const DefaultBody =

Hello, this is a textual message.
; +const Preview = ( +
+ URL preview +
+); + +const TEXTUAL_BODY_VIEW_KIND_OPTIONS = [ + TextualBodyViewKind.TEXT, + TextualBodyViewKind.NOTICE, + TextualBodyViewKind.EMOTE, + TextualBodyViewKind.CAPTION, +]; + +const TEXTUAL_BODY_VIEW_BODY_WRAPPER_KIND_OPTIONS = [ + TextualBodyViewBodyWrapperKind.NONE, + TextualBodyViewBodyWrapperKind.LINK, + TextualBodyViewBodyWrapperKind.ACTION, +]; + +const meta = { + title: "MessageBody/TextualBody", + component: TextualBodyViewWrapper, + tags: ["autodocs"], + argTypes: { + kind: { + options: TEXTUAL_BODY_VIEW_KIND_OPTIONS, + control: { type: "select" }, + }, + bodyWrapper: { + options: TEXTUAL_BODY_VIEW_BODY_WRAPPER_KIND_OPTIONS, + control: { type: "select" }, + }, + }, + args: { + kind: TextualBodyViewKind.TEXT, + bodyWrapper: TextualBodyViewBodyWrapperKind.NONE, + body: DefaultBody, + urlPreviews: undefined, + showEditedMarker: false, + editedMarkerText: "(edited)", + editedMarkerTooltip: "Edited yesterday at 11:48", + editedMarkerCaption: "View edit history", + showPendingModerationMarker: false, + pendingModerationText: "(Visible to you while moderation is pending)", + emoteSenderName: "Alice", + bodyActionAriaLabel: "Open starter link", + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; + +export const Notice: Story = { + args: { + kind: TextualBodyViewKind.NOTICE, + body:
This is a notice message.
, + }, +}; + +export const CaptionWithPreview: Story = { + args: { + kind: TextualBodyViewKind.CAPTION, + body:
Caption for the uploaded image.
, + urlPreviews: Preview, + }, +}; + +export const Edited: Story = { + args: { + showEditedMarker: true, + }, +}; + +export const PendingModeration: Story = { + args: { + showPendingModerationMarker: true, + }, +}; + +export const HighlightLink: Story = { + args: { + bodyWrapper: TextualBodyViewBodyWrapperKind.LINK, + bodyLinkHref: "https://example.org/#/room/!room:example.org/$event", + }, +}; + +export const StarterLink: Story = { + args: { + bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION, + body:
Launch the integration flow.
, + }, +}; + +export const Emote: Story = { + args: { + kind: TextualBodyViewKind.EMOTE, + body: waves enthusiastically, + showEditedMarker: true, + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx new file mode 100644 index 0000000000..5e4d8a3e89 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBody.test.tsx @@ -0,0 +1,192 @@ +/* + * 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, type MouseEventHandler } from "react"; +import { composeStories } from "@storybook/react-vite"; +import userEvent from "@testing-library/user-event"; +import { render, screen } from "@test-utils"; +import { describe, expect, it, vi } from "vitest"; + +import { MockViewModel } from "../../../../../core/viewmodel"; +import { + TextualBodyView, + TextualBodyViewBodyWrapperKind, + TextualBodyViewKind, + type TextualBodyContentElement, + type TextualBodyViewActions, + type TextualBodyViewModel, + type TextualBodyViewSnapshot, +} from "./TextualBodyView"; +import * as publicApi from "./index"; +import * as stories from "./TextualBody.stories"; + +const { Default, Notice, CaptionWithPreview, Emote } = composeStories(stories); + +describe("TextualBodyView", () => { + it("renders the default message body", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders the notice branch", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders caption messages with url previews", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders emote messages with annotations", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("re-exports the public TextualBodyView API", () => { + expect(publicApi.TextualBodyView).toBe(TextualBodyView); + }); + + it("forwards body refs to the rendered body element", () => { + const bodyRef = createRef(); + const vm = new MockViewModel({ + kind: TextualBodyViewKind.TEXT, + }) as TextualBodyViewModel; + + render(Body content} bodyRef={bodyRef} />); + + expect(bodyRef.current).not.toBeNull(); + expect(bodyRef.current?.textContent).toBe("Body content"); + }); + + it("invokes edited marker, body action, and emote sender handlers", async () => { + const user = userEvent.setup(); + const onEditedMarkerClick = vi.fn(); + const onBodyActionClick = vi.fn(); + const onEmoteSenderClick = vi.fn(); + + class TestTextualBodyViewModel + extends MockViewModel + implements TextualBodyViewActions + { + public onEditedMarkerClick?: MouseEventHandler; + public onBodyActionClick?: MouseEventHandler; + public onEmoteSenderClick?: MouseEventHandler; + + public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) { + super(snapshot); + Object.assign(this, actions); + } + } + + const vm = new TestTextualBodyViewModel( + { + kind: TextualBodyViewKind.EMOTE, + bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION, + bodyActionAriaLabel: "Open starter link", + showEditedMarker: true, + editedMarkerText: "(edited)", + editedMarkerTooltip: "Edited yesterday at 11:48", + editedMarkerCaption: "View edit history", + emoteSenderName: "Alice", + }, + { + onEditedMarkerClick, + onBodyActionClick, + onEmoteSenderClick, + }, + ) as TextualBodyViewModel; + + render(waves} />); + + await user.click(screen.getByRole("button", { name: "Alice" })); + await user.click(screen.getByRole("button", { name: "Open starter link" })); + await user.click(screen.getByRole("button", { name: "(edited)" })); + + expect(onEmoteSenderClick).toHaveBeenCalledTimes(1); + expect(onBodyActionClick).toHaveBeenCalledTimes(1); + expect(onEditedMarkerClick).toHaveBeenCalledTimes(1); + }); + + it("renders link-wrapped annotated bodies without an edited tooltip", async () => { + const user = userEvent.setup(); + const onEditedMarkerClick = vi.fn(); + + class TestTextualBodyViewModel + extends MockViewModel + implements TextualBodyViewActions + { + public onEditedMarkerClick?: MouseEventHandler; + + public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) { + super(snapshot); + Object.assign(this, actions); + } + } + + const vm = new TestTextualBodyViewModel( + { + kind: TextualBodyViewKind.TEXT, + bodyWrapper: TextualBodyViewBodyWrapperKind.LINK, + bodyLinkHref: "https://example.org/#/room/!room:example.org/$event", + showEditedMarker: true, + editedMarkerText: "(edited)", + showPendingModerationMarker: true, + pendingModerationText: "(Visible to you while moderation is pending)", + }, + { onEditedMarkerClick }, + ) as TextualBodyViewModel; + + render(Body content} />); + + expect(screen.getByRole("link")).toHaveAttribute("href", "https://example.org/#/room/!room:example.org/$event"); + expect(screen.queryByRole("tooltip")).not.toBeInTheDocument(); + expect(screen.getByText("(Visible to you while moderation is pending)")).toBeInTheDocument(); + + await user.click(screen.getByRole("button", { name: "(edited)" })); + + expect(onEditedMarkerClick).toHaveBeenCalledTimes(1); + }); + + it("renders action wrappers as native buttons and activates them for Enter and Space key presses", async () => { + const user = userEvent.setup(); + const onBodyActionClick = vi.fn(); + + class TestTextualBodyViewModel + extends MockViewModel + implements TextualBodyViewActions + { + public onBodyActionClick?: MouseEventHandler; + + public constructor(snapshot: TextualBodyViewSnapshot, actions: TextualBodyViewActions) { + super(snapshot); + Object.assign(this, actions); + } + } + + const vm = new TestTextualBodyViewModel( + { + kind: TextualBodyViewKind.TEXT, + bodyWrapper: TextualBodyViewBodyWrapperKind.ACTION, + bodyActionAriaLabel: "Open starter link", + }, + { onBodyActionClick }, + ) as TextualBodyViewModel; + + render(Launch the integration flow.} />); + + const action = screen.getByRole("button", { name: "Open starter link" }); + expect(action).toHaveAttribute("type", "button"); + + action.focus(); + await user.keyboard("{Escape}"); + await user.keyboard("{Enter}"); + await user.keyboard(" "); + + expect(onBodyActionClick).toHaveBeenCalledTimes(2); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx new file mode 100644 index 0000000000..aabde86295 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/TextualBodyView.tsx @@ -0,0 +1,278 @@ +/* + * 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, { + cloneElement, + isValidElement, + type JSX, + type MouseEventHandler, + type ReactElement, + type ReactNode, + type Ref, +} from "react"; +import classNames from "classnames"; +import { Tooltip } from "@vector-im/compound-web"; + +import { type ViewModel, useViewModel } from "../../../../../core/viewmodel"; +import styles from "./TextualBody.module.css"; + +export const enum TextualBodyViewKind { + TEXT = "TEXT", + NOTICE = "NOTICE", + EMOTE = "EMOTE", + CAPTION = "CAPTION", +} + +export const enum TextualBodyViewBodyWrapperKind { + NONE = "NONE", + LINK = "LINK", + ACTION = "ACTION", +} + +export interface TextualBodyViewSnapshot { + /** + * Optional id passed to the root message-body element. + */ + id?: string; + /** + * Controls the layout and styling branch for the body. + */ + kind: TextualBodyViewKind; + /** + * Optional outer wrapper applied around the rendered body content. + */ + bodyWrapper?: TextualBodyViewBodyWrapperKind; + /** + * Href used when `bodyWrapper` is `LINK`. + */ + bodyLinkHref?: string; + /** + * Accessible label used when `bodyWrapper` is `ACTION`. + */ + bodyActionAriaLabel?: string; + /** + * Whether to render the edited marker. + */ + showEditedMarker?: boolean; + /** + * Visible label for the edited marker. + */ + editedMarkerText?: string; + /** + * Tooltip description for the edited marker. + */ + editedMarkerTooltip?: string; + /** + * Optional tooltip caption for the edited marker. + */ + editedMarkerCaption?: string; + /** + * Whether to render the pending-moderation marker. + */ + showPendingModerationMarker?: boolean; + /** + * Visible label for the pending-moderation marker. + */ + pendingModerationText?: string; + /** + * Sender label rendered for emote events. + */ + emoteSenderName?: string; +} + +export interface TextualBodyViewActions { + /** + * Capture-phase click handler attached to the root message-body container. + */ + onRootClick?: MouseEventHandler; + /** + * Activation handler used when `bodyWrapper` is `ACTION`. + */ + onBodyActionClick?: MouseEventHandler; + /** + * Click handler for the edited marker. + */ + onEditedMarkerClick?: MouseEventHandler; + /** + * Click handler for the emote sender. + */ + onEmoteSenderClick?: MouseEventHandler; +} + +export type TextualBodyViewModel = ViewModel; + +export type TextualBodyContentElement = HTMLDivElement | HTMLSpanElement; +export type TextualBodyContentRef = Ref; + +interface TextualBodyViewProps { + /** + * The view model providing the layout state and event handlers. + */ + vm: TextualBodyViewModel; + /** + * The message body element, typically `EventContentBodyView`. + */ + body: ReactElement; + /** + * Optional ref to attach to the message body element. + */ + bodyRef?: TextualBodyContentRef; + /** + * Optional URL preview subtree rendered after the body. + */ + urlPreviews?: ReactNode; + /** + * Optional host-level class names. + */ + className?: string; +} + +/** + * Re-clones the supplied body element so consumers can observe the rendered + * body node via `bodyRef` without constraining the `body` prop shape. + */ +function attachBodyRef(body: ReactElement, bodyRef?: TextualBodyContentRef): ReactElement { + if (!bodyRef || !isValidElement(body)) { + return body; + } + + return cloneElement(body as ReactElement<{ ref?: TextualBodyContentRef }>, { ref: bodyRef }); +} + +export function TextualBodyView({ + vm, + body, + bodyRef, + urlPreviews, + className, +}: Readonly): JSX.Element { + const { + id, + kind, + bodyWrapper = TextualBodyViewBodyWrapperKind.NONE, + bodyLinkHref, + bodyActionAriaLabel, + showEditedMarker, + editedMarkerText, + editedMarkerTooltip, + editedMarkerCaption, + showPendingModerationMarker, + pendingModerationText, + emoteSenderName, + } = useViewModel(vm); + + const rootClasses = classNames(className, styles.root, { + [styles.text]: kind === TextualBodyViewKind.TEXT, + [styles.notice]: kind === TextualBodyViewKind.NOTICE, + [styles.emote]: kind === TextualBodyViewKind.EMOTE, + [styles.caption]: kind === TextualBodyViewKind.CAPTION, + }); + + let renderedBody: ReactNode = attachBodyRef(body, bodyRef); + const onEditedMarkerClick: MouseEventHandler | undefined = vm.onEditedMarkerClick + ? (event): void => { + event.preventDefault(); + event.stopPropagation(); + vm.onEditedMarkerClick?.(event); + } + : undefined; + + const markers: ReactNode[] = []; + if (showEditedMarker) { + const editedMarkerButton = ( + + ); + + markers.push( + editedMarkerTooltip ? ( + + {editedMarkerButton} + + ) : ( + React.cloneElement(editedMarkerButton, { key: "edited-marker" }) + ), + ); + } + + if (showPendingModerationMarker) { + markers.push( + + {pendingModerationText} + , + ); + } + + if (bodyWrapper === TextualBodyViewBodyWrapperKind.LINK && bodyLinkHref) { + renderedBody = ( + + {renderedBody} + + ); + } else if (bodyWrapper === TextualBodyViewBodyWrapperKind.ACTION) { + renderedBody = ( + + ); + } + + if (markers.length > 0) { + const annotatedClasses = classNames(styles.annotated, { + [styles.annotatedInline]: kind === TextualBodyViewKind.EMOTE, + }); + + renderedBody = + kind === TextualBodyViewKind.EMOTE ? ( + + {renderedBody} + {markers} + + ) : ( +
+ {renderedBody} + {markers} +
+ ); + } + + if (kind === TextualBodyViewKind.EMOTE) { + return ( +
+ *  + +   + {renderedBody} + {urlPreviews} +
+ ); + } + + return ( +
+ {renderedBody} + {urlPreviews} +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap new file mode 100644 index 0000000000..2f69f4985f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/__snapshots__/TextualBody.test.tsx.snap @@ -0,0 +1,76 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`TextualBodyView > renders caption messages with url previews 1`] = ` +
+
+
+ Caption for the uploaded image. +
+
+ URL preview +
+
+
+`; + +exports[`TextualBodyView > renders emote messages with annotations 1`] = ` +
+
+ *  + +   + + + waves enthusiastically + + + +
+
+`; + +exports[`TextualBodyView > renders the default message body 1`] = ` +
+
+
+ Hello, this is a textual message. +
+
+
+`; + +exports[`TextualBodyView > renders the notice branch 1`] = ` +
+
+
+ This is a notice message. +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/index.tsx new file mode 100644 index 0000000000..dce11459c8 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/TextualBodyView/index.tsx @@ -0,0 +1,17 @@ +/* + * 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 { + TextualBodyView, + TextualBodyViewKind, + TextualBodyViewBodyWrapperKind, + type TextualBodyViewSnapshot, + type TextualBodyViewActions, + type TextualBodyViewModel, + type TextualBodyContentElement, + type TextualBodyContentRef, +} from "./TextualBodyView"; From 70e40009a3689807ccbade4ec04e14435f17066c Mon Sep 17 00:00:00 2001 From: Zack Date: Thu, 9 Apr 2026 14:13:02 +0200 Subject: [PATCH 04/17] Fix issues with /me emote two liner (#33081) * Fix issues with me emote liner * Fix Prettier --- apps/web/res/css/views/rooms/_EventTile.pcss | 4 ++++ .../components/views/messages/TextualBody.tsx | 7 ++++-- .../views/messages/TextualBody-test.tsx | 22 +++++++++++++++++++ 3 files changed, 31 insertions(+), 2 deletions(-) diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index 5bea427961..63d1bdaee2 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -1378,6 +1378,10 @@ $left-gutter: 64px; display: flex; } +.mx_EventTile_annotatedInline { + display: inline-flex; +} + .mx_EventTile_footer { display: flex; gap: var(--cpd-space-2x); diff --git a/apps/web/src/components/views/messages/TextualBody.tsx b/apps/web/src/components/views/messages/TextualBody.tsx index 7fadf33510..caf5df344d 100644 --- a/apps/web/src/components/views/messages/TextualBody.tsx +++ b/apps/web/src/components/views/messages/TextualBody.tsx @@ -301,6 +301,9 @@ class InnerTextualBody extends React.Component { const isCaption = [MsgType.Image, MsgType.File, MsgType.Audio, MsgType.Video].includes( content.msgtype as MsgType, ); + const annotatedClassName = isEmote + ? "mx_EventTile_annotated mx_EventTile_annotatedInline" + : "mx_EventTile_annotated"; const willHaveWrapper = this.props.replacingEventId || this.props.isSeeingThroughMessageHiddenForModeration || isEmote; @@ -315,7 +318,7 @@ class InnerTextualBody extends React.Component { if (this.props.replacingEventId) { body = ( -
+
{body} {this.renderEditedMarker()}
@@ -323,7 +326,7 @@ class InnerTextualBody extends React.Component { } if (this.props.isSeeingThroughMessageHiddenForModeration) { body = ( -
+
{body} {this.renderPendingModerationMarker()}
diff --git a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx index 8351394533..0fd8c7106b 100644 --- a/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/TextualBody-test.tsx @@ -146,6 +146,28 @@ describe("", () => { expect(content).toMatchSnapshot(); }); + it("keeps edited emote bodies inline with the sender", () => { + DMRoomMap.makeShared(defaultMatrixClient); + + const ev = mkEvent({ + type: "m.room.message", + room: room1Id, + user: "sender", + content: { + body: "winks", + msgtype: "m.emote", + }, + event: true, + }); + jest.spyOn(ev, "replacingEventDate").mockReturnValue(new Date(1993, 7, 3)); + + const { container } = getComponent({ mxEvent: ev, replacingEventId: ev.getId() }); + + const annotated = container.querySelector(".mx_MEmoteBody > .mx_EventTile_annotatedInline"); + expect(annotated).not.toBeNull(); + expect(annotated?.tagName).toBe("DIV"); + }); + it("renders m.notice correctly", () => { DMRoomMap.makeShared(defaultMatrixClient); From 253dcb44dd5cf6dc55553f4dec21770ae1f56586 Mon Sep 17 00:00:00 2001 From: Robin Date: Thu, 9 Apr 2026 14:25:14 +0200 Subject: [PATCH 05/17] Show a 'grab' cursor on picture-in-picture view (#33079) * Remove unused 'draggable' prop from PictureInPictureDragger * Show a 'grab' cursor on picture-in-picture view To give it a proper affordance for dragging. --- apps/web/res/css/_components.pcss | 2 +- .../structures/_PictureInPictureDragger.pcss | 20 +++++++++++++ .../css/views/voip/_LegacyCallPreview.pcss | 28 ------------------- .../structures/PictureInPictureDragger.tsx | 10 +------ .../components/structures/PipContainer.tsx | 7 +---- .../PictureInPictureDragger-test.tsx | 12 ++++---- .../PictureInPictureDragger-test.tsx.snap | 4 +++ 7 files changed, 32 insertions(+), 51 deletions(-) create mode 100644 apps/web/res/css/structures/_PictureInPictureDragger.pcss delete mode 100644 apps/web/res/css/views/voip/_LegacyCallPreview.pcss diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 1197cbabe6..bdca70276d 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -70,6 +70,7 @@ @import "./structures/_MatrixChat.pcss"; @import "./structures/_MessagePanel.pcss"; @import "./structures/_NonUrgentToastContainer.pcss"; +@import "./structures/_PictureInPictureDragger.pcss"; @import "./structures/_QuickSettingsButton.pcss"; @import "./structures/_RightPanel.pcss"; @import "./structures/_RoomSearch.pcss"; @@ -375,7 +376,6 @@ @import "./views/voip/_DialPad.pcss"; @import "./views/voip/_DialPadContextMenu.pcss"; @import "./views/voip/_DialPadModal.pcss"; -@import "./views/voip/_LegacyCallPreview.pcss"; @import "./views/voip/_LegacyCallView.pcss"; @import "./views/voip/_LegacyCallViewForRoom.pcss"; @import "./views/voip/_LegacyCallViewHeader.pcss"; diff --git a/apps/web/res/css/structures/_PictureInPictureDragger.pcss b/apps/web/res/css/structures/_PictureInPictureDragger.pcss new file mode 100644 index 0000000000..d6effd3b20 --- /dev/null +++ b/apps/web/res/css/structures/_PictureInPictureDragger.pcss @@ -0,0 +1,20 @@ +/* +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. +*/ + +.mx_PictureInPictureDragger { + cursor: grab; + user-select: none; + left: 0; + position: fixed; + top: 0; + /* Display above any widget elements */ + z-index: 102; +} + +.mx_PictureInPictureDragger:active { + cursor: grabbing; +} diff --git a/apps/web/res/css/views/voip/_LegacyCallPreview.pcss b/apps/web/res/css/views/voip/_LegacyCallPreview.pcss deleted file mode 100644 index 3a8bf5af9f..0000000000 --- a/apps/web/res/css/views/voip/_LegacyCallPreview.pcss +++ /dev/null @@ -1,28 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 Šimon Brandner - -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_LegacyCallPreview { - align-items: flex-end; - display: flex; - flex-direction: column; - gap: $spacing-16; - left: 0; - position: fixed; - top: 0; - /* Display above any widget elements */ - z-index: 102; - - .mx_VideoFeed_remote.mx_VideoFeed_voice { - min-height: 150px; - } - - .mx_VideoFeed_local { - border-radius: 8px; - overflow: hidden; - } -} diff --git a/apps/web/src/components/structures/PictureInPictureDragger.tsx b/apps/web/src/components/structures/PictureInPictureDragger.tsx index 2cadc59a7b..d9f472c311 100644 --- a/apps/web/src/components/structures/PictureInPictureDragger.tsx +++ b/apps/web/src/components/structures/PictureInPictureDragger.tsx @@ -37,9 +37,7 @@ interface IChildrenOptions { } interface IProps { - className?: string; children: Array; - draggable: boolean; onDoubleClick?: () => void; onMove?: () => void; } @@ -181,9 +179,6 @@ export default class PictureInPictureDragger extends React.Component { }; private onStartMoving = (event: React.MouseEvent | MouseEvent): void => { - event.preventDefault(); - event.stopPropagation(); - this.mouseHeld = true; this.startingPositionX = event.clientX; this.startingPositionY = event.clientY; @@ -217,9 +212,6 @@ export default class PictureInPictureDragger extends React.Component { private onEndMoving = (event: MouseEvent): void => { if (!this.mouseHeld) return; - event.preventDefault(); - event.stopPropagation(); - this.mouseHeld = false; // Delaying this to the next event loop tick is necessary for click // event cancellation to work @@ -250,7 +242,7 @@ export default class PictureInPictureDragger extends React.Component { return (
@@ -1256,7 +1283,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` class="_label_19upo_59" for="mx_SettingsFlag_bfwnd5rz4XNX" > - Show profile picture changes + Show chat effects (animations when receiving e.g. confetti)
@@ -1289,7 +1316,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` class="_label_19upo_59" for="mx_SettingsFlag_gs5uWEzYzZrS" > - Show avatars in user, room and event mentions + Show profile picture changes @@ -1322,7 +1349,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` class="_label_19upo_59" for="mx_SettingsFlag_qWg7OgID1yRR" > - Enable big emoji in chat + Show avatars in user, room and event mentions @@ -1355,7 +1382,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` class="_label_19upo_59" for="mx_SettingsFlag_pOPewl7rtMbV" > - Jump to the bottom of the timeline when you send a message + Enable big emoji in chat @@ -1387,6 +1414,39 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` + + +
+
+
+ +
+
+
+
+ @@ -1424,7 +1484,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` > @@ -1438,7 +1498,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` > @@ -1452,13 +1512,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` > A hidden media can always be shown by tapping on it @@ -1571,7 +1631,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` checked="" class="_input_udcm8_24" disabled="" - id="_r_2b_" + id="_r_2d_" role="switch" type="checkbox" /> @@ -1585,13 +1645,13 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` > Your server does not implement this feature. @@ -1636,7 +1696,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` @@ -1650,7 +1710,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` > @@ -1690,7 +1750,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` checked="" class="_input_udcm8_24" disabled="" - id="mx_SettingsFlag_SBSSOZDRlzlA" + id="mx_SettingsFlag_FLEpLCb0jpp6" role="switch" type="checkbox" /> @@ -1704,7 +1764,7 @@ exports[`PreferencesUserSettingsTab should render 1`] = ` > diff --git a/apps/web/test/unit-tests/settings/SettingsStore-test.ts b/apps/web/test/unit-tests/settings/SettingsStore-test.ts index 0987569bb9..2452456bf0 100644 --- a/apps/web/test/unit-tests/settings/SettingsStore-test.ts +++ b/apps/web/test/unit-tests/settings/SettingsStore-test.ts @@ -96,63 +96,18 @@ describe("SettingsStore", () => { describe("runMigrations", () => { let client: MatrixClient; let room: Room; - let localStorageSetItemSpy: jest.SpyInstance; - let localStorageSetPromise: Promise; beforeEach(() => { client = stubClient(); room = mkStubRoom("!room:example.org", "Room", client); - room.getAccountData = jest.fn().mockReturnValue({ - getContent: jest.fn().mockReturnValue({ - urlPreviewsEnabled_e2ee: true, - }), - }); client.getRooms = jest.fn().mockReturnValue([room]); client.getRoom = jest.fn().mockReturnValue(room); - - localStorageSetPromise = new Promise((resolve) => { - localStorageSetItemSpy = jest - .spyOn(localStorage.__proto__, "setItem") - .mockImplementation(() => resolve()); - }); }); afterEach(() => { jest.restoreAllMocks(); }); - it("migrates URL previews setting for e2ee rooms", async () => { - SettingsStore.runMigrations(false); - client.emit(ClientEvent.Sync, SyncState.Prepared, null); - - expect(room.getAccountData).toHaveBeenCalled(); - - await localStorageSetPromise; - - expect(localStorageSetItemSpy!).toHaveBeenCalledWith( - `mx_setting_urlPreviewsEnabled_e2ee_${room.roomId}`, - JSON.stringify({ value: true }), - ); - }); - - it("does not migrate e2ee URL previews on a fresh login", async () => { - SettingsStore.runMigrations(true); - client.emit(ClientEvent.Sync, SyncState.Prepared, null); - - expect(room.getAccountData).not.toHaveBeenCalled(); - }); - - it("does not migrate if the device is flagged as migrated", async () => { - jest.spyOn(localStorage.__proto__, "getItem").mockImplementation((key: unknown): string | undefined => { - if (key === "url_previews_e2ee_migration_done") return JSON.stringify({ value: true }); - return undefined; - }); - SettingsStore.runMigrations(false); - client.emit(ClientEvent.Sync, SyncState.Prepared, null); - - expect(room.getAccountData).not.toHaveBeenCalled(); - }); - describe("Migrate media preview configuration", () => { beforeEach(() => { MatrixClientBackedController.matrixClient = client; diff --git a/apps/web/test/unit-tests/settings/controllers/RequiresSettingsController-test.ts b/apps/web/test/unit-tests/settings/controllers/RequiresSettingsController-test.ts new file mode 100644 index 0000000000..dafd7c8896 --- /dev/null +++ b/apps/web/test/unit-tests/settings/controllers/RequiresSettingsController-test.ts @@ -0,0 +1,33 @@ +/* +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 RequiresSettingsController from "../../../../src/settings/controllers/RequiresSettingsController"; +import { SettingLevel } from "../../../../src/settings/SettingLevel"; +import SettingsStore from "../../../../src/settings/SettingsStore"; + +describe("RequiresSettingsController", () => { + afterEach(() => { + SettingsStore.reset(); + }); + + it("forces a value if a setting is false", async () => { + const forcedValue = true; + await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true); + await SettingsStore.setValue("useCustomFontSize", null, SettingLevel.DEVICE, false); + const controller = new RequiresSettingsController(["useCompactLayout", "useCustomFontSize"], forcedValue); + expect(controller.settingDisabled).toEqual(true); + expect(controller.getValueOverride()).toEqual(forcedValue); + }); + + it("does not force a value if all settings are true", async () => { + const controller = new RequiresSettingsController(["useCompactLayout", "useCustomFontSize"]); + await SettingsStore.setValue("useCompactLayout", null, SettingLevel.DEVICE, true); + await SettingsStore.setValue("useCustomFontSize", null, SettingLevel.DEVICE, true); + expect(controller.settingDisabled).toEqual(false); + expect(controller.getValueOverride()).toEqual(null); + }); +}); From f5ec194937ff37c89fd0c1714aa22e7a2fee9d92 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 9 Apr 2026 13:34:52 +0100 Subject: [PATCH 07/17] Tweaks to CI (#33014) * Tweak github actions to make Sonar & zizmor happier * Apply filters on some pnpm install calls * Remove stale setup-python step * Add missing needs in complete job * Remove repository_dispatch for everything bar develop CD js-sdk now runs the tests downstream so this was unnecessary * Fix prepare desktop for tests in merge queue * Iterate * Iterate * Iterate * Discard changes to .github/workflows/build_desktop_linux.yaml * Discard changes to .github/workflows/build_desktop_macos.yaml --- .github/actions/download-verify-element-tarball/action.yml | 4 +++- .github/workflows/build-and-test.yaml | 3 +-- .github/workflows/shared-component-visual-tests-netlify.yaml | 4 +++- .github/workflows/sonarqube.yml | 4 +++- .github/workflows/static_analysis.yaml | 2 -- .github/workflows/tests.yml | 2 -- 6 files changed, 10 insertions(+), 9 deletions(-) diff --git a/.github/actions/download-verify-element-tarball/action.yml b/.github/actions/download-verify-element-tarball/action.yml index a64bc3241b..40855b85c6 100644 --- a/.github/actions/download-verify-element-tarball/action.yml +++ b/.github/actions/download-verify-element-tarball/action.yml @@ -31,7 +31,9 @@ runs: - name: Move webapp to out-file-path shell: bash - run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp ${{ inputs.out-file-path }} + run: mv ${{ runner.temp }}/download-verify-element-tarball/webapp "$OUT_PATH" + env: + OUT_PATH: ${{ inputs.out-file-path }} - name: Clean up temp directory shell: bash diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 19943434e6..01333a7752 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -18,8 +18,6 @@ on: push: # We do not build on push to develop as the merge_group check handles that branches: [staging, master] - repository_dispatch: - types: [element-web-notify] # support triggering from other workflows workflow_call: @@ -246,6 +244,7 @@ jobs: needs: - playwright_ew - downstream-modules + - prepare_ed - build_ed_windows - build_ed_linux - build_ed_macos diff --git a/.github/workflows/shared-component-visual-tests-netlify.yaml b/.github/workflows/shared-component-visual-tests-netlify.yaml index 1f9ae76826..e4b830406d 100644 --- a/.github/workflows/shared-component-visual-tests-netlify.yaml +++ b/.github/workflows/shared-component-visual-tests-netlify.yaml @@ -2,7 +2,9 @@ # It uploads the received images and diffs to netlify, printing the URLs to the console name: Upload Shared Component Visual Test Diffs on: - workflow_run: + # Privilege escalation necessary to deploy to Netlify + # 🚨 We must not execute any checked out code here. + workflow_run: # zizmor: ignore[dangerous-triggers] workflows: ["Shared Component Visual Tests"] types: - completed diff --git a/.github/workflows/sonarqube.yml b/.github/workflows/sonarqube.yml index 73efd48ba3..e934f05ad1 100644 --- a/.github/workflows/sonarqube.yml +++ b/.github/workflows/sonarqube.yml @@ -1,6 +1,8 @@ name: SonarQube on: - workflow_run: + # Privilege escalation necessary to call upon SonarCloud + # 🚨 We must not execute any checked out code here. + workflow_run: # zizmor: ignore[dangerous-triggers] workflows: ["Tests"] types: - completed diff --git a/.github/workflows/static_analysis.yaml b/.github/workflows/static_analysis.yaml index f3052ff373..3dd7da0e39 100644 --- a/.github/workflows/static_analysis.yaml +++ b/.github/workflows/static_analysis.yaml @@ -5,8 +5,6 @@ on: branches: [develop, master] merge_group: types: [checks_requested] - repository_dispatch: - types: [element-web-notify] concurrency: group: ${{ github.workflow }}-${{ github.ref }}-${{ github.event_name }} cancel-in-progress: true diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index dbec96db01..60730451f6 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -5,8 +5,6 @@ on: types: [checks_requested] push: branches: [develop, master] - repository_dispatch: - types: [element-web-notify] workflow_call: inputs: disable_coverage: From 60a7a22c7b46ad77b44471dc7c68eddbeea49d59 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 9 Apr 2026 14:13:35 +0100 Subject: [PATCH 08/17] Consolidate element-modules playwright run into the main html report (#33082) --- .github/workflows/build-and-test.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build-and-test.yaml b/.github/workflows/build-and-test.yaml index 01333a7752..1c66b02414 100644 --- a/.github/workflows/build-and-test.yaml +++ b/.github/workflows/build-and-test.yaml @@ -186,6 +186,7 @@ jobs: uses: element-hq/element-modules/.github/workflows/reusable-playwright-tests.yml@main # zizmor: ignore[unpinned-uses] with: webapp-artifact: webapp + reporter: blob prepare_ed: name: "Prepare Element Desktop" From a5e09ebb53e7a9d1bcf8b421f13cb8432a1d9110 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 9 Apr 2026 14:14:41 +0100 Subject: [PATCH 09/17] feat: expand sections when filter is toggled (#33077) --- .../viewmodels/room-list/RoomListViewModel.ts | 10 +++++++ .../room-list/RoomListViewModel-test.tsx | 27 +++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts index 0dc476e09c..d2ff465db9 100644 --- a/apps/web/src/viewmodels/room-list/RoomListViewModel.ts +++ b/apps/web/src/viewmodels/room-list/RoomListViewModel.ts @@ -182,6 +182,16 @@ export class RoomListViewModel // Update roomsMap immediately before clearing VMs this.updateRoomsMap(this.roomsResult); + // When a filter is toggled on, expand sections that have results so they're visible + if (newFilter) { + for (const section of this.roomsResult.sections) { + if (section.rooms.length > 0) { + const sectionHeaderVM = this.roomSectionHeaderViewModels.get(section.tag); + if (sectionHeaderVM) sectionHeaderVM.isExpanded = true; + } + } + } + this.updateRoomListData(); }; diff --git a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx index b9172b680b..2207f08d19 100644 --- a/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx +++ b/apps/web/test/viewmodels/room-list/RoomListViewModel-test.tsx @@ -885,6 +885,33 @@ describe("RoomListViewModel", () => { expect(snapshot.sections[0].roomIds).toEqual(["!fav1:server"]); }); + it("should expand collapsed sections that have results when a filter is toggled on", () => { + viewModel = new RoomListViewModel({ client: matrixClient }); + + // Collapse the favourite section + const favHeader = viewModel.getSectionHeaderViewModel(DefaultTagID.Favourite); + favHeader.onClick(); + expect(favHeader.isExpanded).toBe(false); + + // Toggle a filter that returns rooms in the favourite section + jest.spyOn(RoomListStoreV3.instance, "getSortedRoomsInActiveSpace").mockReturnValue({ + spaceId: "home", + sections: [ + { tag: DefaultTagID.Favourite, rooms: [favRoom1] }, + { tag: CHATS_TAG, rooms: [] }, + { tag: DefaultTagID.LowPriority, rooms: [] }, + ], + filterKeys: [FilterEnum.UnreadFilter], + }); + viewModel.onToggleFilter("unread"); + + // The favourite section should be expanded and its rooms visible + expect(favHeader.isExpanded).toBe(true); + const snapshot = viewModel.getSnapshot(); + const favSection = snapshot.sections.find((s) => s.id === DefaultTagID.Favourite); + expect(favSection!.roomIds).toEqual(["!fav1:server"]); + }); + it("should apply sticky room within the correct section", async () => { stubClient(); viewModel = new RoomListViewModel({ client: matrixClient }); From b6b0b0009cfb673a5698672b1cd3a44d3c7365cb Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Thu, 9 Apr 2026 15:34:48 +0100 Subject: [PATCH 10/17] Fix some flaky playwright tests (#33085) * Tweak flaky test reporter to identify setup failures * Fix some flaky playwright tests * Iterate --- apps/web/playwright/e2e/crypto/toasts.spec.ts | 6 +---- .../room-list-panel/room-list.spec.ts | 2 +- .../e2e/room-directory/room-directory.spec.ts | 6 ++--- packages/playwright-common/flaky-reporter.ts | 23 +++++++++++++++++-- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/apps/web/playwright/e2e/crypto/toasts.spec.ts b/apps/web/playwright/e2e/crypto/toasts.spec.ts index 876000009b..9ce1b8a5ae 100644 --- a/apps/web/playwright/e2e/crypto/toasts.spec.ts +++ b/apps/web/playwright/e2e/crypto/toasts.spec.ts @@ -43,11 +43,7 @@ test.describe("Key storage out of sync toast", () => { }); test("should prompt for recovery key if 'enter recovery key' pressed", { tag: "@screenshot" }, async ({ page }) => { - // We need to wait for there to be two toasts as the wait below won't work in isolation: - // playwright only evaluates the 'first()' call initially, not subsequent times it checks, so - // it would always be checking the same toast, even if another one is now the first. - await expect(page.getByRole("alert")).toHaveCount(2); - await expect(page.getByRole("alert").first()).toMatchScreenshot( + await expect(page.getByRole("alert").filter({ hasText: "Your key storage is out of sync." })).toMatchScreenshot( "key-storage-out-of-sync-toast.png", screenshotOptions, ); diff --git a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts index 5907ad6d97..79cc0b4cf3 100644 --- a/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts +++ b/apps/web/playwright/e2e/left-panel/room-list-panel/room-list.spec.ts @@ -328,11 +328,11 @@ test.describe("Room list", () => { const roomListView = getRoomList(page); const videoRoom = roomListView.getByRole("option", { name: "video room" }); + await expect(videoRoom).toHaveAttribute("aria-selected", "true"); // wait for room list update // focus the user menu to avoid to have hover decoration await page.getByRole("button", { name: "User menu" }).focus(); - await expect(videoRoom).toBeVisible(); await expect(videoRoom).toMatchScreenshot("room-list-item-video.png"); }); }); diff --git a/apps/web/playwright/e2e/room-directory/room-directory.spec.ts b/apps/web/playwright/e2e/room-directory/room-directory.spec.ts index 741fde3505..6eea5abce7 100644 --- a/apps/web/playwright/e2e/room-directory/room-directory.spec.ts +++ b/apps/web/playwright/e2e/room-directory/room-directory.spec.ts @@ -48,9 +48,9 @@ test.describe("Room Directory", () => { await app.closeDialog(); const resp = await bot.publicRooms({}); - expect(resp.total_room_count_estimate).toEqual(1); - expect(resp.chunk).toHaveLength(1); - expect(resp.chunk[0].room_id).toEqual(roomId); + expect(resp.total_room_count_estimate).toBeGreaterThanOrEqual(1); + expect(resp.chunk).toHaveLength(resp.total_room_count_estimate); + expect(resp.chunk.find((r) => r.room_id === roomId)).toBeTruthy(); }, ); diff --git a/packages/playwright-common/flaky-reporter.ts b/packages/playwright-common/flaky-reporter.ts index 520c2553a2..cebfee7702 100644 --- a/packages/playwright-common/flaky-reporter.ts +++ b/packages/playwright-common/flaky-reporter.ts @@ -24,6 +24,8 @@ type PaginationLinks = { first?: string; }; +const ANSI_COLOUR_REGEX = /[\u001b\u009b][[()#;?]*(?:[0-9]{1,4}(?:;[0-9]{0,4})*)?[0-9A-ORZcf-nqry=><]/g; + // We see quite a few test flakes which are caused by the app exploding // so we have some magic strings we check the logs for to better track the flake with its cause const SPECIAL_CASES: Record = { @@ -38,18 +40,35 @@ class FlakyReporter implements Reporter { public onTestEnd(test: TestCase): void { // Ignores flakes on Dendrite and Pinecone as they have their own flakes we do not track if (["Dendrite", "Pinecone"].includes(test.parent.project()!.name!)) return; - let failures = [`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`]; + if (test.outcome() === "flaky") { + const failures: string[] = []; + const timedOutRuns = test.results.filter((result) => result.status === "timedOut"); const pageLogs = timedOutRuns.flatMap((result) => result.attachments.filter((attachment) => attachment.name.startsWith("page-")), ); + // If a test failed due to a systemic fault then the test is not flaky, the app is, record it as such. const specialCases = Object.keys(SPECIAL_CASES).filter((log) => pageLogs.some((attachment) => attachment.name.startsWith("page-") && attachment.body?.includes(log)), ); if (specialCases.length > 0) { - failures = specialCases.map((specialCase) => SPECIAL_CASES[specialCase]); + failures.push(...specialCases.map((specialCase) => SPECIAL_CASES[specialCase])); + } + + // Check for fixtures failing to set up + const errorMessages = timedOutRuns + .map((r) => r.error?.message?.replace(ANSI_COLOUR_REGEX, "")) + .filter(Boolean) as string[]; + for (const error of errorMessages) { + if (error.startsWith("Fixture") && error.endsWith("exceeded during setup.")) { + failures.push(error); + } + } + + if (failures.length < 1) { + failures.push(`${test.location.file.split("playwright/e2e/")[1]}: ${test.title}`); } for (const title of failures) { From 3fd5718fcd8f4267a51d47918b033ec7a59371c9 Mon Sep 17 00:00:00 2001 From: Florian Duros Date: Thu, 9 Apr 2026 16:01:20 +0100 Subject: [PATCH 11/17] Add tags support to SC `I18nApi` (#32984) * chore: update ew module to 1.13.0 * feat: implement tag support in I18nApi#translate * fix: correct return type for translate * test: translate World! in i18nApi test * fix: again return type * chore: update pnpm lock --- .../{I18nApi.test.ts => I18nApi.test.tsx} | 18 +++++++++++ .../src/core/i18n/I18nApi.ts | 13 ++++++-- pnpm-lock.yaml | 30 +++++++++---------- pnpm-workspace.yaml | 2 +- 4 files changed, 45 insertions(+), 18 deletions(-) rename packages/shared-components/src/core/i18n/{I18nApi.test.ts => I18nApi.test.tsx} (54%) diff --git a/packages/shared-components/src/core/i18n/I18nApi.test.ts b/packages/shared-components/src/core/i18n/I18nApi.test.tsx similarity index 54% rename from packages/shared-components/src/core/i18n/I18nApi.test.ts rename to packages/shared-components/src/core/i18n/I18nApi.test.tsx index a9fd287c4f..75f4ae6d8a 100644 --- a/packages/shared-components/src/core/i18n/I18nApi.test.ts +++ b/packages/shared-components/src/core/i18n/I18nApi.test.tsx @@ -6,6 +6,7 @@ */ import { describe, it, expect } from "vitest"; +import React from "react"; import { I18nApi } from "./I18nApi"; @@ -20,4 +21,21 @@ describe("I18nApi", () => { expect(i18n.translate("hello.world" as TranslationKey)).toBe("Hello, World!"); }); + + it("can register a translation and use it with tags", () => { + const i18n = new I18nApi(); + i18n.register({ + ["hello.world" as TranslationKey]: { + en: "Hello, World!", + }, + }); + + expect( + i18n.translate("hello.world" as TranslationKey, {}, { Bold: (sub) => {sub} }), + ).toStrictEqual( + + Hello, World! + , + ); + }); }); diff --git a/packages/shared-components/src/core/i18n/I18nApi.ts b/packages/shared-components/src/core/i18n/I18nApi.ts index c15d1c3204..66ed14dbec 100644 --- a/packages/shared-components/src/core/i18n/I18nApi.ts +++ b/packages/shared-components/src/core/i18n/I18nApi.ts @@ -5,7 +5,12 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import { type I18nApi as II18nApi, type Variables, type Translations } from "@element-hq/element-web-module-api"; +import { + type I18nApi as II18nApi, + type Variables, + type Translations, + type Tags, +} from "@element-hq/element-web-module-api"; import { humanizeTime } from "../utils/humanize"; import { _t, getLocale, registerTranslations } from "./i18n"; @@ -41,8 +46,12 @@ export class I18nApi implements II18nApi { * Perform a translation, with optional variables * @param key - The key to translate * @param variables - Optional variables to interpolate into the translation + * @param tags - Optional tags to interpolate into the translation */ - public translate(this: void, key: TranslationKey, variables?: Variables): string { + public translate(this: void, key: TranslationKey, variables?: Variables): string; + public translate(this: void, key: TranslationKey, variables: Variables | undefined, tags: Tags): React.ReactNode; + public translate(this: void, key: TranslationKey, variables?: Variables, tags?: Tags): React.ReactNode | string { + if (tags) return _t(key, variables, tags); return _t(key, variables); } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7feb5f30f4..220a6247e4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -7,8 +7,8 @@ settings: catalogs: default: '@element-hq/element-web-module-api': - specifier: 1.12.0 - version: 1.12.0 + specifier: 1.13.0 + version: 1.13.0 '@element-hq/element-web-playwright-common': specifier: 2.4.0 version: 2.4.0 @@ -140,7 +140,7 @@ importers: version: 0.6.0 '@element-hq/element-web-playwright-common': specifier: 'catalog:' - version: 2.4.0(@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1) + version: 2.4.0(@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1) '@nx-tools/nx-container': specifier: ^7.2.1 version: 7.2.1(@nx/devkit@22.5.3(nx@22.5.4))(@nx/js@22.5.3(@babel/traverse@7.29.0)(nx@22.5.4))(dotenv@17.4.0)(nx@22.5.4)(tslib@2.8.1) @@ -336,7 +336,7 @@ importers: version: 7.28.6 '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@element-hq/web-shared-components': specifier: workspace:* version: link:../../packages/shared-components @@ -465,7 +465,7 @@ importers: version: 1.0.3 matrix-js-sdk: specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6371e4b25206dcd459d19375f5d046993ce11b1b + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1 matrix-widget-api: specifier: ^1.17.0 version: 1.17.0 @@ -598,7 +598,7 @@ importers: version: 0.18.0 '@element-hq/element-web-playwright-common': specifier: 'catalog:' - version: 2.4.0(@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1) + version: 2.4.0(@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1) '@element-hq/element-web-playwright-common-local': specifier: workspace:* version: link:../../packages/playwright-common @@ -937,7 +937,7 @@ importers: dependencies: '@element-hq/element-web-module-api': specifier: 'catalog:' - version: 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + version: 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@matrix-org/spec': specifier: ^1.7.0 version: 1.16.0 @@ -2395,8 +2395,8 @@ packages: '@element-hq/element-call-embedded@0.18.0': resolution: {integrity: sha512-Fg2VlORZWkQ9t9OJTcWFXCwVzlHVLtkaiCF0qFTCOZSYYHlA3kXDRM8TagjLkIoOVR6y+9xZldbwejgKYUS9xw==} - '@element-hq/element-web-module-api@1.12.0': - resolution: {integrity: sha512-fLhHFiL1UbRjolpgera3osHHxhSzfnDGTRhaDEv1UsrHRHwMu3hb/IcyXNqGhLXkJiuX8XoOH0aetaAUqQ0YQA==} + '@element-hq/element-web-module-api@1.13.0': + resolution: {integrity: sha512-3QXejLpXHK52e/BM61zeFQt1pnmKEfhFsooKI3OOXa5M9io683q1eA986TquZTDHoorm0Q+4TyxjYD3j2Nkp8A==} engines: {node: '>=20.0.0'} peerDependencies: '@matrix-org/react-sdk-module-api': '*' @@ -9845,8 +9845,8 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6371e4b25206dcd459d19375f5d046993ce11b1b: - resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6371e4b25206dcd459d19375f5d046993ce11b1b} + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1: + resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1} version: 41.3.0 engines: {node: '>=22.0.0'} @@ -14907,7 +14907,7 @@ snapshots: '@element-hq/element-call-embedded@0.18.0': {} - '@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': + '@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4)': dependencies: '@types/react': 19.2.10 '@types/react-dom': 19.2.3(@types/react@19.2.10) @@ -14916,10 +14916,10 @@ snapshots: '@matrix-org/react-sdk-module-api': 2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4) matrix-web-i18n: 3.6.0 - '@element-hq/element-web-playwright-common@2.4.0(@element-hq/element-web-module-api@1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1)': + '@element-hq/element-web-playwright-common@2.4.0(@element-hq/element-web-module-api@1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4))(@playwright/test@1.59.1)(playwright-core@1.59.1)': dependencies: '@axe-core/playwright': 4.11.1(playwright-core@1.59.1) - '@element-hq/element-web-module-api': 1.12.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) + '@element-hq/element-web-module-api': 1.13.0(@matrix-org/react-sdk-module-api@2.5.0(patch_hash=016146c9cc96e6363609d2b2ac0896ccef567882eb1d73b75a77b8a30929de96)(react@19.2.4))(@types/react-dom@19.2.3(@types/react@19.2.10))(@types/react@19.2.10)(matrix-web-i18n@3.6.0)(react@19.2.4) '@playwright/test': 1.59.1 '@testcontainers/postgresql': 11.11.0 glob: 13.0.6 @@ -23464,7 +23464,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/6371e4b25206dcd459d19375f5d046993ce11b1b: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f17f013f1e0d68622e9dd2f863689b7cd0ae09d1: dependencies: '@babel/runtime': 7.28.6 '@matrix-org/matrix-sdk-crypto-wasm': 18.0.0 diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml index 7251cf86ce..44f4f651b6 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -17,7 +17,7 @@ catalog: "@playwright/test": 1.59.1 "playwright-core": 1.59.1 # Module API - "@element-hq/element-web-module-api": 1.12.0 + "@element-hq/element-web-module-api": 1.13.0 # Compound "@vector-im/compound-design-tokens": 8.0.0 "@vector-im/compound-web": 8.4.0 From 70f26f914273efe1760f722b962e79b5224931af Mon Sep 17 00:00:00 2001 From: Andy Balaam Date: Thu, 9 Apr 2026 16:25:42 +0100 Subject: [PATCH 12/17] Separate cases in DeviceListener (#32973) * Separate cases in DeviceListener According to the comment in `else` there were two ways to end up there. Split these into separate cases and provide a different log message in each case. If we somehow get there another way, throw an error. * Replace a throw with an error log --- .../DeviceListenerCurrentDevice.ts | 22 +++++++++++-------- 1 file changed, 13 insertions(+), 9 deletions(-) diff --git a/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts b/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts index 7dbb976489..0460d82366 100644 --- a/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts +++ b/apps/web/src/device-listener/DeviceListenerCurrentDevice.ts @@ -228,21 +228,25 @@ export class DeviceListenerCurrentDevice { logSpan.info("No default 4S key but backup disabled: no toast needed"); await this.setDeviceState("ok", logSpan); } - } else { - // If we get here, then we are verified, have key backup, and - // 4S, but allSystemsReady is false, which means that either - // secretStorageStatus.ready is false (which means that 4S - // doesn't have all the secrets), or we don't have the backup - // key cached locally. If any of the cross-signing keys are - // missing locally, that is handled by the - // `!allCrossSigningSecretsCached` branch above. - logSpan.warn("4S is missing secrets or backup key not cached", { + } else if (!recoveryIsOk) { + logSpan.warn("4S is missing secrets: setting state to KEY_STORAGE_OUT_OF_SYNC", { secretStorageStatus, allCrossSigningSecretsCached, isCurrentDeviceTrusted, keyBackupDownloadIsOk, }); await this.setDeviceState("key_storage_out_of_sync", logSpan); + } else if (!keyBackupDownloadIsOk) { + logSpan.warn("Backup key is not cached locally: setting state to KEY_STORAGE_OUT_OF_SYNC", { + secretStorageStatus, + allCrossSigningSecretsCached, + isCurrentDeviceTrusted, + keyBackupDownloadIsOk, + }); + await this.setDeviceState("key_storage_out_of_sync", logSpan); + } else { + // We should not get here + logSpan.error("DeviceListenerCurrentDevice: allSystemsReady was false, but no case matched."); } } } From ca6943cb4388a700d0bd27cc7da886e085f3a09d Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Thu, 9 Apr 2026 22:41:03 +0100 Subject: [PATCH 13/17] Fix 'test' lines in codeowners (#33083) * Fix 'test' lines in codeowners Some of the unit tests are meant to be owned by the crypto team, but the paths were wrong, so this didn't work. This seems to have been broken since b084ff2313aedd7d531331827cf8aad02cfc064b, which moved all the tests around. * another fix --- .github/CODEOWNERS | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 5a762fe224..4d173ab222 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -4,12 +4,13 @@ /pnpm-lock.yaml @element-hq/element-web-team /apps/web/src/SecurityManager.ts @element-hq/element-crypto-web-reviewers -/apps/web/test/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers +/apps/web/test/unit-tests/SecurityManager-test.ts @element-hq/element-crypto-web-reviewers /apps/web/src/async-components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/apps/web/test/unit-tests/async-components/dialogs/security/ @element-hq/element-crypto-web-reviewers /apps/web/src/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers -/apps/web/test/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers +/apps/web/test/unit-tests/components/views/dialogs/security/ @element-hq/element-crypto-web-reviewers /apps/web/src/stores/SetupEncryptionStore.ts @element-hq/element-crypto-web-reviewers -/apps/web/test/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers +/apps/web/test/unit-tests/stores/SetupEncryptionStore-test.ts @element-hq/element-crypto-web-reviewers /apps/web/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @element-hq/element-crypto-web-reviewers /apps/web/src/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers /apps/web/test/unit-tests/components/views/settings/encryption/ @element-hq/element-crypto-web-reviewers From 4c4bfcde7eb430705add0be0627ed1fca9504b68 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Thu, 9 Apr 2026 23:11:12 +0100 Subject: [PATCH 14/17] Inline `inviteMultipleToRoom` (#33027) This two-line method serves mostly to obfuscate, imho. Let's get rid of it. --- apps/web/src/RoomInvite.tsx | 32 ++---------------- .../components/structures/SpaceRoomView.tsx | 10 +++--- .../components/views/dialogs/InviteDialog.tsx | 16 ++++++--- apps/web/src/utils/RoomUpgrade.ts | 9 ++--- apps/web/test/unit-tests/RoomInvite-test.ts | 33 ------------------- 5 files changed, 25 insertions(+), 75 deletions(-) delete mode 100644 apps/web/test/unit-tests/RoomInvite-test.ts diff --git a/apps/web/src/RoomInvite.tsx b/apps/web/src/RoomInvite.tsx index feefdf7244..0b789a5766 100644 --- a/apps/web/src/RoomInvite.tsx +++ b/apps/web/src/RoomInvite.tsx @@ -7,9 +7,10 @@ Please see LICENSE files in the repository root for full details. */ import React, { type ComponentProps } from "react"; -import { EventType, type MatrixClient, type MatrixEvent, type Room, type User } from "matrix-js-sdk/src/matrix"; +import { EventType, type MatrixEvent, type Room, type User } from "matrix-js-sdk/src/matrix"; -import MultiInviter, { type CompletionStates, type MultiInviterOptions } from "./utils/MultiInviter"; +import type MultiInviter from "./utils/MultiInviter"; +import { type CompletionStates } from "./utils/MultiInviter"; import Modal from "./Modal"; import { _t } from "./languageHandler"; import InviteDialog from "./components/views/dialogs/InviteDialog"; @@ -19,33 +20,6 @@ import ErrorDialog from "./components/views/dialogs/ErrorDialog"; import { InviteKind } from "./components/views/dialogs/InviteDialogTypes"; import { type Member } from "./utils/direct-messages"; -export interface IInviteResult { - states: CompletionStates; - inviter: MultiInviter; -} - -/** - * Invites multiple addresses to a room. - * - * Simpler interface to {@link MultiInviter}. - * - * Any failures are returned via the `states` in the result. - * - * @param {string} roomId The ID of the room to invite to - * @param {string[]} addresses Array of strings of addresses to invite. May be matrix IDs or 3pids. - * @param options Options object. - * @returns {Promise} Promise - */ -export async function inviteMultipleToRoom( - client: MatrixClient, - roomId: string, - addresses: string[], - options: MultiInviterOptions = {}, -): Promise { - const inviter = new MultiInviter(client, roomId, options); - return { states: await inviter.invite(addresses), inviter }; -} - export function showStartChatInviteDialog(initialText = ""): void { // This dialog handles the room creation internally - we don't need to worry about it. Modal.createDialog( diff --git a/apps/web/src/components/structures/SpaceRoomView.tsx b/apps/web/src/components/structures/SpaceRoomView.tsx index 7b7b79c126..9bb04ce552 100644 --- a/apps/web/src/components/structures/SpaceRoomView.tsx +++ b/apps/web/src/components/structures/SpaceRoomView.tsx @@ -34,7 +34,7 @@ import { useFeatureEnabled } from "../../hooks/useSettings"; import { useStateArray } from "../../hooks/useStateArray"; import { _t } from "../../languageHandler"; import PosthogTrackers from "../../PosthogTrackers"; -import { inviteMultipleToRoom, showRoomInviteDialog } from "../../RoomInvite"; +import { showRoomInviteDialog } from "../../RoomInvite"; import { UIComponent } from "../../settings/UIFeature"; import { UPDATE_EVENT } from "../../stores/AsyncStore"; import RightPanelStore from "../../stores/right-panel/RightPanelStore"; @@ -76,6 +76,7 @@ import SpaceHierarchy, { showRoom } from "./SpaceHierarchy"; import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; import SpacePillButton from "./SpacePillButton.tsx"; import { useRoomName } from "../../hooks/useRoomName.ts"; +import MultiInviter from "../../utils/MultiInviter.ts"; interface IProps { space: Room; @@ -538,11 +539,12 @@ const SpaceSetupPrivateInvite: React.FC<{ setBusy(true); const targetIds = emailAddresses.map((name) => name.trim()).filter(Boolean); try { - const result = await inviteMultipleToRoom(space.client, space.roomId, targetIds); + const inviter = new MultiInviter(space.client, space.roomId); + const states = await inviter.invite(targetIds); - const failedUsers = Object.keys(result.states).filter((a) => result.states[a] === "error"); + const failedUsers = Object.keys(states).filter((a) => states[a] === "error"); if (failedUsers.length > 0) { - logger.log("Failed to invite users to space: ", result); + logger.log("Failed to invite users to space:", states); setError( _t("create_space|failed_invite_users", { csvUsers: failedUsers.join(", "), diff --git a/apps/web/src/components/views/dialogs/InviteDialog.tsx b/apps/web/src/components/views/dialogs/InviteDialog.tsx index f23aadcdad..8a9c36e5de 100644 --- a/apps/web/src/components/views/dialogs/InviteDialog.tsx +++ b/apps/web/src/components/views/dialogs/InviteDialog.tsx @@ -25,7 +25,7 @@ import { getDefaultIdentityServerUrl, setToDefaultIdentityServer } from "../../. import { buildActivityScores, buildMemberScores, compareMembers } from "../../../utils/SortMembers"; import { abbreviateUrl } from "../../../utils/UrlUtils"; import IdentityAuthClient from "../../../IdentityAuthClient"; -import { type IInviteResult, inviteMultipleToRoom, showAnyInviteErrors } from "../../../RoomInvite"; +import { showAnyInviteErrors } from "../../../RoomInvite"; import { Action } from "../../../dispatcher/actions"; import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag"; import RoomListStore from "../../../stores/room-list/RoomListStore"; @@ -63,6 +63,7 @@ import { type NonEmptyArray } from "../../../@types/common"; import { SdkContextClass } from "../../../contexts/SDKContext"; import { type UserProfilesStore } from "../../../stores/UserProfilesStore"; import InviteProgressBody from "./InviteProgressBody.tsx"; +import MultiInviter, { type CompletionStates as MultiInviterCompletionStates } from "../../../utils/MultiInviter.ts"; // we have a number of types defined from the Matrix spec which can't reasonably be altered here. /* eslint-disable camelcase */ @@ -409,10 +410,14 @@ export default class InviteDialog extends React.PureComponent ({ userId: member.userId, user: toMember(member) })); } - private shouldAbortAfterInviteError(result: IInviteResult, room: Room): boolean { + private shouldAbortAfterInviteError( + states: MultiInviterCompletionStates, + inviter: MultiInviter, + room: Room, + ): boolean { this.setState({ busy: false }); const userMap = new Map(this.state.targets.map((member) => [member.userId, member])); - return !showAnyInviteErrors(result.states, room, result.inviter, userMap); + return !showAnyInviteErrors(states, room, inviter, userMap); } private convertFilter(): Member[] { @@ -483,11 +488,12 @@ export default class InviteDialog extends React.PureComponent { - const result = await inviteMultipleToRoom(client, roomId, userIds, inviteOptions); + const inviter = new MultiInviter(client, roomId, inviteOptions); + const states = await inviter.invite(userIds); const room = client.getRoom(roomId)!; - showAnyInviteErrors(result.states, room, result.inviter); + showAnyInviteErrors(states, room, inviter); } diff --git a/apps/web/test/unit-tests/RoomInvite-test.ts b/apps/web/test/unit-tests/RoomInvite-test.ts deleted file mode 100644 index 32ef8dc73f..0000000000 --- a/apps/web/test/unit-tests/RoomInvite-test.ts +++ /dev/null @@ -1,33 +0,0 @@ -/* -Copyright 2025 The Matrix.org Foundation C.I.C. - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. -*/ - -import { getMockClientWithEventEmitter } from "../test-utils"; -import { inviteMultipleToRoom } from "../../src/RoomInvite.tsx"; - -afterEach(() => { - jest.restoreAllMocks(); -}); - -describe("inviteMultipleToRoom", () => { - it("can be called wth no `options`", async () => { - const client = getMockClientWithEventEmitter({}); - const { states, inviter } = await inviteMultipleToRoom(client, "!room:id", []); - expect(states).toEqual({}); - - // @ts-ignore reference to private property - expect(inviter.options).toEqual({}); - }); -}); From b860a3864d88c67ae09b8170332b36b731f06198 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 10 Apr 2026 12:50:12 +0100 Subject: [PATCH 15/17] Improve output of playwright-screenshots script (#33098) * Improve output of playwright-screenshots script * Address review feedback --- packages/playwright-common/playwright-screenshots.sh | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/playwright-common/playwright-screenshots.sh b/packages/playwright-common/playwright-screenshots.sh index 7fc1a1fa33..ecf962f112 100755 --- a/packages/playwright-common/playwright-screenshots.sh +++ b/packages/playwright-common/playwright-screenshots.sh @@ -19,8 +19,15 @@ WS_PORT=3000 PW_VERSION=$(pnpm --silent -- playwright --version | awk '{print $2}') IMAGE_NAME="ghcr.io/element-hq/element-web/playwright-server:$PW_VERSION" -# Pull the image, failing that build the image -docker pull "$IMAGE_NAME" 2>/dev/null || build_image "$IMAGE_NAME" +# If the image exists in the repository, pull it; otherwise, build it. +# +# (This explicit test gives the user clearer progress info than just +# `docker pull 2>/dev/null || build_image`.) +if docker manifest inspect "$IMAGE_NAME" &>/dev/null; then + docker pull "$IMAGE_NAME" +else + build_image "$IMAGE_NAME" +fi # Start the playwright-server in docker CONTAINER=$(docker run --network=host -v /tmp:/tmp --rm -d -e PORT="$WS_PORT" "$IMAGE_NAME") From a132b9167dff54aaf603b669ac0dc46f05d50d96 Mon Sep 17 00:00:00 2001 From: Richard van der Hoff <1389908+richvdh@users.noreply.github.com> Date: Fri, 10 Apr 2026 13:31:03 +0100 Subject: [PATCH 16/17] Fix playwright-server docker image not exiting (#33099) * Fix playwright-server docker image not exiting ... by wrapping with tini * Remove redundant `npm exec` * Update packages/playwright-common/Dockerfile * missing comma --- packages/playwright-common/Dockerfile | 11 ++++++++++- packages/playwright-common/docker-entrypoint.sh | 4 +--- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/playwright-common/Dockerfile b/packages/playwright-common/Dockerfile index 20bb7713a3..093d9dcf82 100644 --- a/packages/playwright-common/Dockerfile +++ b/packages/playwright-common/Dockerfile @@ -12,4 +12,13 @@ RUN npm i -g playwright@${PLAYWRIGHT_VERSION} COPY docker-entrypoint.sh /docker-entrypoint.sh -ENTRYPOINT ["/docker-entrypoint.sh"] +# We use `docker-init` as PID 1, which means that the container shuts down correctly on SIGTERM. +# +# (The problem is that PID 1 doesn't get default signal handlers, and +# playwright server doesn't register a SIGTERM handler, so if that ends up as +# PID 1, then it ignores SIGTERM. Likewise bash doesn't set a SIGTERM handler by default. +# +# The easiest solution is to use docker-init, which is in fact `tini` (https://github.com/krallin/tini). +# +# See https://github.com/krallin/tini/issues/8#issuecomment-146135930 for a good explanation of all this.) +ENTRYPOINT ["/usr/bin/docker-init", "/docker-entrypoint.sh"] diff --git a/packages/playwright-common/docker-entrypoint.sh b/packages/playwright-common/docker-entrypoint.sh index 66a71fcd58..b615bf24d6 100755 --- a/packages/playwright-common/docker-entrypoint.sh +++ b/packages/playwright-common/docker-entrypoint.sh @@ -1,4 +1,2 @@ #!/bin/bash - -# We use npm here as we used `npm i -g` to install playwright in the Dockerfile -npm exec -- playwright run-server --port "$PORT" --host 0.0.0.0 +exec /usr/bin/playwright run-server --port "$PORT" --host 0.0.0.0 From b97a0be0fd28946be4b2e4a693306e592eec4e46 Mon Sep 17 00:00:00 2001 From: Michael Telatynski <7t3chguy@gmail.com> Date: Fri, 10 Apr 2026 15:37:45 +0100 Subject: [PATCH 17/17] Generalise npm publishing workflow to work for more than just shared-components (#33086) * Generalise npm publishing workflow to work for more than just shared-components * Update doc --- ...component-publish.yaml => npm-publish.yaml} | 18 +++++++++++++----- packages/shared-components/README.md | 2 +- 2 files changed, 14 insertions(+), 6 deletions(-) rename .github/workflows/{shared-component-publish.yaml => npm-publish.yaml} (69%) diff --git a/.github/workflows/shared-component-publish.yaml b/.github/workflows/npm-publish.yaml similarity index 69% rename from .github/workflows/shared-component-publish.yaml rename to .github/workflows/npm-publish.yaml index c728c303d5..708d233d26 100644 --- a/.github/workflows/shared-component-publish.yaml +++ b/.github/workflows/npm-publish.yaml @@ -1,6 +1,15 @@ -name: Publish shared component npm package +name: Publish npm package +run-name: Publish ${{ inputs.package }} on: - workflow_dispatch: {} + workflow_dispatch: + inputs: + package: + description: Which package to release + required: true + type: choice + options: + - playwright-common + - shared-components concurrency: release jobs: @@ -29,10 +38,9 @@ jobs: - name: Update npm run: npm install -g npm@latest - # Need to setup element web too as it needs the translations - - name: 🛠️ Setup EW + - name: 🛠️ Install dependencies run: pnpm install --frozen-lockfile - name: 🚀 Publish to npm - working-directory: packages/shared-components + working-directory: packages/${{ inputs.package }} run: npm publish --access public --provenance diff --git a/packages/shared-components/README.md b/packages/shared-components/README.md index 8af1621ff2..138a458197 100644 --- a/packages/shared-components/README.md +++ b/packages/shared-components/README.md @@ -365,4 +365,4 @@ pnpm i18n Two steps are required to publish a new version of this package: 1. Bump the version in `package.json` following semver rules and open a PR. -2. Once merged run the [github workflow](https://github.com/element-hq/element-web/actions/workflows/shared-component-publish.yaml) +2. Once merged run the [github workflow](https://github.com/element-hq/element-web/actions/workflows/npm-publish.yaml)