Merge remote-tracking branch 'origin/develop' into hs/enable-profile-updates

This commit is contained in:
David Baker 2026-04-16 12:47:17 +01:00
commit 06bea86247
36 changed files with 1197 additions and 53 deletions

View File

@ -111,7 +111,7 @@ jobs:
running-workflow-name: "Build & Deploy develop.element.io"
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 10
check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages|Upload|Netlify).)*$
check-regexp: ^((?!SonarCloud|SonarQube|issue|board|label|Release|prepare|GitHub Pages|Upload|Netlify|Report).)*$
# We keep the latest develop.tar.gz on R2 instead of relying on the github artifact uploaded earlier
# as the expires after 24h and requires auth to download.

View File

@ -26,7 +26,7 @@ jobs:
path: storybook-static
- name: 🚀 Deploy to Cloudflare Pages
uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3
uses: cloudflare/wrangler-action@9acf94ace14e7dc412b076f2c5c20b8ce93c79cd # v3
with:
apiToken: ${{ secrets.CF_PAGES_TOKEN }}
accountId: ${{ secrets.CF_PAGES_ACCOUNT_ID }}

View File

@ -105,7 +105,7 @@
"typescript": "5.9.3"
},
"hakDependencies": {
"matrix-seshat": "4.0.1"
"matrix-seshat": "4.2.0"
},
"packageManager": "pnpm@10.33.0+sha512.10568bb4a6afb58c9eb3630da90cc9516417abebd3fabbe6739f0ae795728da1491e9db5a544c76ad8eb7570f5c4bb3d6c637b2cb41bfdcdb47fa823c8649319"
}

View File

