diff --git a/.github/workflows/build_develop.yml b/.github/workflows/build_develop.yml index 1d0c78c7ab..a4c9873a27 100644 --- a/.github/workflows/build_develop.yml +++ b/.github/workflows/build_develop.yml @@ -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. diff --git a/.github/workflows/shared-component-storybook-publish.yaml b/.github/workflows/shared-component-storybook-publish.yaml index 6193c5af74..1a365c6b6b 100644 --- a/.github/workflows/shared-component-storybook-publish.yaml +++ b/.github/workflows/shared-component-storybook-publish.yaml @@ -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 }} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index eb63cd55ee..9b58661185 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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" } diff --git a/apps/web/src/Lifecycle.ts b/apps/web/src/Lifecycle.ts index d7d139249a..32d113adc8 100644 --- a/apps/web/src/Lifecycle.ts +++ b/apps/web/src/Lifecycle.ts @@ -277,9 +277,10 @@ export async function attemptDelegatedAuthLogin( defaultDeviceDisplayName?: string, fragmentAfterLogin?: string, ): Promise { - 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): Promise { +async function attemptOidcNativeLogin( + urlParams: NonNullable, + responseMode: "fragment" | "query", +): Promise { + 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 diff --git a/apps/web/src/components/structures/MatrixChat.tsx b/apps/web/src/components/structures/MatrixChat.tsx index e93799fb1a..14e726e532 100644 --- a/apps/web/src/components/structures/MatrixChat.tsx +++ b/apps/web/src/components/structures/MatrixChat.tsx @@ -350,7 +350,11 @@ export default class MatrixChat extends React.PureComponent { ); // 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 { * {@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 { diff --git a/apps/web/src/components/views/dialogs/InviteDialog.tsx b/apps/web/src/components/views/dialogs/InviteDialog.tsx index 8a9c36e5de..94b6365774 100644 --- a/apps/web/src/components/views/dialogs/InviteDialog.tsx +++ b/apps/web/src/components/views/dialogs/InviteDialog.tsx @@ -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 - {buttonText} - - ); - return (

{helpText}

