Merge remote-tracking branch 'origin/develop' into hs/enable-profile-updates
2
.github/workflows/build_develop.yml
vendored
@ -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.
|
||||
|
||||
@ -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 }}
|
||||
|
||||
@ -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"
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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"],
|
||||
|
||||
@ -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",
|
||||
},
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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: [] });
|
||||
|
||||
@ -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", () => {
|
||||
|
||||
@ -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" }
|
||||
|
||||
|
After Width: | Height: | Size: 28 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 20 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 31 KiB |
@ -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",
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"start": {
|
||||
"command": "vite build --watch",
|
||||
"options": { "cwd": "packages/shared-components" },
|
||||
"dependsOn": ["^start"],
|
||||
"continuous": true
|
||||
},
|
||||
"typedoc": {
|
||||
|
||||
@ -34,6 +34,7 @@
|
||||
"common": {
|
||||
"attachment": "Attachment",
|
||||
"encryption_enabled": "Encryption enabled",
|
||||
"loading": "Loading…",
|
||||
"options": "Options",
|
||||
"preferences": "Preferences",
|
||||
"state_encryption_enabled": "Experimental state encryption enabled"
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -1 +0,0 @@
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
},
|
||||
};
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
`;
|
||||
@ -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";
|
||||
BIN
packages/shared-components/static/image-body/install-spinner.gif
Normal file
|
After Width: | Height: | Size: 256 KiB |
BIN
packages/shared-components/static/image-body/install-spinner.png
Normal file
|
After Width: | Height: | Size: 3.4 KiB |
3
pnpm-lock.yaml
generated
@ -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)
|
||||
|
||||