@ -277,9 +277,10 @@ export async function attemptDelegatedAuthLogin(
defaultDeviceDisplayName?: string,
fragmentAfterLogin?: string,
): Promise<boolean> {
if (urlParams.oidc) {
console.log("We have OIDC params - attempting OIDC login");
return attemptOidcNativeLogin(urlParams["oidc"]);
if (urlParams.oidc_fragment) {
return attemptOidcNativeLogin(urlParams.oidc_fragment, "fragment");
} else if (urlParams.oidc_query) {
return attemptOidcNativeLogin(urlParams.oidc_query, "query");
}
return attemptTokenLogin(urlParams["legacy_sso"], defaultDeviceDisplayName, fragmentAfterLogin);
@ -288,12 +289,18 @@ export async function attemptDelegatedAuthLogin(
/**
* Attempt to login by completing OIDC authorization code flow
* @param urlParams subset of app-load url parameters relating to oidc auth
* @param responseMode - the response_mode used in the auth request
* @returns Promise that resolves to true when login succeeded, else false
*/
async function attemptOidcNativeLogin(urlParams: NonNullable<URLParams["oidc"]>): Promise<boolean> {
async function attemptOidcNativeLogin(
urlParams: NonNullable<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): Promise<boolean> {
console.log("We have OIDC params - attempting OIDC login");
try {
const { accessToken, refreshToken, homeserverUrl, identityServerUrl, idToken, clientId, issuer } =
await completeOidcLogin(urlParams);
await completeOidcLogin(urlParams, responseMode);
const {
user_id: userId,
@ -1036,7 +1043,7 @@ export function isLoggingOut(): boolean {
* By the time this method is called, we have successfully logged in if necessary, and the client has been set up with
* the access token.
*
* Emits {@link Acction.WillStartClient} before starting the client, and {@link Action.ClientStarted} when the client has
* Emits {@link Action.WillStartClient} before starting the client, and {@link Action.ClientStarted} when the client has
* been started.
*
* @param client the matrix client to start

View File

@ -350,7 +350,11 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
);
// remove the loginToken or auth code from the URL regardless
if (!!this.props.urlParams.legacy_sso || !!this.props.urlParams.oidc) {
if (
!!this.props.urlParams.legacy_sso ||
!!this.props.urlParams.oidc_fragment ||
!!this.props.urlParams.oidc_query
) {
this.props.onTokenLoginCompleted(this.props.urlParams, this.getFragmentAfterLogin());
}
@ -408,7 +412,7 @@ export default class MatrixChat extends React.PureComponent<IProps, IState> {
* {@link onWillStartClient} and {@link onClientStarted} will already have been called (but not necessarily
* completed).
*
* This method either calls {@link onLiggedIn} directly, or switches to {@link Views.E2E_SETUP} or
* This method either calls {@link onLoggedIn} directly, or switches to {@link Views.E2E_SETUP} or
* {@link Views.COMPLETE_SECURITY}, which will later call {@link onCompleteSecurityE2eSetupFinished}.
*/
private async postLoginSetup(): Promise<void> {

View File

@ -65,9 +65,6 @@ 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 */
interface Result {
userId: string;
user: Member;
@ -1186,8 +1183,7 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
}
/**
* Render content of the common "users" tab that is shown whether we have a regular invite dialog or a
* "CallTransfer" one.
* Render content of the "users" that is used for both invites and "start chat".
*/
private renderMainTab(): JSX.Element {
let helpText;
@ -1275,26 +1271,23 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
buttonText = _t("action|invite");
goButtonFn = this.inviteUsers;
} else {
throw new Error("Unknown InviteDialog kind: " + this.props.kind);
}
const goButton =
this.props.kind == InviteKind.CallTransfer ? null : (
<AccessibleButton
kind="primary"
onClick={goButtonFn}
className="mx_InviteDialog_goButton"
disabled={this.state.busy || !this.hasSelection()}
>
{buttonText}
</AccessibleButton>
);
return (
<React.Fragment>
<p className="mx_InviteDialog_helpText">{helpText}</p>
<div className="mx_InviteDialog_addressBar">
{this.renderEditor()}
{goButton}
<AccessibleButton
kind="primary"
onClick={goButtonFn}
className="mx_InviteDialog_goButton"
disabled={this.state.busy || !this.hasSelection()}
>
{buttonText}
</AccessibleButton>
</div>
{this.state.busy ? <InviteProgressBody /> : this.renderSuggestions()}
</React.Fragment>
@ -1342,7 +1335,12 @@ export default class InviteDialog extends React.PureComponent<Props, IInviteDial
* See also: {@link renderRegularDialog}.
*/
private renderCallTransferDialog(): React.ReactNode {
const usersSection = this.renderMainTab();
const usersSection = (
<React.Fragment>
<div className="mx_InviteDialog_addressBar">{this.renderEditor()}</div>
{this.state.busy ? <InviteProgressBody /> : this.renderSuggestions()}
</React.Fragment>
);
const tabs: NonEmptyArray<Tab<TabId>> = [
new Tab(

View File

@ -23,6 +23,7 @@ import { type URLParams } from "../../vector/url_utils.ts";
* @param clientId this client's id as registered with configured issuer
* @param homeserverUrl target homeserver
* @param identityServerUrl OPTIONAL target identity server
* @param isRegistration if true will set the prompt to "create"
* @returns Promise that resolves after we have navigated to auth endpoint
*/
export const startOidcLogin = async (
@ -47,7 +48,7 @@ export const startOidcLogin = async (
nonce,
prompt,
urlState: PlatformPeg.get()?.getOidcClientState(),
responseMode: "fragment",
responseMode: delegatedAuthConfig.response_modes_supported?.includes("fragment") ? "fragment" : "query",
});
window.location.href = authorizationUrl;
@ -57,15 +58,20 @@ export const startOidcLogin = async (
* Gets `code` and `state` response params
*
* @param urlParams - the parameters to read
* @param responseMode - the response_mode used in the auth request
* @returns code and state
* @throws when code and state are not valid strings
*/
const getCodeAndStateFromParams = ({
code,
state,
}: NonNullable<URLParams["oidc"]>): { code: string; state: string } => {
const getCodeAndStateFromParams = (
{ code, state }: NonNullable<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): { code: string; state: string } => {
if (!code || typeof code !== "string" || !state || typeof state !== "string") {
throw new Error(OidcClientError.InvalidQueryParameters);
if (responseMode === "fragment") {
throw new Error(OidcClientError.InvalidFragmentParameters);
} else {
throw new Error(OidcClientError.InvalidQueryParameters);
}
}
return { code, state };
};
@ -91,15 +97,17 @@ type CompleteOidcLoginResponse = {
/**
* Attempt to complete authorization code flow to get an access token
* @param urlParams the parameters extracted from the app-load URI.
* @param responseMode - the response_mode used in the auth request
* @returns Promise that resolves with a CompleteOidcLoginResponse when login was successful
* @throws When we failed to get a valid access token
*/
export const completeOidcLogin = async (
urlParams: NonNullable<URLParams["oidc"]>,
urlParams: NonNullable<URLParams["oidc_fragment"]>,
responseMode: "fragment" | "query",
): Promise<CompleteOidcLoginResponse> => {
const { code, state } = getCodeAndStateFromParams(urlParams);
const { code, state } = getCodeAndStateFromParams(urlParams, responseMode);
const { homeserverUrl, tokenResponse, idTokenClaims, identityServerUrl, oidcClientSettings } =
await completeAuthorizationCodeGrant(code, state, "fragment");
await completeAuthorizationCodeGrant(code, state, responseMode);
return {
homeserverUrl,

View File

@ -17,6 +17,7 @@ import { _t } from "../../languageHandler";
*/
export enum OidcClientError {
InvalidQueryParameters = "Invalid query parameters for OIDC native login. `code` and `state` are required.",
InvalidFragmentParameters = "Invalid fragment parameters for OIDC native login. `code` and `state` are required.",
}
/**
@ -30,6 +31,7 @@ export const getOidcErrorMessage = (error: Error): string | ReactNode => {
case OidcError.MissingOrInvalidStoredState:
return _t("auth|oidc|missing_or_invalid_stored_state");
case OidcClientError.InvalidQueryParameters:
case OidcClientError.InvalidFragmentParameters:
case OidcError.CodeExchangeFailed:
case OidcError.InvalidBearerTokenResponse:
case OidcError.InvalidIdToken:

View File

@ -44,7 +44,7 @@ function onTokenLoginCompleted(urlParams: URLParams, fragmentAfterLogin: string)
const url = new URL(window.location.href);
// if we did a token login, we're now left with the login token as query param in the url; clear it out
for (const param in { ...urlParams.legacy_sso }) {
for (const param in { ...urlParams.legacy_sso, ...urlParams.oidc_query }) {
url.searchParams.delete(param);
}
@ -112,7 +112,7 @@ export async function loadApp(urlParams: URLParams, matrixChatRef: React.Ref<Mat
// Before we continue, let's see if we're supposed to do an SSO redirect
const [userId] = await Lifecycle.getStoredSessionOwner();
const hasPossibleToken = !!userId;
const isReturningFromSso = !!urlParams.legacy_sso || !!urlParams.oidc;
const isReturningFromSso = !!urlParams.legacy_sso || !!urlParams.oidc_fragment || !!urlParams.oidc_query;
const ssoRedirects = config.sso_redirect_options || {};
let autoRedirect = ssoRedirects.immediate === true;
// XXX: This path matching is a bit brittle, but better to do it early instead of in the app code.

View File

@ -55,10 +55,15 @@ const urlParameterConfig = {
location: "query",
},
// Fragment params for OIDC login, added by the Identity Provider
oidc: {
oidc_fragment: {
keys: ["code", "state"],
location: "fragment",
},
// Query params for OIDC login, added by the Identity Provider, used as fallback when fragment is unsupported
oidc_query: {
keys: ["code", "state"],
location: "query",
},
// Fragment params relating to 3pid (email) invites, added in url within the invite email itself
threepid: {
keys: ["client_secret", "session_id", "hs_url", "is_url", "sid"],

View File

@ -321,7 +321,7 @@ describe("<MatrixChat />", () => {
const code = "test-oidc-auth-code";
const state = "test-oidc-state";
const urlParams = {
oidc: {
oidc_fragment: {
code,
state: state,
},
@ -386,7 +386,7 @@ describe("<MatrixChat />", () => {
it("should fail when query params do not include valid code and state", async () => {
const urlParams = {
oidc: {
oidc_query: {
code: "",
state: "abc",
},

View File

@ -75,7 +75,7 @@ describe("OIDC authorization", () => {
const authUrl = new URL(window.location.href);
expect(authUrl.searchParams.get("response_mode")).toEqual("fragment");
expect(authUrl.searchParams.get("response_mode")).toEqual("query");
expect(authUrl.searchParams.get("response_type")).toEqual("code");
expect(authUrl.searchParams.get("client_id")).toEqual(clientId);
expect(authUrl.searchParams.get("code_challenge_method")).toEqual("S256");
@ -90,6 +90,18 @@ describe("OIDC authorization", () => {
expect(authUrl.searchParams.has("nonce")).toBeTruthy();
expect(authUrl.searchParams.has("code_challenge")).toBeTruthy();
});
it("should prefer response_mode fragment if supported", async () => {
await startOidcLogin(
{ ...delegatedAuthConfig, response_modes_supported: ["query", "fragment"] },
clientId,
homeserverUrl,
);
const authUrl = new URL(window.location.href);
expect(authUrl.searchParams.get("response_mode")).toEqual("fragment");
});
});
describe("completeOidcLogin()", () => {
@ -131,19 +143,19 @@ describe("OIDC authorization", () => {
});
it("should throw when query params do not include state and code", async () => {
await expect(async () => await completeOidcLogin({})).rejects.toThrow(
await expect(async () => await completeOidcLogin({}, "query")).rejects.toThrow(
OidcClientError.InvalidQueryParameters,
);
});
it("should make request complete authorization code grant", async () => {
await completeOidcLogin(params);
await completeOidcLogin(params, "fragment");
expect(completeAuthorizationCodeGrant).toHaveBeenCalledWith(code, state, "fragment");
});
it("should return accessToken, configured homeserver and identityServer", async () => {
const result = await completeOidcLogin(params);
const result = await completeOidcLogin(params, "query");
expect(result).toEqual({
accessToken: tokenResponse.access_token,

View File

@ -76,7 +76,7 @@ describe("sso_redirect_options", () => {
});
it("should redirect for native OIDC", async () => {
const authConfig = makeDelegatedAuthConfig(issuer);
const authConfig = { ...makeDelegatedAuthConfig(issuer), response_modes_supported: ["query", "fragment"] };
fetchMock.get("https://synapse/_matrix/client/v1/auth_metadata", authConfig);
fetchMock.get(`${authConfig.issuer}.well-known/openid-configuration`, authConfig);
fetchMock.get(authConfig.jwks_uri!, { keys: [] });

View File

@ -43,11 +43,18 @@ describe("parseUrlParameters", () => {
expect(parsed.params.legacy_sso?.loginToken).toEqual("foobar");
});
it("should parse oidc parameters from oauth-fragment", () => {
it("should parse oidc parameters from fragment", () => {
const u = new URL("https://app.element.io/#code=foobar&state=barfoo");
const parsed = parseAppUrl(u);
expect(parsed.params.oidc?.code).toEqual("foobar");
expect(parsed.params.oidc?.state).toEqual("barfoo");
expect(parsed.params.oidc_fragment?.code).toEqual("foobar");
expect(parsed.params.oidc_fragment?.state).toEqual("barfoo");
});
it("should parse oidc parameters from query", () => {
const u = new URL("https://app.element.io/?code=foobar&state=barfoo");
const parsed = parseAppUrl(u);
expect(parsed.params.oidc_query?.code).toEqual("foobar");
expect(parsed.params.oidc_query?.state).toEqual("barfoo");
});
it("should parse guest parameters", () => {

View File

@ -14,6 +14,12 @@
"cwd": "packages/module-api"
}
},
"start": {
"command": "vite build --watch",
"options": { "cwd": "packages/module-api" },
"dependsOn": ["^start"],
"continuous": true
},
"lint:types": {
"command": "pnpm exec tsc --noEmit",
"options": { "cwd": "packages/module-api" }

View File

@ -63,6 +63,7 @@
"linkifyjs": "4.3.2",
"lodash": "npm:lodash-es@^4.17.21",
"matrix-web-i18n": "catalog:",
"react-blurhash": "^0.3.0",
"react-merge-refs": "^3.0.2",
"react-resizable-panels": "^4.6.5",
"react-virtuoso": "^4.14.0",

View File

@ -13,6 +13,7 @@
"start": {
"command": "vite build --watch",
"options": { "cwd": "packages/shared-components" },
"dependsOn": ["^start"],
"continuous": true
},
"typedoc": {

View File

@ -34,6 +34,7 @@
"common": {
"attachment": "Attachment",
"encryption_enabled": "Encryption enabled",
"loading": "Loading…",
"options": "Options",
"preferences": "Preferences",
"state_encryption_enabled": "Experimental state encryption enabled"

View File

@ -17,6 +17,7 @@ export * from "./room/timeline/ReadMarker";
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/MImageBodyView";
export * from "./room/timeline/event-tile/body/MVideoBodyView";
export * from "./room/timeline/event-tile/body/TextualBodyView";
export * from "./room/timeline/event-tile/EventTileView/TileErrorView";

View File

@ -0,0 +1,144 @@
/*
* 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 {
display: flex;
flex-direction: column;
gap: var(--cpd-space-2x);
min-width: var(--cpd-space-0x);
}
.link {
display: block;
width: fit-content;
color: inherit;
text-decoration: none;
}
.thumbnailContainer {
position: relative;
overflow: hidden;
contain: paint;
border-radius: var(--MBody-border-radius);
}
.placeholder {
position: absolute;
inset: var(--cpd-space-0x);
display: flex;
align-items: center;
justify-content: center;
background-color: var(--cpd-color-bg-canvas-default);
z-index: 1;
}
.placeholderBlurhash {
background-color: transparent;
}
.blurhash {
width: 100%;
height: 100%;
}
.blurhash > canvas {
width: 100%;
height: 100%;
animation: blurhashPulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1);
}
.mediaContent {
position: relative;
max-width: 100%;
max-height: 100%;
}
.image {
display: block;
width: 100%;
height: 100%;
}
.banner {
position: absolute;
bottom: var(--cpd-space-2x);
left: var(--cpd-space-2x);
max-width: min(100%, 350px);
overflow: hidden;
padding: var(--cpd-space-1x);
border-radius: var(--MBody-border-radius);
background-color: rgb(0 0 0 / 0.6);
color: #fff;
text-overflow: ellipsis;
white-space: nowrap;
font: var(--cpd-font-body-sm-regular);
user-select: none;
pointer-events: none;
}
.gifLabel {
position: absolute;
display: block;
top: var(--cpd-space-0x);
left: 14px; /* Preserve the original GIF badge offset from _MImageBody.pcss. */
padding: 5px; /* Preserve the original GIF badge padding from _MImageBody.pcss. */
border-radius: 5px; /* Preserve the original GIF badge corner radius from _MImageBody.pcss. */
background: rgba(0, 0, 0, 0.7);
border: 2px solid rgba(0, 0, 0, 0.2);
color: rgba(255, 255, 255, 1);
pointer-events: none;
}
.hiddenButton {
border: none;
width: 100%;
height: 100%;
padding: var(--cpd-space-0x);
inset: var(--cpd-space-0x);
display: flex;
align-items: center;
justify-content: center;
text-align: center;
cursor: pointer;
background-color: var(--cpd-color-bg-subtle-secondary);
}
.hiddenButton:hover,
.hiddenButton:focus-visible {
background-color: var(--cpd-color-bg-canvas-default);
}
.hiddenButtonContent {
display: flex;
color: var(--cpd-color-text-action-accent);
}
.hiddenButtonContent > svg {
margin-top: auto;
margin-bottom: auto;
}
.error {
display: block;
color: var(--cpd-color-text-critical-primary);
}
.errorIcon {
margin-right: var(--cpd-space-1x);
vertical-align: text-top;
}
@keyframes blurhashPulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.65;
}
}

View File

@ -0,0 +1,157 @@
/*
* 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 { expect, fn, userEvent, within } from "storybook/test";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { useMockedViewModel } from "../../../../../core/viewmodel/useMockedViewModel";
import { withViewDocs } from "../../../../../../.storybook/withViewDocs";
import {
ImageBodyView,
ImageBodyViewPlaceholder,
ImageBodyViewState,
type ImageBodyViewActions,
type ImageBodyViewSnapshot,
} from "./ImageBodyView";
const imageSrc = new URL("../../../../../../static/image-body/install-spinner.png", import.meta.url).href;
const thumbnailSrc = new URL("../../../../../../static/image-body/install-spinner.png", import.meta.url).href;
const animatedGifSrc = new URL("../../../../../../static/image-body/install-spinner.gif", import.meta.url).href;
const demoBlurhash = "LEHV6nWB2yk8pyo0adR*.7kCMdnj";
const imageBodyViewStateOptions = [ImageBodyViewState.ERROR, ImageBodyViewState.HIDDEN, ImageBodyViewState.READY];
const imageBodyViewPlaceholderOptions = [
ImageBodyViewPlaceholder.NONE,
ImageBodyViewPlaceholder.SPINNER,
ImageBodyViewPlaceholder.BLURHASH,
];
type ImageBodyViewProps = ImageBodyViewSnapshot &
ImageBodyViewActions & {
className?: string;
children?: ReactNode;
};
const ImageBodyViewWrapperImpl = ({
onLinkClick,
onHiddenButtonClick,
onImageLoad,
onImageError,
className,
children,
...snapshotProps
}: ImageBodyViewProps): ReactNode => {
const vm = useMockedViewModel(snapshotProps, {
onLinkClick: onLinkClick ?? fn(),
onHiddenButtonClick: onHiddenButtonClick ?? fn(),
onImageLoad: onImageLoad ?? fn(),
onImageError: onImageError ?? fn(),
});
return (
<ImageBodyView vm={vm} className={className}>
{children}
</ImageBodyView>
);
};
const ImageBodyViewWrapper = withViewDocs(ImageBodyViewWrapperImpl, ImageBodyView);
const meta = {
title: "MessageBody/ImageBodyView",
component: ImageBodyViewWrapper,
tags: ["autodocs"],
argTypes: {
state: {
options: imageBodyViewStateOptions,
control: { type: "select" },
},
placeholder: {
options: imageBodyViewPlaceholderOptions,
control: { type: "select" },
},
className: { control: "text" },
},
args: {
state: ImageBodyViewState.READY,
alt: "Element logo",
hiddenButtonLabel: "Show image",
errorLabel: "Unable to show image due to error",
src: imageSrc,
thumbnailSrc,
showAnimatedContentOnHover: false,
placeholder: ImageBodyViewPlaceholder.NONE,
blurhash: demoBlurhash,
maxWidth: 320,
maxHeight: 320,
aspectRatio: "1 / 1",
isSvg: false,
gifLabel: undefined,
bannerLabel: "install-spinner.png",
tooltipLabel: undefined,
linkUrl: imageSrc,
linkTarget: undefined,
className: undefined,
children: <div>File body slot</div>,
},
} satisfies Meta<typeof ImageBodyViewWrapper>;
export default meta;
type Story = StoryObj<typeof meta>;
export const Default: Story = {};
export const Hidden: Story = {
args: {
state: ImageBodyViewState.HIDDEN,
linkUrl: undefined,
tooltipLabel: undefined,
},
};
export const LoadingWithSpinner: Story = {
args: {
placeholder: ImageBodyViewPlaceholder.SPINNER,
},
};
export const LoadingWithBlurhash: Story = {
args: {
placeholder: ImageBodyViewPlaceholder.BLURHASH,
},
};
export const AnimatedPreview: Story = {
args: {
src: animatedGifSrc,
thumbnailSrc,
linkUrl: animatedGifSrc,
showAnimatedContentOnHover: true,
gifLabel: "GIF",
},
};
export const ErrorState: Story = {
args: {
state: ImageBodyViewState.ERROR,
linkUrl: undefined,
children: undefined,
},
};
export const WithTooltip: Story = {
args: {
tooltipLabel: "Tooltip image name",
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await userEvent.hover(canvas.getByRole("img", { name: "Element logo" }));
await expect(
within(canvasElement.ownerDocument.body).findByText("Tooltip image name"),
).resolves.toBeInTheDocument();
},
};

View File

@ -0,0 +1,181 @@
/*
* Copyright 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
import React from "react";
import { composeStories } from "@storybook/react-vite";
import { fireEvent, render, screen } from "@test-utils";
import userEvent from "@testing-library/user-event";
import { describe, expect, it, vi } from "vitest";
import { MockViewModel } from "../../../../../core/viewmodel/MockViewModel";
import * as stories from "./ImageBodyView.stories";
import {
ImageBodyView,
ImageBodyViewPlaceholder,
ImageBodyViewState,
type ImageBodyViewActions,
type ImageBodyViewSnapshot,
} from "./ImageBodyView";
const { Default, Hidden, LoadingWithSpinner, LoadingWithBlurhash, AnimatedPreview, ErrorState } =
composeStories(stories);
class TestImageBodyViewModel extends MockViewModel<ImageBodyViewSnapshot> implements ImageBodyViewActions {
public onLinkClick?: ImageBodyViewActions["onLinkClick"];
public onHiddenButtonClick?: ImageBodyViewActions["onHiddenButtonClick"];
public onImageLoad?: ImageBodyViewActions["onImageLoad"];
public onImageError?: ImageBodyViewActions["onImageError"];
public constructor(snapshot: ImageBodyViewSnapshot, actions: ImageBodyViewActions = {}) {
super(snapshot);
this.onLinkClick = actions.onLinkClick;
this.onHiddenButtonClick = actions.onHiddenButtonClick;
this.onImageLoad = actions.onImageLoad;
this.onImageError = actions.onImageError;
}
}
describe("ImageBodyView", () => {
it.each([
["default", Default],
["hidden", Hidden],
["loading-with-spinner", LoadingWithSpinner],
["loading-with-blurhash", LoadingWithBlurhash],
["animated-preview", AnimatedPreview],
["error", ErrorState],
])("matches snapshot for %s story", (_name, Story) => {
const { container } = render(<Story />);
expect(container).toMatchSnapshot();
});
it("renders the hidden preview button and invokes the click handler", async () => {
const user = userEvent.setup();
const onHiddenButtonClick = vi.fn();
const vm = new TestImageBodyViewModel(
{
state: ImageBodyViewState.HIDDEN,
hiddenButtonLabel: "Show image",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
},
{ onHiddenButtonClick },
);
render(<ImageBodyView vm={vm} />);
await user.click(screen.getByRole("button", { name: "Show image" }));
expect(onHiddenButtonClick).toHaveBeenCalledTimes(1);
});
it("renders an error label when the media cannot be displayed", () => {
const vm = new TestImageBodyViewModel({
state: ImageBodyViewState.ERROR,
errorLabel: "Error decrypting image",
});
render(<ImageBodyView vm={vm} />);
expect(screen.getByText("Error decrypting image")).toBeInTheDocument();
expect(screen.queryByRole("img")).not.toBeInTheDocument();
});
it("renders a link wrapper and forwards the click handler", () => {
const onLinkClick = vi.fn();
const vm = new TestImageBodyViewModel(
{
state: ImageBodyViewState.READY,
alt: "Linked image",
src: "https://example.org/full.png",
thumbnailSrc: "https://example.org/thumb.png",
linkUrl: "https://example.org/full.png",
linkTarget: "_blank",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
},
{ onLinkClick },
);
render(<ImageBodyView vm={vm} />);
const link = screen.getByRole("link");
expect(link).toHaveAttribute("href", "https://example.org/full.png");
expect(link).toHaveAttribute("target", "_blank");
expect(link).toHaveAttribute("rel", "noreferrer noopener");
fireEvent.click(link);
expect(onLinkClick).toHaveBeenCalledTimes(1);
});
it("swaps to the full source on hover for animated previews", async () => {
const user = userEvent.setup();
const vm = new TestImageBodyViewModel({
state: ImageBodyViewState.READY,
alt: "Animated image",
src: "https://example.org/full.gif",
thumbnailSrc: "https://example.org/thumb.png",
showAnimatedContentOnHover: true,
gifLabel: "GIF",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
});
render(<ImageBodyView vm={vm} />);
const image = screen.getByRole("img", { name: "Animated image" }) as HTMLImageElement;
expect(image).toHaveAttribute("src", "https://example.org/thumb.png");
expect(screen.getByText("GIF")).toBeInTheDocument();
await user.hover(image);
expect(image).toHaveAttribute("src", "https://example.org/full.gif");
expect(screen.queryByText("GIF")).not.toBeInTheDocument();
});
it("renders the configured placeholder state", () => {
const vm = new TestImageBodyViewModel({
state: ImageBodyViewState.READY,
alt: "Loading image",
src: "https://example.org/full.png",
placeholder: ImageBodyViewPlaceholder.SPINNER,
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
});
render(<ImageBodyView vm={vm} />);
expect(screen.getByRole("progressbar")).toBeInTheDocument();
});
it("invokes image load and error handlers", () => {
const onImageLoad = vi.fn();
const onImageError = vi.fn();
const vm = new TestImageBodyViewModel(
{
state: ImageBodyViewState.READY,
alt: "Loaded image",
src: "https://example.org/full.png",
thumbnailSrc: "https://example.org/thumb.png",
maxWidth: 320,
maxHeight: 240,
aspectRatio: "4 / 3",
},
{ onImageLoad, onImageError },
);
render(<ImageBodyView vm={vm} />);
const image = screen.getByRole("img", { name: "Loaded image" });
fireEvent.load(image);
fireEvent.error(image);
expect(onImageLoad).toHaveBeenCalledTimes(1);
expect(onImageError).toHaveBeenCalledTimes(1);
});
});

View File

@ -0,0 +1,354 @@
/*
* 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 CSSProperties,
type HTMLAttributeAnchorTarget,
type JSX,
type MouseEventHandler,
type PropsWithChildren,
type ReactEventHandler,
useState,
} from "react";
import classNames from "classnames";
import { Blurhash } from "react-blurhash";
import { ImageErrorIcon, VisibilityOnIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { InlineSpinner, Tooltip } from "@vector-im/compound-web";
import { useI18n } from "../../../../../core/i18n/i18nContext";
import { type ViewModel, useViewModel } from "../../../../../core/viewmodel";
import styles from "./ImageBodyView.module.css";
/**
* High-level rendering state for the shared image body view.
*/
export const enum ImageBodyViewState {
ERROR = "ERROR",
HIDDEN = "HIDDEN",
READY = "READY",
}
/**
* Placeholder variant shown over the media frame while the image is still settling.
*/
export const enum ImageBodyViewPlaceholder {
NONE = "NONE",
SPINNER = "SPINNER",
BLURHASH = "BLURHASH",
}
export interface ImageBodyViewSnapshot {
/**
* Controls whether the component renders an error state, a hidden-preview state,
* or a visible image frame.
*/
state: ImageBodyViewState;
/**
* Image alt text.
*/
alt?: string;
/**
* Label shown when media processing fails.
*/
errorLabel?: string;
/**
* Label used by the hidden-media reveal button.
*/
hiddenButtonLabel?: string;
/**
* Full-resolution image source.
*/
src?: string;
/**
* Thumbnail/static preview image source.
* Falls back to `src` when omitted.
*/
thumbnailSrc?: string;
/**
* Whether hovering or focusing the link should swap to the full-resolution image.
*/
showAnimatedContentOnHover?: boolean;
/**
* Which placeholder to render over the image frame.
*/
placeholder?: ImageBodyViewPlaceholder;
/**
* Blurhash string used when `placeholder` is `BLURHASH`.
*/
blurhash?: string;
/**
* Maximum rendered width for the media frame.
*/
maxWidth?: number;
/**
* Maximum rendered height for the media frame.
*/
maxHeight?: number;
/**
* Aspect ratio reserved for the media frame.
*/
aspectRatio?: CSSProperties["aspectRatio"];
/**
* Whether the displayed image is an SVG and should therefore use explicit width sizing.
*/
isSvg?: boolean;
/**
* Optional badge shown for animated images when not hovered/focused.
*/
gifLabel?: string;
/**
* Optional overlay banner shown while hovered/focused.
*/
bannerLabel?: string;
/**
* Optional tooltip shown on the media frame.
*/
tooltipLabel?: string;
/**
* Optional link target for the media frame.
*/
linkUrl?: string;
/**
* Optional anchor target applied when `linkUrl` is provided.
*/
linkTarget?: HTMLAttributeAnchorTarget;
}
export interface ImageBodyViewActions {
/**
* Invoked when the linked image is activated.
*/
onLinkClick?: MouseEventHandler<HTMLAnchorElement>;
/**
* Invoked when the user chooses to reveal hidden media.
*/
onHiddenButtonClick?: MouseEventHandler<HTMLButtonElement>;
/**
* Invoked when the visible image loads.
*/
onImageLoad?: ReactEventHandler<HTMLImageElement>;
/**
* Invoked when the visible image fails to load.
*/
onImageError?: ReactEventHandler<HTMLImageElement>;
}
export type ImageBodyViewModel = ViewModel<ImageBodyViewSnapshot, ImageBodyViewActions>;
interface ImageBodyViewProps {
/**
* The view model for the component.
*/
vm: ImageBodyViewModel;
/**
* Optional host CSS class.
*/
className?: string;
/**
* Optional supplemental content rendered after the media frame.
*/
children?: PropsWithChildren["children"];
}
function renderPlaceholder({
placeholder,
blurhash,
maxWidth,
maxHeight,
loadingLabel,
}: Pick<ImageBodyViewSnapshot, "placeholder" | "blurhash" | "maxWidth" | "maxHeight"> & {
loadingLabel: string;
}): JSX.Element | null {
switch (placeholder) {
case ImageBodyViewPlaceholder.BLURHASH:
if (!blurhash) {
return <InlineSpinner aria-label={loadingLabel} role="progressbar" />;
}
return (
<Blurhash
className={styles.blurhash}
hash={blurhash}
width={maxWidth ?? 320}
height={maxHeight ?? 240}
/>
);
case ImageBodyViewPlaceholder.SPINNER:
return <InlineSpinner aria-label={loadingLabel} role="progressbar" />;
case ImageBodyViewPlaceholder.NONE:
default:
return null;
}
}
/**
* Renders the body of an image message with ready, hidden, and error states.
*
* The media frame supports thumbnail fallbacks, optional loading placeholders,
* animated-content preview on hover/focus, and optional tooltip/banner labels.
* Supplemental content such as a file body row can be rendered after the image
* through `children`.
*
* @example
* ```tsx
* <ImageBodyView vm={imageBodyViewModel}>
* <div>File body slot</div>
* </ImageBodyView>
* ```
*/
export function ImageBodyView({ vm, className, children }: Readonly<ImageBodyViewProps>): JSX.Element {
const { translate: _t } = useI18n();
const {
state,
alt,
errorLabel,
hiddenButtonLabel,
src,
thumbnailSrc,
showAnimatedContentOnHover,
placeholder = ImageBodyViewPlaceholder.NONE,
blurhash,
maxWidth,
maxHeight,
aspectRatio,
isSvg,
gifLabel,
bannerLabel,
tooltipLabel,
linkUrl,
linkTarget,
} = useViewModel(vm);
const [hover, setHover] = useState(false);
const [focus, setFocus] = useState(false);
const hoverOrFocus = hover || focus;
const rootClassName = classNames(className, styles.root);
if (state === ImageBodyViewState.ERROR) {
return (
<span className={classNames(rootClassName, styles.error)}>
<ImageErrorIcon className={styles.errorIcon} width="16" height="16" />
{errorLabel}
</span>
);
}
const resolvedThumbnailSrc = thumbnailSrc ?? src;
const resolvedImageSrc = hoverOrFocus && showAnimatedContentOnHover && src ? src : resolvedThumbnailSrc;
// Reserve the media box on the container itself so the timeline doesn't jump
// while the image element or loading state is still settling.
const resolvedWidth = maxWidth === undefined ? undefined : `min(100%, ${maxWidth}px)`;
const containerStyle: CSSProperties = {
width: resolvedWidth,
maxWidth,
maxHeight,
aspectRatio,
};
const mediaStyle: CSSProperties | undefined = isSvg
? {
width: resolvedWidth,
maxWidth,
maxHeight,
}
: undefined;
const placeholderNode = renderPlaceholder({
placeholder,
blurhash,
maxWidth,
maxHeight,
loadingLabel: _t("common|loading"),
});
const showPlaceholder = placeholderNode !== null;
const media =
state === ImageBodyViewState.HIDDEN ? (
<div style={{ width: maxWidth, height: maxHeight }}>
<button type="button" className={styles.hiddenButton} onClick={vm.onHiddenButtonClick}>
<div className={styles.hiddenButtonContent}>
<VisibilityOnIcon />
<span>{hiddenButtonLabel}</span>
</div>
</button>
</div>
) : resolvedImageSrc ? (
<img
className={styles.image}
src={resolvedImageSrc}
alt={alt}
onError={vm.onImageError}
onLoad={vm.onImageLoad}
onMouseEnter={(): void => setHover(true)}
onMouseLeave={(): void => setHover(false)}
/>
) : null;
const banner =
state === ImageBodyViewState.READY && bannerLabel && hoverOrFocus ? (
<span className={styles.banner}>{bannerLabel}</span>
) : null;
const gifBadge =
state === ImageBodyViewState.READY && gifLabel && !hoverOrFocus ? (
<p className={styles.gifLabel}>{gifLabel}</p>
) : null;
let frame = (
<div className={styles.thumbnailContainer} style={containerStyle}>
{showPlaceholder && (
<div
className={classNames(styles.placeholder, {
[styles.placeholderBlurhash]: placeholder === ImageBodyViewPlaceholder.BLURHASH && !!blurhash,
})}
>
{placeholderNode}
</div>
)}
<div className={styles.mediaContent} style={mediaStyle}>
{media}
{gifBadge}
{banner}
</div>
</div>
);
if (tooltipLabel) {
frame = (
<Tooltip description={tooltipLabel} placement="right" isTriggerInteractive={true}>
{frame}
</Tooltip>
);
}
if (state === ImageBodyViewState.READY && linkUrl) {
frame = (
<a
href={linkUrl}
target={linkTarget}
rel={linkTarget === "_blank" ? "noreferrer noopener" : undefined}
className={styles.link}
onClick={vm.onLinkClick}
onFocus={(): void => setFocus(true)}
onBlur={(): void => setFocus(false)}
>
{frame}
</a>
);
}
return (
<div className={rootClassName}>
{frame}
{children}
</div>
);
}

View File

@ -0,0 +1,238 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`ImageBodyView > matches snapshot for animated-preview story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.gif"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
<p
class="ImageBodyView-module_gifLabel"
>
GIF
</p>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for default story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.png"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for error story 1`] = `
<div>
<span
class="ImageBodyView-module_root ImageBodyView-module_error"
>
<svg
class="ImageBodyView-module_errorIcon"
fill="currentColor"
height="16"
viewBox="0 0 24 24"
width="16"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M5 3a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h7.803a6 6 0 0 1-.72-2H5v-3.172l4-4 3.585 3.585a6 6 0 0 1 1.172-1.656l-3.343-3.343a2 2 0 0 0-2.828 0L5 13V5h14v7.083c.718.12 1.393.368 2 .72V5a2 2 0 0 0-2-2z"
/>
<path
d="M17 9a2 2 0 1 1-4 0 2 2 0 0 1 4 0m1 5a1 1 0 0 1 1 1v3a1 1 0 1 1-2 0v-3a1 1 0 0 1 1-1m-1 7a1 1 0 1 1 2 0 1 1 0 0 1-2 0"
/>
</svg>
Unable to show image due to error
</span>
</div>
`;
exports[`ImageBodyView > matches snapshot for hidden story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_mediaContent"
>
<div
style="width: 320px; height: 320px;"
>
<button
class="ImageBodyView-module_hiddenButton"
type="button"
>
<div
class="ImageBodyView-module_hiddenButtonContent"
>
<svg
fill="currentColor"
height="1em"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
d="M12 16q1.875 0 3.188-1.312Q16.5 13.375 16.5 11.5t-1.312-3.187T12 7 8.813 8.313 7.5 11.5t1.313 3.188T12 16m0-1.8q-1.125 0-1.912-.787A2.6 2.6 0 0 1 9.3 11.5q0-1.125.787-1.912A2.6 2.6 0 0 1 12 8.8q1.125 0 1.912.787.788.788.788 1.913t-.787 1.912A2.6 2.6 0 0 1 12 14.2m0 4.8q-3.475 0-6.35-1.837Q2.775 15.324 1.3 12.2a.8.8 0 0 1-.1-.312 3 3 0 0 1 0-.775.8.8 0 0 1 .1-.313q1.475-3.125 4.35-4.962Q8.525 4 12 4t6.35 1.838T22.7 10.8a.8.8 0 0 1 .1.313 3 3 0 0 1 0 .774.8.8 0 0 1-.1.313q-1.475 3.125-4.35 4.963Q15.475 19 12 19m0-2a9.54 9.54 0 0 0 5.188-1.488A9.77 9.77 0 0 0 20.8 11.5a9.77 9.77 0 0 0-3.613-4.012A9.54 9.54 0 0 0 12 6a9.55 9.55 0 0 0-5.187 1.487A9.77 9.77 0 0 0 3.2 11.5a9.77 9.77 0 0 0 3.613 4.012A9.54 9.54 0 0 0 12 17"
/>
</svg>
<span>
Show image
</span>
</div>
</button>
</div>
</div>
</div>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for loading-with-blurhash story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.png"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_placeholder ImageBodyView-module_placeholderBlurhash"
>
<div
class="ImageBodyView-module_blurhash"
style="display: inline-block; height: 320px; width: 320px; position: relative;"
>
<canvas
height="32"
style="position: absolute; inset: 0px; width: 100%; height: 100%;"
width="32"
/>
</div>
</div>
<div
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;
exports[`ImageBodyView > matches snapshot for loading-with-spinner story 1`] = `
<div>
<div
class="ImageBodyView-module_root"
>
<a
class="ImageBodyView-module_link"
href="http://localhost:63315/static/image-body/install-spinner.png"
>
<div
class="ImageBodyView-module_thumbnailContainer"
style="width: min(100%, 320px); max-width: 320px; max-height: 320px; aspect-ratio: 1 / 1;"
>
<div
class="ImageBodyView-module_placeholder"
>
<svg
aria-label="Loading…"
class="_icon_11k6c_18"
fill="currentColor"
height="1em"
role="progressbar"
style="width: 20px; height: 20px;"
viewBox="0 0 24 24"
width="1em"
xmlns="http://www.w3.org/2000/svg"
>
<path
clip-rule="evenodd"
d="M12 4.031a8 8 0 1 0 8 8 1 1 0 0 1 2 0c0 5.523-4.477 10-10 10s-10-4.477-10-10 4.477-10 10-10a1 1 0 1 1 0 2"
fill-rule="evenodd"
/>
</svg>
</div>
<div
class="ImageBodyView-module_mediaContent"
>
<img
alt="Element logo"
class="ImageBodyView-module_image"
src="http://localhost:63315/static/image-body/install-spinner.png"
/>
</div>
</div>
</a>
<div>
File body slot
</div>
</div>
</div>
`;

View File

@ -0,0 +1,15 @@
/*
* 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 {
ImageBodyView,
ImageBodyViewPlaceholder,
ImageBodyViewState,
type ImageBodyViewActions,
type ImageBodyViewModel,
type ImageBodyViewSnapshot,
} from "./ImageBodyView";

Binary file not shown.

After

Width:  |  Height:  |  Size: 256 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

3
pnpm-lock.yaml generated
View File

@ -1055,6 +1055,9 @@ importers:
matrix-web-i18n:
specifier: 'catalog:'
version: 3.6.0
react-blurhash:
specifier: ^0.3.0
version: 0.3.0(patch_hash=58bc7f075478017ce27bcc252e8509876390db106246bd5b0a7446642cc4b505)(blurhash@2.0.5)(react@19.2.4)
react-merge-refs:
specifier: ^3.0.2
version: 3.0.2(react@19.2.4)