{this.renderEditor()} - {goButton} + + {buttonText} +
{this.state.busy ? : this.renderSuggestions()}
@@ -1342,7 +1335,12 @@ export default class InviteDialog extends React.PureComponent +
{this.renderEditor()}
+ {this.state.busy ? : this.renderSuggestions()} + + ); const tabs: NonEmptyArray> = [ new Tab( diff --git a/apps/web/src/utils/oidc/authorize.ts b/apps/web/src/utils/oidc/authorize.ts index b0020446ac..2e1420e32e 100644 --- a/apps/web/src/utils/oidc/authorize.ts +++ b/apps/web/src/utils/oidc/authorize.ts @@ -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): { code: string; state: string } => { +const getCodeAndStateFromParams = ( + { code, state }: NonNullable, + 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: NonNullable, + responseMode: "fragment" | "query", ): Promise => { - 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, diff --git a/apps/web/src/utils/oidc/error.ts b/apps/web/src/utils/oidc/error.ts index f9334a739c..3cc5c14ec5 100644 --- a/apps/web/src/utils/oidc/error.ts +++ b/apps/web/src/utils/oidc/error.ts @@ -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: diff --git a/apps/web/src/vector/app.tsx b/apps/web/src/vector/app.tsx index fa31bdd732..db5d0e4e99 100644 --- a/apps/web/src/vector/app.tsx +++ b/apps/web/src/vector/app.tsx @@ -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", () => { const code = "test-oidc-auth-code"; const state = "test-oidc-state"; const urlParams = { - oidc: { + oidc_fragment: { code, state: state, }, @@ -386,7 +386,7 @@ describe("", () => { it("should fail when query params do not include valid code and state", async () => { const urlParams = { - oidc: { + oidc_query: { code: "", state: "abc", }, diff --git a/apps/web/test/unit-tests/utils/oidc/authorize-test.ts b/apps/web/test/unit-tests/utils/oidc/authorize-test.ts index c28e6325d7..b7bd2bfd9f 100644 --- a/apps/web/test/unit-tests/utils/oidc/authorize-test.ts +++ b/apps/web/test/unit-tests/utils/oidc/authorize-test.ts @@ -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, diff --git a/apps/web/test/unit-tests/vector/app-test.ts b/apps/web/test/unit-tests/vector/app-test.ts index 381f4a2c53..fa95a0053b 100644 --- a/apps/web/test/unit-tests/vector/app-test.ts +++ b/apps/web/test/unit-tests/vector/app-test.ts @@ -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: [] }); diff --git a/apps/web/test/unit-tests/vector/url_utils-test.ts b/apps/web/test/unit-tests/vector/url_utils-test.ts index f6f42f573c..ad2c0e0390 100644 --- a/apps/web/test/unit-tests/vector/url_utils-test.ts +++ b/apps/web/test/unit-tests/vector/url_utils-test.ts @@ -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", () => { diff --git a/packages/module-api/project.json b/packages/module-api/project.json index 56f41507ae..2b7e0be792 100644 --- a/packages/module-api/project.json +++ b/packages/module-api/project.json @@ -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" } diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png new file mode 100644 index 0000000000..996f5ec6d1 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/animated-preview-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png new file mode 100644 index 0000000000..62ea65c997 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/default-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png new file mode 100644 index 0000000000..a61b7cdf2c Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/error-state-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png new file mode 100644 index 0000000000..4f09d381a5 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/hidden-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png new file mode 100644 index 0000000000..9934c59d57 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-blurhash-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png new file mode 100644 index 0000000000..6653fe161a Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/loading-with-spinner-auto.png differ diff --git a/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png new file mode 100644 index 0000000000..866d94ddf9 Binary files /dev/null and b/packages/shared-components/__vis__/linux/__baselines__/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx/with-tooltip-auto.png differ diff --git a/packages/shared-components/package.json b/packages/shared-components/package.json index 2cfe4a0fab..72b458751a 100644 --- a/packages/shared-components/package.json +++ b/packages/shared-components/package.json @@ -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", diff --git a/packages/shared-components/project.json b/packages/shared-components/project.json index b4401cc5a0..1751c4c6c4 100644 --- a/packages/shared-components/project.json +++ b/packages/shared-components/project.json @@ -13,6 +13,7 @@ "start": { "command": "vite build --watch", "options": { "cwd": "packages/shared-components" }, + "dependsOn": ["^start"], "continuous": true }, "typedoc": { diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index 7cd70dbb0d..0ab6da3a94 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -34,6 +34,7 @@ "common": { "attachment": "Attachment", "encryption_enabled": "Encryption enabled", + "loading": "Loading…", "options": "Options", "preferences": "Preferences", "state_encryption_enabled": "Experimental state encryption enabled" diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 64c504c5fb..081ccf0d83 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -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"; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep deleted file mode 100644 index 8b13789179..0000000000 --- a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/.gitkeep +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css new file mode 100644 index 0000000000..05d56d3987 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.module.css @@ -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; + } +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx new file mode 100644 index 0000000000..3b6c66cf38 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.stories.tsx @@ -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 ( + + {children} + + ); +}; + +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:
File body slot
, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +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(); + }, +}; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx new file mode 100644 index 0000000000..2eaeeba41f --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.test.tsx @@ -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 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(); + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + 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(); + + const image = screen.getByRole("img", { name: "Loaded image" }); + fireEvent.load(image); + fireEvent.error(image); + + expect(onImageLoad).toHaveBeenCalledTimes(1); + expect(onImageError).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx new file mode 100644 index 0000000000..7a4ea96acb --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/ImageBodyView.tsx @@ -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; + /** + * Invoked when the user chooses to reveal hidden media. + */ + onHiddenButtonClick?: MouseEventHandler; + /** + * Invoked when the visible image loads. + */ + onImageLoad?: ReactEventHandler; + /** + * Invoked when the visible image fails to load. + */ + onImageError?: ReactEventHandler; +} + +export type ImageBodyViewModel = ViewModel; + +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 & { + loadingLabel: string; +}): JSX.Element | null { + switch (placeholder) { + case ImageBodyViewPlaceholder.BLURHASH: + if (!blurhash) { + return ; + } + + return ( + + ); + + case ImageBodyViewPlaceholder.SPINNER: + return ; + + 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 + * + *
File body slot
+ *
+ * ``` + */ +export function ImageBodyView({ vm, className, children }: Readonly): 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 ( + + + {errorLabel} + + ); + } + + 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 ? ( +
+ +
+ ) : resolvedImageSrc ? ( + {alt} setHover(true)} + onMouseLeave={(): void => setHover(false)} + /> + ) : null; + + const banner = + state === ImageBodyViewState.READY && bannerLabel && hoverOrFocus ? ( + {bannerLabel} + ) : null; + + const gifBadge = + state === ImageBodyViewState.READY && gifLabel && !hoverOrFocus ? ( +

{gifLabel}

+ ) : null; + + let frame = ( +
+ {showPlaceholder && ( +
+ {placeholderNode} +
+ )} + +
+ {media} + {gifBadge} + {banner} +
+
+ ); + + if (tooltipLabel) { + frame = ( + + {frame} + + ); + } + + if (state === ImageBodyViewState.READY && linkUrl) { + frame = ( + setFocus(true)} + onBlur={(): void => setFocus(false)} + > + {frame} + + ); + } + + return ( +
+ {frame} + {children} +
+ ); +} diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap new file mode 100644 index 0000000000..e01e97e094 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/__snapshots__/ImageBodyView.test.tsx.snap @@ -0,0 +1,238 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`ImageBodyView > matches snapshot for animated-preview story 1`] = ` +
+
+ +
+
+ Element logo +

+ GIF +

+
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for default story 1`] = ` +
+
+ +
+
+ Element logo +
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for error story 1`] = ` +
+ + + + + + Unable to show image due to error + +
+`; + +exports[`ImageBodyView > matches snapshot for hidden story 1`] = ` +
+
+
+
+
+ +
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for loading-with-blurhash story 1`] = ` +
+
+ +
+
+
+ +
+
+
+ Element logo +
+
+
+
+ File body slot +
+
+
+`; + +exports[`ImageBodyView > matches snapshot for loading-with-spinner story 1`] = ` +
+
+ +
+
+ + + +
+
+ Element logo +
+
+
+
+ File body slot +
+
+
+`; diff --git a/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx new file mode 100644 index 0000000000..a71a80c047 --- /dev/null +++ b/packages/shared-components/src/room/timeline/event-tile/body/MImageBodyView/index.tsx @@ -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"; diff --git a/packages/shared-components/static/image-body/install-spinner.gif b/packages/shared-components/static/image-body/install-spinner.gif new file mode 100644 index 0000000000..ce6c17844a Binary files /dev/null and b/packages/shared-components/static/image-body/install-spinner.gif differ diff --git a/packages/shared-components/static/image-body/install-spinner.png b/packages/shared-components/static/image-body/install-spinner.png new file mode 100644 index 0000000000..75c3f48ad8 Binary files /dev/null and b/packages/shared-components/static/image-body/install-spinner.png differ diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 53f3fe4e88..4f33e46617 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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)