From a4df87c7ed203a2a767bc7c95a30aeae21f1111a Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 May 2026 13:02:57 +0000 Subject: [PATCH 01/13] Upgrade dependency to matrix-js-sdk@41.5.0-rc.0 --- apps/web/package.json | 2 +- pnpm-lock.yaml | 13 +++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index c04ddac5e8..9a8dc3ee7d 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -81,7 +81,7 @@ "lodash": "npm:lodash-es@4.18.1", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", + "matrix-js-sdk": "41.5.0-rc.0", "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 841534b316..ac6af903b1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -472,8 +472,8 @@ importers: specifier: ^1.0.3 version: 1.0.3 matrix-js-sdk: - specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/69985ee350a33ba75f1ad11f96468344f0c92a8d + specifier: 41.5.0-rc.0 + version: 41.5.0-rc.0 matrix-widget-api: specifier: ^1.17.0 version: 1.17.0 @@ -9977,9 +9977,8 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/69985ee350a33ba75f1ad11f96468344f0c92a8d: - resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/69985ee350a33ba75f1ad11f96468344f0c92a8d} - version: 41.4.0 + matrix-js-sdk@41.5.0-rc.0: + resolution: {integrity: sha512-+3k+eUYJzjX8wCTI/JrP22/QnhB8tb7vdZTtXh4g4ukVxf+B20HguAPOEmVYXmKUTko9hxCHu2e7DQqZazqZBA==} engines: {node: '>=22.0.0'} matrix-web-i18n@3.6.0: @@ -12935,6 +12934,7 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.0: @@ -12947,6 +12947,7 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -23578,7 +23579,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/69985ee350a33ba75f1ad11f96468344f0c92a8d: + matrix-js-sdk@41.5.0-rc.0: dependencies: '@babel/runtime': 7.29.2 '@matrix-org/matrix-sdk-crypto-wasm': 18.2.0 From d5b583652b4878832026e9da16f3ab1037e7f4e0 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 5 May 2026 13:06:08 +0000 Subject: [PATCH 02/13] v1.12.18-rc.0 --- apps/desktop/package.json | 2 +- apps/web/package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 84db56e160..a080962f65 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "productName": "Element", "main": "lib/electron-main.js", "exports": "./lib/electron-main.js", - "version": "1.12.17", + "version": "1.12.18-rc.0", "description": "Element: the future of secure communication", "author": { "name": "Element", diff --git a/apps/web/package.json b/apps/web/package.json index 9a8dc3ee7d..7df89851cd 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.12.17", + "version": "1.12.18-rc.0", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { From e157b1d4e86d10e90f7476837f18abdb39d141f6 Mon Sep 17 00:00:00 2001 From: ElementRobot Date: Thu, 7 May 2026 13:12:50 +0200 Subject: [PATCH 03/13] Restore the Manage account button text color and add playwright test (#33411) (#33415) * Restore the Manage account button text color and add playwright test * Fix prettier issue * Fix Sonar issue (cherry picked from commit e491fb8c81a32fb94edec1d4cadca4241fda71b1) Co-authored-by: rbondesson --- .../account-user-settings-tab.spec.ts | 43 ++++++++++++++++++ .../account-manage-account-button-linux.png | Bin 0 -> 6499 bytes .../views/settings/UserProfileSettings.tsx | 2 + 3 files changed, 45 insertions(+) create mode 100644 apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-manage-account-button-linux.png diff --git a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts index b3ec952b01..b4a7b71926 100644 --- a/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts +++ b/apps/web/playwright/e2e/settings/account-user-settings-tab.spec.ts @@ -6,11 +6,14 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +import { type Route } from "@playwright/test"; + import { test, expect } from "../../element-web-test"; import { getSampleFilePath } from "../../sample-files"; const USER_NAME = "Bob"; const USER_NAME_NEW = "Alice"; +const EXTERNAL_ACCOUNT_MANAGEMENT_URL = "https://just.for.test.io/"; test.describe("Account user settings tab", () => { test.use({ @@ -79,6 +82,46 @@ test.describe("Account user settings tab", () => { await expect(uut).toMatchScreenshot("account-smallscreen.png"); }); + test.describe("with external account management", () => { + test.use({ + page: async ({ page }, runFixture) => { + const authMetadataHandler = async (route: Route): Promise => { + await route.fulfill({ + json: { + issuer: EXTERNAL_ACCOUNT_MANAGEMENT_URL, + authorization_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}authorize`, + token_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}token`, + revocation_endpoint: `${EXTERNAL_ACCOUNT_MANAGEMENT_URL}revoke`, + response_types_supported: ["code"], + grant_types_supported: ["authorization_code"], + code_challenge_methods_supported: ["S256"], + account_management_uri: EXTERNAL_ACCOUNT_MANAGEMENT_URL, + }, + }); + }; + + await page.route("**/_matrix/client/v1/auth_metadata", authMetadataHandler); + await page.route("**/_matrix/client/unstable/org.matrix.msc2965/auth_metadata", authMetadataHandler); + await runFixture(page); + }, + }); + + test("should render the manage account button properly", { tag: "@screenshot" }, async ({ uut, axe }) => { + const manageAccountButton = uut.getByTestId("external-account-management-link"); + + await expect(manageAccountButton).toBeVisible(); + await expect(manageAccountButton).toHaveAttribute("href", EXTERNAL_ACCOUNT_MANAGEMENT_URL); + await expect(manageAccountButton).toHaveAttribute("target", "_blank"); + await expect(manageAccountButton).toHaveText(/Manage account/); + + const profileButtons = uut.locator(".mx_UserProfileSettings_profile_buttons"); + await profileButtons.scrollIntoViewIfNeeded(); + await expect(profileButtons).toMatchScreenshot("account-manage-account-button.png"); + + await expect(axe).toHaveNoViolations(); + }); + }); + test("should show tooltips on narrow screen", async ({ page, uut }) => { await page.setViewportSize({ width: 700, height: 600 }); await page.getByRole("tab", { name: "Account" }).hover(); diff --git a/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-manage-account-button-linux.png b/apps/web/playwright/snapshots/settings/account-user-settings-tab.spec.ts/account-manage-account-button-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..40f79c05b32272408fb7541e478e75ea63430366 GIT binary patch literal 6499 zcmV-p8Jy;cP)nL7^izvrA=So!(+Kp+qZ1OnmbfF*y{$jB&< ztJfYs_KApy5GALG#p2xDTp(0g*laeB$J62S%`ME^w`3H)%fGC`CG>1mLe z4!JpylLL8DApDxBFhNxn*s7qe0UDa1qYrB8Kp<3i81h)md;Zyb^w^1HQF2ahj#Qcl zgldP1ii)bLs;;h%{iF%gogCqZ5FHNjv6Kws_m{5=EH?E;#_uvyf#xHjS|OECEi-JN zNpc}sNVV9QOfWD86LTOCsxM^-^WJ?2Hg4L&;czlDGlB36z~yqYv$OwLx@el?6!>n$ zL_l~5)T{&AI>6Ng0--d}m69rguwd$KF){-Jp*m8kFu#8NW{TrKp<2vN)~3fGykqyy*@21 z4G6z58X6kw)~*^cd?-{B39%3z0S%f`E^7ioQP9Qh{~XLLLEjJvgla?y!n||Wo&yID zrKF?);g?5STieNL>g<`*p~8ucq%5Ag4S+x>4}t!m%?CqMAP}kx#f5qOhQAIUIhK`` z1q4C?>Krx4X$}jW=R*a8s*J(H+Vz1zC_fkt(cpm*(Plz5;ftuA+nKW`Pq`6QCKM$z zJL}NlV;J3liUK{@Xe$#{CR7yoE~O?z;wK;wss*1d=750rLx0 zT3gqJa*{5F;CIlZH4q3D%gZNV-H7xBBUA%E3v=7{og*WofIuh-d{4~GOnn~ThjQ|t zFW8XYUW7^@CmY_shDI%cK&avry0Z7}KPVK6fIuh(27?h77r%G!0VoeK5ul|*RGCmI zsB%D4i|W2WAXIS*EoSqYb@K9*oYJeGZ6D_a^Xk;8U2Om3Cq5(X#$w{!+0$)Ww<_@v zp=!ZoGQ&fIKxv-bf!1A=8?P+BaXW6^)|`)F#rCVWY-)Mw6c7lAR0@9gp-opH5UMiN zCvw~NT`VROlx6;R#vs>pJnDpUcd6w)z!tsxbgPh_dZ8Aou*A)_U95z zJbU(h@X(P!_>P#EnD{^9oGT4!AGzlfCcm!oGdR&UzK^6D8-9I@w}Nc)-mQA3Tl ztp~-4abBK$>YUyT2f;FD_zsZXg@me)N@QeIvM5<;RhAP}Eih_{%>CRzka^=pIyU-4yO0xrO`U<-mUBLV}}CXzIyWL-oy#x z=tF88&c^j?9zM7e68!G^)k`au{~-|6pbyorXT5Ll9xMuvI!<-Ca`^%lLJ#5Pa=Cvj zU4;2Vg5G-H@xnBNQQX<^jgrmH&9-jYbnouX;6VS&?&pRN8zMWaeY>`$kbUO~I}k?_!SF5Z>PK;X^N7 zJd3w_>*h7OKzum!=lza}Su>q5GoQ}~1;Hkf-?JB>DCl71>p~?HF>!jtc&EfY2caCO zXlkh0)Wbwh7!|6-;OM7i>ppZH7+}>bbkG>_H7`0Fx{lFc6&*i=p78f|TZG$9&W(r? zd)@umXLx{B)371qa-(DDocTGqiMtQrp7*tzhuV&e-?Rhc;?m@l0L#YsFFz}r&Xyez zgaw9l>`R@Jos+n8U+BQmc+n3%24fn&oS`q}toz5_!!VKV{Rq!OZ!^6=Pn;IJbWKq6 zE-ta4DZ&Vdcu;?u_4#+{-4N8u&XANdw$l1c*`wtr`0BZ-ZpAC%Db z1brmc{c6O7>F?{e4sPA!)5=YtrQ>V{hYXfsmonnuNYS*?par|(X&YfSre1)}W(W2SeGbfugZd7pdYiG}z zuEFJAbia(gjmh>C_x`gJvd~Z;?CQ5?)pCUgM#q4(-}J) zMH=NT)x%SkIc~!?P*LIa?2`WAN%Wj0u}fBAC|A9qO@{CD*o7yg(o!yZ;8tf8CAX-l@qg`vynGB8t8h4&#zKnTB*&&6 z9ZP0gn4zPXV`W)*KWn$-Mn-d6w~!l9BxHwjqhi8_PDt~9sL`<LBX4j4^BSF!UQ!ib^x@Wz4%O5&`*Q1Nj^?2BzvDj9Sr^*U|Z(1hl5=f}Ru!M<8 z*jj~3h%pz~%4aPs%)`SYh7BJjvz14T93y`rD1p~+crE*LNym;I^z`&<*0ewkL*pCu zMwSN^bnZOo)YR1b_a8#Rx(jGAck0;Q?aWy^!}cA!PrFewN*;DSfU@1AN4FCvPjBD3 z`}vC(hYlY@qukWg#OLvSv{^CFfC2qYOpJYepAH`}8h`%wTZ3yJ?sPe*=mQ7z3wR&c zyKiAD0S#HZk;Av|*oC(wyPr}^?r&SGmL;}J=qG^I^X5$(&>+V2)G3ouws-E@vt{dc z{OR}X`LH2_@!Z%?pD?~I5{a-`lbn)*&6L|WueWdCu0@OHsI1?9bXrnwy)qZq?Gq*KgBbTR-rRA*$rV|EM=X9b2}P+x_|E3?W9pvs{p8zB&? zEUe_O9a$B`$>T>*l|#eAP?h836F^y#k`hsIyLa!3!bW%MzI6FY(G2KZ{rE9-{MgY8 z=FL%4Q?s$5y6<8_mI1v2bmC)ZC~j$2FrI>XdGv@23c0x%HT^w@!@(^EuyO0@S?IC! z_kSA}7LJ07=~k^)~;Y z{(0rfRs4sEfdl*F6`%M#O)gmM3zh!v-Fw(HY1g(*nZhg%&3j~6q z_o+0=$%?N$hT#&`3Kr(jes-BJUzc_iO+$`#ov*c_G|-qUOtDy~TY{C8@txsEIqj47 z9!e@u6dKyaO`~dAlTI)Tf&%P<|86vknL2za)#QcD7yp4oqS>VbrOH6HG`0HlNO%{R z{=^Tj!V(yy-MNqAo%10Ky}rC&T~WfRFu6pM^`Af85nV=ZSY(+h#ck1)PH^f{qaGx} z#%Mzs3%k_j{YK+qRm^pu?N zNUR+;RxmK1cISa+w~pegUQAs+uTS@)+43{9u<1jUT%gK`-V6siQJyN70KRFJ8J;5$ zDjOD58acVSXxEOg8&gb`&z(J;mzSr|1S9{RJ%_^Fp+h?ZeSH+C)2IJM$(J1+H+J;; zb*nQnGOl0uOh`3%kcFV|D zI$ZY?gA!6k21SNAqZ!BR-7Wd_`IrUEGM>Fcvya*wX-aHC3`WqlmnwOEGi++1{vEW61 zX(gAd$^=5SgT>d@ice79oc#s^1`aK8f3aD>gTX3d(o7T8GWKex23NgsOu zo{FuS7#n-tyhTf$!{kXArWJ{jtXz9)O$-d000RLNklOdslfma}gQCzzjCw^j1g*TUe7zIBsZow|Q4aN->~$T+ zfr6lgo%lZ&H0-b(v=li7!XOojG}=?+V$tRk9y_hxP=Ph1DZ7)!O1t|2wTgCSqp6Df zHQKhMFNeaM_RuFgFqkqidr?zXsaaFg^`IH5(XJJ(ugPu~7#t3>&6k7Cl$xpq{q32r z-_oPB(PtjqzIQstgN4a>^}#H}T969K0mz~b3V?{aqrBeEV9bbI>1w&n3tx*F!9W7&QAK)J0!DHAyFqrEkZ59{|shGZS!M)f~I(qHoG}X{h(6()xmMzO37J@RLo15FH zQNy`&W`~D|FLa(SvnemSUz#!9anQg4Y_@7lOblA;Qi%i#e9;(PzJ2>H?zwU8^5ZAI z7UpKXdi8kp_z6a=43?|M_idv@*qr)Q7uPSYIq z_4P9{GBK`<;)grq;^OxoaG5#7>Ey|yPoF-sv8jiC?DH4gX+QSsH~#3;_PBiUhNl-6 zEBXJa*1k<-B<0_>?b{n2>>D=zrNm%4yh%_L3@~GuS)B`&NxJu=D5v@Ox)?amGMGOT zlt4vAuykJJlsWMmw`=w3t~JOuVdp;K;ggaiA;-!RMJV&xE5R~nZaaA!m*~^3;XEsT z(~gKSQ#IPRK~bcl%j4}hHr9BQZEmX3p$&?qBr%B!`W9#{+rkV(#i>_rgbo<3*|~jo zXgG$VHM@7#9@4L9$@BoE%one*aJui6JfwJdijJI0_3)%i&Pz!}v6QJYjjsJztbXLw zIsB1BB%k6k|MQm}(wjC3gYnV7FV1-4$K-2^PMwv+CDgiJ(3t}Rk>P;J| z)-Iu$Xt-ig*hu>bhuQoQgY$E8k`5eY8ym8XS2H-O@>E$~Fu<(J0Ro}QqEg4o3i8Wq zLVX8LoH&gNhgKshbg@HQwr)px>(aT?-&_AOGc`SV%B{@OQ1Di)T!R*;^MbjH7A`pG za!966=y)G9ZqoG|o*g^18$NvKfrE#Nb~JOEF?;u(z3AMYFn(;O&K+;wyuHAAv79BN z6MnLTBU=BxdiIz)#h%4v&Uapnx{HURqhrQRun!3d89Qc_!{mwnZ{OOD9{1wqD?GGu z(-u@{>$-LDl-8|UVk2Pgyuulj7BBu2L*V-QdT3sIT)p=EgEd20cd2?I(>_ zZW%|6se_`ZYf#fZR5FoMe#fUQSh+|^RW5*M+Y7ZS=VO@Y)HyUCEj`?Hr;N`D4Nusy zH#a;=Z{`#|$B7CRsQ>#k!Ji8<-vm&`@x4cS)9j5lt&|<*j~GM^8OqNu8V)zvy}8EH zd1x}C`7XY33)=_A+tw8;7IhJu2zjaE#9jL{e4fd7FJQQGabb0~{||)_1H%@lT#DcD ztf_|Umaz>5i97d6l0-EY&5<2a`>lR04>zrTy~Ve@u>_2)8}Haio4-_DMp9KkspgLy zT;kQSL1TVwFUDN3WYJMK-ASV@ubgHSOi>_DmE{HBv`TIQ5(t$F1BC%U{pFcsM9wKW zmCpbD;c~gCc684v{D?gDeQ0E8*sMjH@^oRVHn3Q%70dsaY_FI&QEUEWWRY2K`}sX1s!YRTG5hxIt?+3&`VfN#z4F2=4`^lqfgnl*)0#jaR9RT2rY1Ukos5iF zM&-_yU1h+yGWv=qPo6Z{egc!pM5pw*^A}gGS_^~%Fu;s06;Kp4b!z3G^fbzpArLB$ zG%>K*z)=GNp~^z_k{v&8%;vwh=PTP;Hz+8$ZrwV?hW3_u3jD-i7#RNn;VV>BRPb#9 z<)~od{ z83sL=Xa50Lj5KFuk+sqYrNU;jmn?Rk=2-SRgQZgLE?@-0xA^!T)HT6~bo(MyZ3<10 zX^sy1`g%YhltNx!o}oZcE>%X1%pfh58qiE2R5;mLkd{VNnNSV*>>E6C*oDhg2Lhoa zGn@sQP>s=6cq3*REcv#p3~i@Fkj>8f#XqkaNn< zGT>@}nI#1J1A$P11il9I+GJi4LiON_-ZXZ0Bc@Mxq!%J25NOoY)Mn3~Im&JXR22G# zpv{Mn03Z-5gphZjtq1xwfIz4gFuq=QX!oAI2M)T5L?R&k^6>fEGiNwWcXEUZCpHRF zlfn8oAP~v}+RCWPh9*EDR2LW}E(m!3g8Q=NE5+h8ApF8;XlQKSw0_W_0Z>UKeuBtQ zXwZx-IY3Z`>?|s-j2>)aD-)_0jM7%>4GIc&nCg_2Buq_B1;Q_emKLvuL5)MM`)k#* zgi0kN9fIEiPa7<1L%~Ye1VY(JBoH0~8EI5sLK0UdR69x)W*XN+N7k;}$YL?Gva*5j z3&7=arBdngKbK6JGy%RFv60kw*4zrTbwGo3KO~e+S}HYvTvRC4k|WDh5UMF<2s6#T z11`sppG-_lQdMP(M9Dy?cIfc=**Vz)fnef^0W?9!YAl(rNzXJaU00960M8wb>00006Nkl = ({ externalAccou onClick={null} element="a" kind="primary" + data-kind="primary" target="_blank" rel="noreferrer noopener" href={externalAccountManagementUrl} data-testid="external-account-management-link" + style={{ textDecoration: "none" }} > {_t("settings|general|oidc_manage_button")} From 0f723390a9bc888c4bc462e3acab9f409c1e26b4 Mon Sep 17 00:00:00 2001 From: rbondesson Date: Tue, 12 May 2026 14:28:00 +0200 Subject: [PATCH 04/13] Refactor EventTile using the MVVM pattern - #1 (#33463) * Add EventTile layout attribute tests * Add EventTile timestamp tests * Add EventTile sender and avatar tests * Add EventTile read receipt tests * Add EventTile reactions and footer tests * Add EventTile action bar tests * Add EventTile reply chain tests * Add EventTile highlight exclusion tests * Add EventTile action bar focus tests * Add EventTile context menu characterization tests * Add EventTile rendering attribute tests * Add EventTile search thread info tests * Add EventTile reaction update tests * Add EventTile message type class tests * Fix flakyness in root attribute tests * Fix Sonar issues * Fix additional Sonar issues --- .../components/views/rooms/EventTile-test.tsx | 704 +++++++++++++++++- 1 file changed, 697 insertions(+), 7 deletions(-) diff --git a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx index 1d2d0ac657..b0f0c0d346 100644 --- a/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx +++ b/apps/web/test/unit-tests/components/views/rooms/EventTile-test.tsx @@ -15,6 +15,8 @@ import { type IEventDecryptionResult, type MatrixClient, MatrixEvent, + MatrixEventEvent, + MsgType, NotificationCountType, PendingEventOrdering, Room, @@ -44,6 +46,59 @@ import PinningUtils from "../../../../../src/utils/PinningUtils"; import { Layout } from "../../../../../src/settings/enums/Layout"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; import SettingsStore from "../../../../../src/settings/SettingsStore"; +import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; +import PlatformPeg from "../../../../../src/PlatformPeg"; + +function getTile(container: HTMLElement): HTMLElement { + const tile = container.querySelector(".mx_EventTile"); + expect(tile).not.toBeNull(); + return tile as HTMLElement; +} + +function getLine(container: HTMLElement): HTMLElement { + const line = container.querySelector(".mx_EventTile_line"); + expect(line).not.toBeNull(); + return line as HTMLElement; +} + +function expectTileClass(container: HTMLElement, className: string): void { + expect(getTile(container)).toHaveClass(className); +} + +function makeReplyEvent(roomId: string): MatrixEvent { + const parentEvent = mkMessage({ + room: roomId, + user: "@alice:example.org", + msg: "Original message", + event: true, + }); + + return mkMessage({ + room: roomId, + user: "@bob:example.org", + msg: "Reply message", + event: true, + relatesTo: { + "m.in_reply_to": { + event_id: parentEvent.getId(), + }, + }, + }); +} + +function makeThreadReplyEvent(roomId: string): MatrixEvent { + return mkMessage({ + room: roomId, + user: "@alice:example.org", + msg: "Hello world!", + ts: 1234, + event: true, + relatesTo: { + rel_type: "m.thread", + event_id: "$thread-root", + }, + }); +} describe("EventTile", () => { const ROOM_ID = "!roomId:example.org"; @@ -83,11 +138,35 @@ describe("EventTile", () => { return render(); } + function makeOwnMessage(overrides: Partial[0]> = {}): MatrixEvent { + return mkMessage({ + ...overrides, + room: overrides.room ?? room.roomId, + user: overrides.user ?? client.getSafeUserId(), + msg: overrides.msg ?? "Hello world!", + event: overrides.event ?? true, + }); + } + + function makeTimestampedMessage(overrides: Partial[0]> = {}): MatrixEvent { + return mkMessage({ + ...overrides, + room: overrides.room ?? room.roomId, + user: overrides.user ?? "@alice:example.org", + msg: overrides.msg ?? "Hello world!", + ts: overrides.ts ?? 1234, + event: overrides.event ?? true, + }); + } + beforeEach(() => { jest.clearAllMocks(); stubClient(); client = MatrixClientPeg.safeGet(); + jest.spyOn(DMRoomMap, "shared").mockReturnValue({ + getUserIdForRoomId: jest.fn().mockReturnValue(undefined), + } as unknown as DMRoomMap); room = new Room(ROOM_ID, client, client.getSafeUserId(), { pendingEventOrdering: PendingEventOrdering.Detached, @@ -110,6 +189,550 @@ describe("EventTile", () => { jest.restoreAllMocks(); }); + describe("layout and tile attributes", () => { + it.each([ + ["last", { last: true }, "mx_EventTile_last"], + ["lastInSection", { lastInSection: true }, "mx_EventTile_lastInSection"], + ["contextual", { contextual: true }, "mx_EventTile_contextual"], + ["isSelectedEvent", { isSelectedEvent: true }, "mx_EventTile_selected"], + ["hideSender", { hideSender: true }, "mx_EventTile_noSender"], + ["isTwelveHour", { isTwelveHour: true }, "mx_EventTile_12hr"], + ] as const)("adds the %s class", (_propName, overrides, className) => { + const { container } = getComponent(overrides); + + expectTileClass(container, className); + }); + + it("marks events from other users as non-self events", () => { + const { container } = getComponent(); + + expect(getTile(container)).toHaveAttribute("data-self", "false"); + }); + + it("marks events from the current user as self events", () => { + const ownEvent = makeOwnMessage(); + const { container } = getComponent({ mxEvent: ownEvent }); + + expect(getTile(container)).toHaveAttribute("data-self", "true"); + }); + + it("exposes the rendered event id in room timelines", () => { + const { container } = getComponent(); + + expect(getTile(container)).toHaveAttribute("data-event-id", mxEvent.getId()); + }); + + it("renders the event line inside the tile", () => { + const { container } = getComponent(); + + expect(getTile(container)).toContainElement(getLine(container)); + }); + + it("does not expose a scroll token for local echo events", () => { + const localEcho = makeOwnMessage(); + localEcho.setStatus(EventStatus.SENDING); + const { container } = getComponent({ mxEvent: localEcho, eventSendStatus: EventStatus.SENDING }); + + expect(getTile(container)).not.toHaveAttribute("data-scroll-tokens"); + }); + }); + + describe("rendering root attributes", () => { + type RootAttribute = + | "data-scroll-tokens" + | "data-layout" + | "data-shape" + | "data-self" + | "data-event-id" + | "data-has-reply"; + + it.each([ + [ + TimelineRenderingType.Room, + ["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"], + ["data-shape"], + ], + [ + TimelineRenderingType.Thread, + ["data-scroll-tokens", "data-layout", "data-self", "data-event-id", "data-has-reply"], + ["data-shape"], + ], + [ + TimelineRenderingType.ThreadsList, + ["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"], + ["data-event-id"], + ], + [ + TimelineRenderingType.Notification, + ["data-scroll-tokens", "data-layout", "data-shape", "data-self", "data-has-reply"], + ["data-event-id"], + ], + [ + TimelineRenderingType.File, + ["data-scroll-tokens"], + ["data-layout", "data-shape", "data-self", "data-event-id", "data-has-reply"], + ], + ] as const)( + "sets root attributes for %s rendering", + (renderingType, expectedPresentAttributes, expectedAbsentAttributes) => { + const { container } = getComponent({}, renderingType); + const tile = getTile(container); + const expectedValues: Record = { + "data-scroll-tokens": mxEvent.getId()!, + "data-layout": Layout.Group, + "data-shape": renderingType, + "data-self": "false", + "data-event-id": mxEvent.getId()!, + "data-has-reply": "false", + }; + + for (const attribute of expectedPresentAttributes) { + expect(tile).toHaveAttribute(attribute, expectedValues[attribute]); + } + + for (const attribute of expectedAbsentAttributes) { + expect(tile).not.toHaveAttribute(attribute); + } + }, + ); + }); + + describe("message type classes", () => { + it("adds media and image classes for image messages", () => { + const imageEvent = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: room.roomId, + user: "@alice:example.org", + content: { + msgtype: MsgType.Image, + body: "image.png", + url: "mxc://example.org/image", + info: { + mimetype: "image/png", + w: 100, + h: 100, + size: 1234, + }, + }, + }); + const { container } = getComponent({ mxEvent: imageEvent }); + + expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine"); + expect(getLine(container)).toHaveClass("mx_EventTile_image"); + }); + + it("adds emote classes for emote messages", () => { + const emoteEvent = mkEvent({ + event: true, + type: EventType.RoomMessage, + room: room.roomId, + user: "@alice:example.org", + content: { + msgtype: MsgType.Emote, + body: "waves", + }, + }); + const { container } = getComponent({ mxEvent: emoteEvent }); + + expect(getTile(container)).toHaveClass("mx_EventTile_emote"); + expect(getLine(container)).toHaveClass("mx_EventTile_emote"); + }); + + it("adds media and sticker classes for sticker events", () => { + const stickerEvent = mkEvent({ + event: true, + type: EventType.Sticker, + room: room.roomId, + user: "@alice:example.org", + content: { + body: "sticker.png", + url: "mxc://example.org/sticker", + info: { + mimetype: "image/png", + w: 100, + h: 100, + size: 1234, + }, + }, + }); + const { container } = getComponent({ mxEvent: stickerEvent }); + + expect(getLine(container)).toHaveClass("mx_EventTile_mediaLine"); + expect(getLine(container)).toHaveClass("mx_EventTile_sticker"); + }); + }); + + describe("timestamps", () => { + beforeEach(() => { + mxEvent = makeTimestampedMessage(); + }); + + it("hides the timestamp by default in room timelines", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + }); + + it("shows the timestamp when the tile is hovered", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("shows the timestamp when focus is within the tile", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + + fireEvent.focus(getTile(container)); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("shows the timestamp for the last event", () => { + const { container } = getComponent({ last: true }); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("shows the timestamp when timestamps are always shown", () => { + const { container } = getComponent({ alwaysShowTimestamps: true }); + + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("hides the timestamp when timestamps are disabled for the tile", () => { + const { container } = getComponent({ alwaysShowTimestamps: true, hideTimestamp: true }); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + }); + + it("renders a placeholder timestamp in IRC layout", () => { + const { container } = getComponent({ layout: Layout.IRC }); + const timestamp = container.querySelector(".mx_MessageTimestamp"); + + expect(timestamp).not.toBeNull(); + expect(timestamp?.tagName).toBe("SPAN"); + }); + + it("dispatches a room view when the linked timestamp is clicked", () => { + jest.spyOn(dis, "dispatch").mockImplementation(() => {}); + const permalinkCreator = new RoomPermalinkCreator(room); + const { container } = getComponent({ alwaysShowTimestamps: true, permalinkCreator }); + const timestamp = container.querySelector("a.mx_MessageTimestamp"); + + expect(timestamp).not.toBeNull(); + fireEvent.click(timestamp!); + + expect(dis.dispatch).toHaveBeenCalledWith( + expect.objectContaining({ + action: Action.ViewRoom, + event_id: mxEvent.getId(), + highlighted: true, + room_id: room.roomId, + }), + ); + }); + }); + + describe("sender and avatar rendering", () => { + it("shows sender and avatar in room timelines", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_DisambiguatedProfile")).not.toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull(); + }); + + it("hides sender and avatar for continuation events in room timelines", () => { + const { container } = getComponent({ continuation: true }); + + expectTileClass(container, "mx_EventTile_continuation"); + expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).toBeNull(); + }); + + it("hides sender but keeps avatar when sender display is disabled", () => { + const { container } = getComponent({ hideSender: true }); + + expectTileClass(container, "mx_EventTile_noSender"); + expect(container.querySelector(".mx_DisambiguatedProfile")).toBeNull(); + expect(container.querySelector(".mx_EventTile_avatar")).not.toBeNull(); + }); + + it("renders sender details as a permalink in file timelines", () => { + const { container } = getComponent({}, TimelineRenderingType.File); + const senderDetailsLink = container.querySelector(".mx_EventTile_senderDetailsLink"); + + expect(senderDetailsLink).not.toBeNull(); + expect(senderDetailsLink).toContainElement(container.querySelector(".mx_DisambiguatedProfile")); + expect(senderDetailsLink).toContainElement(container.querySelector(".mx_EventTile_avatar")); + }); + + it("renders sender details in thread timelines", () => { + const { container } = getComponent({}, TimelineRenderingType.Thread); + const senderDetails = container.querySelector(".mx_EventTile_senderDetails"); + + expect(senderDetails).not.toBeNull(); + expect(senderDetails).toContainElement(container.querySelector(".mx_DisambiguatedProfile")); + expect(senderDetails).toContainElement(container.querySelector(".mx_EventTile_avatar")); + }); + }); + + describe("read receipt option", () => { + it("shows a sent receipt for the current user's last successful event", () => { + const ownEvent = makeOwnMessage(); + const { getByRole } = getComponent({ mxEvent: ownEvent, lastSuccessful: true }); + + expect(getByRole("status")).toHaveAccessibleName("Your message was sent"); + }); + + it.each([ + [EventStatus.SENDING, "Sending your message…"], + [EventStatus.ENCRYPTING, "Encrypting your message…"], + [EventStatus.NOT_SENT, "Failed to send"], + ])("shows the %s receipt for the current user's pending event", (eventSendStatus, label) => { + const ownEvent = makeOwnMessage(); + ownEvent.setStatus(eventSendStatus); + const { getByRole } = getComponent({ mxEvent: ownEvent, eventSendStatus }); + + expect(getByRole("status")).toHaveAccessibleName(label); + }); + + it("does not show a sent receipt in the threads list", () => { + const ownEvent = makeOwnMessage(); + const { queryByRole } = getComponent( + { mxEvent: ownEvent, lastSuccessful: true }, + TimelineRenderingType.ThreadsList, + ); + + expect(queryByRole("status", { name: "Your message was sent" })).toBeNull(); + }); + + it("shows normal read receipts instead of the sent receipt when other users have read the event", () => { + const ownEvent = makeOwnMessage(); + const { getByRole, queryByRole } = getComponent({ + mxEvent: ownEvent, + lastSuccessful: true, + showReadReceipts: true, + readReceipts: [ + { + userId: "@bob:example.org", + roomMember: null, + ts: 1234, + }, + ], + }); + + expect(queryByRole("status", { name: "Your message was sent" })).toBeNull(); + expect(getByRole("group", { name: "Seen by 1 person" })).toBeInTheDocument(); + }); + }); + + describe("reactions and footer", () => { + it("gets annotation relations when reactions are enabled", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + + getComponent({ showReactions: true, getRelationsForEvent }); + + expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction"); + }); + + it("does not get annotation relations when reactions are disabled", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + + getComponent({ getRelationsForEvent }); + + expect(getRelationsForEvent).not.toHaveBeenCalled(); + }); + + it("refreshes annotation relations when reaction relations are created", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + getComponent({ showReactions: true, getRelationsForEvent }); + getRelationsForEvent.mockClear(); + + act(() => { + mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.annotation", "m.reaction"); + }); + + expect(getRelationsForEvent).toHaveBeenCalledWith(mxEvent.getId(), "m.annotation", "m.reaction"); + }); + + it("does not refresh annotation relations for unrelated relations", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + getComponent({ showReactions: true, getRelationsForEvent }); + getRelationsForEvent.mockClear(); + + act(() => { + mxEvent.emit(MatrixEventEvent.RelationsCreated, "m.reference", "m.room.message"); + }); + + expect(getRelationsForEvent).not.toHaveBeenCalled(); + }); + + it("does not render reactions for redacted events", () => { + const getRelationsForEvent = jest.fn().mockReturnValue(null); + const { container } = getComponent({ showReactions: true, getRelationsForEvent, isRedacted: true }); + + expect(container.querySelector(".mx_ReactionsRow")).toBeNull(); + }); + + it("renders a footer for pinned messages", () => { + jest.spyOn(PinningUtils, "isPinned").mockReturnValue(true); + const { container } = getComponent(); + + expect(container.querySelector(".mx_EventTile_footer")).not.toBeNull(); + expect(screen.getByText("Pinned message")).toBeInTheDocument(); + }); + }); + + describe("action bar", () => { + it("does not render the message action bar by default", () => { + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + + it("renders the message action bar when the tile is hovered", () => { + const { container } = getComponent(); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); + }); + + it("renders the message action bar when the tile receives keyboard focus", () => { + const matches = HTMLElement.prototype.matches; + jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) { + if (selector === ":focus-visible") return true; + return matches.call(this, selector); + }); + const { container } = getComponent(); + + fireEvent.focus(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); + }); + + it("hides the keyboard-focused message action bar when focus leaves the tile", () => { + const matches = HTMLElement.prototype.matches; + jest.spyOn(HTMLElement.prototype, "matches").mockImplementation(function (this: HTMLElement, selector) { + if (selector === ":focus-visible") return true; + return matches.call(this, selector); + }); + const { container } = getComponent(); + const tile = getTile(container); + + fireEvent.focus(tile); + expect(container.querySelector(".mx_MessageActionBar")).not.toBeNull(); + + fireEvent.blur(tile); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + + it("does not render the message action bar on hover when exporting", () => { + const { container } = getComponent({ forExport: true }); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + + it("does not render the message action bar on hover while editing", () => { + const { container } = getComponent({ editState: {} as EventTileProps["editState"] }); + + fireEvent.mouseEnter(getTile(container)); + + expect(container.querySelector(".mx_MessageActionBar")).toBeNull(); + }); + }); + + describe("context menu", () => { + it("renders the message context menu when the event line is right-clicked", async () => { + const { container } = getComponent(); + + fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 }); + + expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument(); + }); + + it("marks the tile selected when the context menu is open", async () => { + const { container } = getComponent(); + const tile = getTile(container); + + fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 }); + + expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument(); + expect(tile).toHaveClass("mx_EventTile_selected"); + }); + + it("shows the timestamp while the context menu is open", async () => { + mxEvent = makeTimestampedMessage(); + const { container } = getComponent(); + + expect(container.querySelector(".mx_MessageTimestamp")).toBeNull(); + + fireEvent.contextMenu(getLine(container), { clientX: 1, clientY: 2 }); + + expect(await screen.findByTestId("mx_MessageContextMenu")).toBeInTheDocument(); + expect(container.querySelector(".mx_MessageTimestamp")).not.toBeNull(); + }); + + it("does not render the message context menu while editing", () => { + const { container } = getComponent({ editState: {} as EventTileProps["editState"] }); + + expect(container.querySelector(".mx_EventTile_line")).toBeNull(); + expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull(); + }); + + it("does not override the native browser context menu for links", () => { + const { container } = getComponent(); + jest.spyOn(PlatformPeg, "get").mockReturnValue({ + allowOverridingNativeContextMenus: () => false, + } as ReturnType); + const link = document.createElement("a"); + link.href = "https://example.org/"; + getLine(container).appendChild(link); + + const event = new MouseEvent("contextmenu", { bubbles: true, cancelable: true, clientX: 1, clientY: 2 }); + link.dispatchEvent(event); + + expect(event.defaultPrevented).toBe(false); + expect(screen.queryByTestId("mx_MessageContextMenu")).toBeNull(); + }); + }); + + describe("reply chain", () => { + it("marks non-reply events as having no reply", () => { + const { container } = getComponent(); + + expect(getTile(container)).toHaveAttribute("data-has-reply", "false"); + expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull(); + }); + + it("marks reply events as having a reply chain", () => { + const replyEvent = makeReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: replyEvent }); + + expect(getTile(container)).toHaveAttribute("data-has-reply", "true"); + expect(container.querySelector(".mx_ReplyChain_wrapper")).not.toBeNull(); + }); + + it("does not render the reply chain for redacted reply events", () => { + const replyEvent = makeReplyEvent(room.roomId); + jest.spyOn(replyEvent, "isRedacted").mockReturnValue(true); + const { container } = getComponent({ mxEvent: replyEvent }); + + expect(getTile(container)).toHaveAttribute("data-has-reply", "false"); + expect(container.querySelector(".mx_ReplyChain_wrapper")).toBeNull(); + }); + }); + describe("EventTile thread summary", () => { beforeEach(() => { jest.spyOn(client, "supportsThreads").mockReturnValue(true); @@ -150,6 +773,43 @@ describe("EventTile", () => { }); }); + describe("search thread info", () => { + it("renders search thread info for events in a thread", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search); + + expect(container.querySelector(".mx_ThreadSummary_icon")).not.toBeNull(); + expect(container.querySelector(".mx_ThreadSummary_icon")).toHaveTextContent("From a thread"); + }); + + it("renders search thread info as a link when a highlight link is provided", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent( + { mxEvent: threadEvent, highlightLink: "https://example.org/thread" }, + TimelineRenderingType.Search, + ); + const threadInfo = container.querySelector("a.mx_ThreadSummary_icon"); + + expect(threadInfo).not.toBeNull(); + expect(threadInfo).toHaveAttribute("href", "https://example.org/thread"); + }); + + it("renders search thread info as text when no highlight link is provided", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Search); + const threadInfo = container.querySelector(".mx_ThreadSummary_icon"); + + expect(threadInfo?.tagName).toBe("P"); + }); + + it("does not render search thread info outside search timelines", () => { + const threadEvent = makeThreadReplyEvent(room.roomId); + const { container } = getComponent({ mxEvent: threadEvent }, TimelineRenderingType.Room); + + expect(container.querySelector(".mx_ThreadSummary_icon")).toBeNull(); + }); + }); + describe("EventTile renderingType: ThreadsList", () => { it("shows an unread notification badge", () => { const { container } = getComponent({}, TimelineRenderingType.ThreadsList); @@ -246,13 +906,6 @@ describe("EventTile", () => { }); describe("EventTile in the right panel", () => { - beforeAll(() => { - const dmRoomMap: DMRoomMap = { - getUserIdForRoomId: jest.fn(), - } as unknown as DMRoomMap; - DMRoomMap.setShared(dmRoomMap); - }); - it("renders the room name for notifications", () => { const { container } = getComponent({}, TimelineRenderingType.Notification); expect(container.getElementsByClassName("mx_EventTile_details")[0]).toHaveTextContent( @@ -600,6 +1253,43 @@ describe("EventTile", () => { expect(isHighlighted(container)).toBeFalsy(); }); + it("does not highlight when exporting", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + const { container } = getComponent({ forExport: true }); + + expect(client.getPushActionsForEvent).not.toHaveBeenCalled(); + expect(isHighlighted(container)).toBeFalsy(); + }); + + it.each([TimelineRenderingType.Notification, TimelineRenderingType.ThreadsList])( + "does not highlight in %s timelines", + (renderingType) => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + const { container } = getComponent({}, renderingType); + + expect(client.getPushActionsForEvent).not.toHaveBeenCalled(); + expect(isHighlighted(container)).toBeFalsy(); + }, + ); + + it("does not highlight events sent by the current user", () => { + mocked(client.getPushActionsForEvent).mockReturnValue({ + notify: true, + tweaks: { [TweakName.Highlight]: true }, + }); + const ownEvent = makeOwnMessage(); + const { container } = getComponent({ mxEvent: ownEvent }); + + expect(client.getPushActionsForEvent).toHaveBeenCalledWith(ownEvent); + expect(isHighlighted(container)).toBeFalsy(); + }); + it("highlights when message's push actions have a highlight tweak", () => { mocked(client.getPushActionsForEvent).mockReturnValue({ notify: true, From f72f15ff1bb73798f0dd5d4bc9d61cc0f7e3f7f9 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 May 2026 13:12:37 +0000 Subject: [PATCH 05/13] Upgrade dependency to matrix-js-sdk@41.5.0 --- apps/web/package.json | 2 +- pnpm-lock.yaml | 11 ++++++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 7df89851cd..f2a0900e81 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -81,7 +81,7 @@ "lodash": "npm:lodash-es@4.18.1", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-js-sdk": "41.5.0-rc.0", + "matrix-js-sdk": "41.5.0", "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ac6af903b1..2d344cd629 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -472,8 +472,8 @@ importers: specifier: ^1.0.3 version: 1.0.3 matrix-js-sdk: - specifier: 41.5.0-rc.0 - version: 41.5.0-rc.0 + specifier: 41.5.0 + version: 41.5.0 matrix-widget-api: specifier: ^1.17.0 version: 1.17.0 @@ -5797,6 +5797,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -9977,8 +9978,8 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@41.5.0-rc.0: - resolution: {integrity: sha512-+3k+eUYJzjX8wCTI/JrP22/QnhB8tb7vdZTtXh4g4ukVxf+B20HguAPOEmVYXmKUTko9hxCHu2e7DQqZazqZBA==} + matrix-js-sdk@41.5.0: + resolution: {integrity: sha512-CK3h+qQJ4wkVEUgEWc5MdLjccXyiFqncCC53P+auqOhnX2U6tAFsRfnbML1QQiKIsFMzqTrAnF/4a5LUUOIeXg==} engines: {node: '>=22.0.0'} matrix-web-i18n@3.6.0: @@ -23579,7 +23580,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@41.5.0-rc.0: + matrix-js-sdk@41.5.0: dependencies: '@babel/runtime': 7.29.2 '@matrix-org/matrix-sdk-crypto-wasm': 18.2.0 From dc0dbd540becbb34d32a72570efcef1b30d174e5 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 May 2026 13:17:09 +0000 Subject: [PATCH 06/13] v1.12.18 --- CHANGELOG.md | 33 +++++++++++++++++++++++++++++++++ apps/desktop/package.json | 2 +- apps/web/package.json | 2 +- 3 files changed, 35 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c53af59eb..2c8720d408 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,36 @@ +Changes in [1.12.18](https://github.com/element-hq/element-web/releases/tag/v1.12.18) (2026-05-12) +================================================================================================== +## ✨ Features + +* Room list: add collapse/expand all sections ([#33318](https://github.com/element-hq/element-web/pull/33318)). Contributed by @florianduros. +* Show user status in timeline ([#32991](https://github.com/element-hq/element-web/pull/32991)). Contributed by @Half-Shot. +* Disable URL Preview setting if disabled on the homeserver ([#33279](https://github.com/element-hq/element-web/pull/33279)). Contributed by @Half-Shot. +* Go to welcome on logout ([#33306](https://github.com/element-hq/element-web/pull/33306)). Contributed by @t3chguy. +* Room list: edit or remove custom sections ([#33283](https://github.com/element-hq/element-web/pull/33283)). Contributed by @florianduros. +* Re-generate QR code if the channel expires before scan ([#33303](https://github.com/element-hq/element-web/pull/33303)). Contributed by @t3chguy. +* Update toast styles, improve incoming call notifications ([#33043](https://github.com/element-hq/element-web/pull/33043)). Contributed by @robintown. +* Add Module Composer API ([#33284](https://github.com/element-hq/element-web/pull/33284)). Contributed by @Half-Shot. +* Room list: exclude default section from room list item menu ([#33278](https://github.com/element-hq/element-web/pull/33278)). Contributed by @florianduros. +* Show 'Verify this device' toast even if there are no encrypted rooms yet ([#32891](https://github.com/element-hq/element-web/pull/32891)). Contributed by @andybalaam. +* Promote "Share encrypted history" from labs ([#33281](https://github.com/element-hq/element-web/pull/33281)). Contributed by @richvdh. +* Room list: assign room to section when section is created ([#33240](https://github.com/element-hq/element-web/pull/33240)). Contributed by @florianduros. +* Confirm before inviting unknown users to a DM/room ([#33171](https://github.com/element-hq/element-web/pull/33171)). Contributed by @richvdh. +* Room list: assign room to custom section ([#33238](https://github.com/element-hq/element-web/pull/33238)). Contributed by @florianduros. +* Redesign link previews ([#33061](https://github.com/element-hq/element-web/pull/33061)). Contributed by @Half-Shot. +* Room list: scroll to newly creation section ([#33210](https://github.com/element-hq/element-web/pull/33210)). Contributed by @florianduros. + +## 🐛 Bug Fixes + +* Update home page CSS ([#32723](https://github.com/element-hq/element-web/pull/32723)). Contributed by @wolterkam. +* Web: Fix typo in `152x152` icon source of `manifest.json` ([#33369](https://github.com/element-hq/element-web/pull/33369)). Contributed by @bartvdbraak. +* prevent replay hover from restarting playback ([#33364](https://github.com/element-hq/element-web/pull/33364)). Contributed by @ZacksBot. +* Properly save `undefined` id tokens from OIDC login ([#33345](https://github.com/element-hq/element-web/pull/33345)). Contributed by @gingershaped. +* Show the right cursor when hovering over a space ([#33351](https://github.com/element-hq/element-web/pull/33351)). Contributed by @robintown. +* Set `type` in auth dict for `m.oauth` UIA stage ([#33344](https://github.com/element-hq/element-web/pull/33344)). Contributed by @gingershaped. +* Remove duplicated UI in appearance settings ([#33336](https://github.com/element-hq/element-web/pull/33336)). Contributed by @t3chguy. +* Move playwright-common wait-on from devDependencies to dependencies ([#33272](https://github.com/element-hq/element-web/pull/33272)). Contributed by @t3chguy. + + Changes in [1.12.17](https://github.com/element-hq/element-web/releases/tag/v1.12.17) (2026-04-30) ================================================================================================== ## 🐛 Bug Fixes diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a080962f65..db60149ecd 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -3,7 +3,7 @@ "productName": "Element", "main": "lib/electron-main.js", "exports": "./lib/electron-main.js", - "version": "1.12.18-rc.0", + "version": "1.12.18", "description": "Element: the future of secure communication", "author": { "name": "Element", diff --git a/apps/web/package.json b/apps/web/package.json index f2a0900e81..a19d4bb311 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,6 +1,6 @@ { "name": "element-web", - "version": "1.12.18-rc.0", + "version": "1.12.18", "description": "Element: the future of secure communication", "author": "New Vector Ltd.", "repository": { From 13dd1a0b5e4594d2c1f60ba6c39c3454b2552b71 Mon Sep 17 00:00:00 2001 From: RiotRobot Date: Tue, 12 May 2026 13:21:16 +0000 Subject: [PATCH 07/13] Reset matrix-js-sdk back to develop branch --- apps/web/package.json | 2 +- pnpm-lock.yaml | 25 +++++-------------------- 2 files changed, 6 insertions(+), 21 deletions(-) diff --git a/apps/web/package.json b/apps/web/package.json index 3acbad37ab..2a094d9ae6 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -81,7 +81,7 @@ "lodash": "npm:lodash-es@4.18.1", "maplibre-gl": "^5.0.0", "matrix-encrypt-attachment": "^1.0.3", - "matrix-js-sdk": "41.5.0", + "matrix-js-sdk": "github:matrix-org/matrix-js-sdk#develop", "matrix-widget-api": "^1.16.1", "memoize-one": "^6.0.0", "mime": "^4.0.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0aeef1ee5..8eecf981a2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -463,7 +463,7 @@ importers: version: 1.0.3 matrix-js-sdk: specifier: github:matrix-org/matrix-js-sdk#develop - version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32 + version: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9 matrix-widget-api: specifier: ^1.17.0 version: 1.17.0 @@ -1530,7 +1530,6 @@ packages: '@babel/plugin-proposal-private-methods@7.18.6': resolution: {integrity: sha512-nutsvktDItsNn4rpGItSNV2sz1XwS+nfU0Rg8aCx3W3NOKVzdMjJRu0O5OkgDp3ZGICSTbgRpxZoWsxoKRvbeA==} engines: {node: '>=6.9.0'} - deprecated: This proposal has been merged to the ECMAScript standard and thus this plugin is no longer maintained. Please use @babel/plugin-transform-private-methods instead. peerDependencies: '@babel/core': ^7.0.0-0 @@ -2822,7 +2821,6 @@ packages: '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} - deprecated: Use @eslint/config-array instead '@humanwhocodes/module-importer@1.0.1': resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} @@ -2830,7 +2828,6 @@ packages: '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} - deprecated: Use @eslint/object-schema instead '@iconify-json/simple-icons@1.2.75': resolution: {integrity: sha512-KvcCUbvcBWb0sbqLIxHoY8z5/piXY08wcY9gfMhF+ph3AfzGMaSmZFkUY71HSXAljQngXkgs4bdKdekO0HQWvg==} @@ -5752,7 +5749,6 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} - deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@unrs/resolver-binding-android-arm-eabi@1.11.1': resolution: {integrity: sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==} @@ -6649,7 +6645,6 @@ packages: boolean@3.2.0: resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} - deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. brace-expansion@1.1.13: resolution: {integrity: sha512-9ZLprWS6EENmhEOpjCYW2c8VkmOvckIJZfkr7rBW6dObmfgJ/L1GpSYW5Hpo9lDz4D1+n0Ckz8rU7FwHDQiG/w==} @@ -8183,7 +8178,6 @@ packages: eslint@8.57.1: resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true espree@10.4.0: @@ -8611,7 +8605,6 @@ packages: glob@10.5.0: resolution: {integrity: sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me hasBin: true glob@13.0.6: @@ -8620,7 +8613,6 @@ packages: glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} - deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me global-agent@3.0.0: resolution: {integrity: sha512-PT6XReJ+D07JvGoxQMkT6qji/jVNfX/h364XHZOWeRzy64sSFr+xJ5OX7LI3b4MPQzdL4H8Y8M0xzPpsVMwA8Q==} @@ -8985,7 +8977,6 @@ packages: inflight@1.0.6: resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} - deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. inherits@2.0.4: resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} @@ -9927,9 +9918,9 @@ packages: matrix-events-sdk@0.0.1: resolution: {integrity: sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32: - resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32} - version: 41.4.0 + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9: + resolution: {tarball: https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9} + version: 41.5.0 engines: {node: '>=22.0.0'} matrix-web-i18n@3.6.0: @@ -11365,7 +11356,6 @@ packages: react-beautiful-dnd@13.1.1: resolution: {integrity: sha512-0Lvs4tq2VcrEjEgDXHjT98r+63drkKEgqyxdA7qD3mvKwga6a5SscbdLPO2IExotU1jW8L0Ksdl0Cj2AF67nPQ==} - deprecated: 'react-beautiful-dnd is now deprecated. Context and options: https://github.com/atlassian/react-beautiful-dnd/issues/2672' peerDependencies: react: ^16.8.5 || ^17.0.0 || ^18.0.0 react-dom: ^16.8.5 || ^17.0.0 || ^18.0.0 @@ -11701,12 +11691,10 @@ packages: rimraf@2.6.3: resolution: {integrity: sha512-mwqeW5XsA2qAejG46gYdENaxXjx9onRNCfn7L0duuP4hCuTIi/QO7PDK07KJfp1d+izWPrzEJDcSqBa0OZQriA==} - deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@3.0.2: resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} - deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true rimraf@6.1.3: @@ -12903,7 +12891,6 @@ packages: uuid@10.0.0: resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true uuid@11.1.1: @@ -12916,7 +12903,6 @@ packages: uuid@8.3.2: resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==} - deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true v8-to-istanbul@9.3.0: @@ -13270,7 +13256,6 @@ packages: whatwg-encoding@3.1.1: resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} engines: {node: '>=18'} - deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation whatwg-mimetype@4.0.0: resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} @@ -23486,7 +23471,7 @@ snapshots: matrix-events-sdk@0.0.1: {} - matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/d4739cbeda2b0b21e548dc496eb47da939c11f32: + matrix-js-sdk@https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/b125ef6855303575ae8f2fcc41427e746f22e8c9: dependencies: '@babel/runtime': 7.29.2 '@matrix-org/matrix-sdk-crypto-wasm': 18.2.0 From 1e7c9f672a693c99ae27ebdcb614255fedf9990a Mon Sep 17 00:00:00 2001 From: Zack Date: Wed, 13 May 2026 08:03:43 +0200 Subject: [PATCH 08/13] Phase 2 Refactor MImageBody to MVVM and remove legacy component (#33212) * MVVMing of MImageBody and removing legacy component + css * Fix Prettier * update small image to large image in test * Update test * Preserve MImageBody legacy class names * Click image in custom component download test * Update snapshots * Update MBodyFactory snapshots * Added new tests to pass coverage * Fix prettier * Remove legacy import that was removed * Add MImageReplayBody test for coverage * Remove legacy MImageBody selectors from image view * Update image body selectors in Playwright tests * Keep file panel image body spacing compact * Update apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts Co-authored-by: Florian Duros * added documentation to component * Fix hidden media placeholder import --------- Co-authored-by: Florian Duros --- .../web/playwright/e2e/composer/CIDER.spec.ts | 2 +- apps/web/playwright/e2e/composer/RTE.spec.ts | 4 +- .../e2e/file-upload/image-upload.spec.ts | 2 +- .../e2e/modules/custom-component.spec.ts | 20 +- .../e2e/right-panel/file-panel.spec.ts | 8 +- .../playwright/e2e/timeline/timeline.spec.ts | 4 +- apps/web/res/css/_components.pcss | 1 - apps/web/res/css/structures/_FilePanel.pcss | 4 + .../res/css/views/messages/_MImageBody.pcss | 81 -- .../css/views/messages/_MImageReplyBody.pcss | 58 + .../res/css/views/rooms/_EventBubbleTile.pcss | 36 +- apps/web/res/css/views/rooms/_EventTile.pcss | 8 +- .../views/messages/MBodyFactory.tsx | 121 ++ .../components/views/messages/MImageBody.tsx | 714 ------------ .../views/messages/MImageReplyBody.tsx | 612 +++++++++- .../views/messages/MStickerBody.tsx | 4 +- .../views/messages/MessageEvent.tsx | 6 +- .../message-body/ImageBodyViewModel.ts | 674 +++++++++++ .../views/messages/MBodyFactory-test.tsx | 184 ++- .../views/messages/MImageBody-test.tsx | 376 ------ .../views/messages/MImageReplyBody-test.tsx | 620 ++++++++++ .../views/messages/MessageEvent-test.tsx | 6 +- .../__snapshots__/MImageBody-test.tsx.snap | 379 ------ .../message-body/ImageBodyViewModel-test.tsx | 1012 +++++++++++++++++ .../MImageBodyView/ImageBodyView.test.tsx | 32 + .../body/MImageBodyView/ImageBodyView.tsx | 29 +- 26 files changed, 3389 insertions(+), 1608 deletions(-) delete mode 100644 apps/web/res/css/views/messages/_MImageBody.pcss delete mode 100644 apps/web/src/components/views/messages/MImageBody.tsx create mode 100644 apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts delete mode 100644 apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx create mode 100644 apps/web/test/unit-tests/components/views/messages/MImageReplyBody-test.tsx delete mode 100644 apps/web/test/unit-tests/components/views/messages/__snapshots__/MImageBody-test.tsx.snap create mode 100644 apps/web/test/viewmodels/message-body/ImageBodyViewModel-test.tsx diff --git a/apps/web/playwright/e2e/composer/CIDER.spec.ts b/apps/web/playwright/e2e/composer/CIDER.spec.ts index 89f4cad276..2b2a241aeb 100644 --- a/apps/web/playwright/e2e/composer/CIDER.spec.ts +++ b/apps/web/playwright/e2e/composer/CIDER.spec.ts @@ -208,7 +208,7 @@ test.describe("Composer", () => { }); await app.viewRoomByName("Bob"); await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png"); - await expect(page.locator(".mx_MImageBody")).toBeVisible(); + await expect(page.locator(".mx_ImageBody")).toBeVisible(); }); }); }); diff --git a/apps/web/playwright/e2e/composer/RTE.spec.ts b/apps/web/playwright/e2e/composer/RTE.spec.ts index 2c5f8071ec..8227c7f9ae 100644 --- a/apps/web/playwright/e2e/composer/RTE.spec.ts +++ b/apps/web/playwright/e2e/composer/RTE.spec.ts @@ -198,7 +198,7 @@ test.describe("Composer", () => { test("can paste a file", async ({ page, bot, app }) => { await app.composerDragAndPasteFile("room", getSampleFilePath("riot.png"), "image/png"); - await expect(page.locator(".mx_MImageBody")).toBeVisible(); + await expect(page.locator(".mx_ImageBody")).toBeVisible(); }); test("can paste a file in a thread", async ({ page, app }) => { @@ -213,7 +213,7 @@ test.describe("Composer", () => { await tile.getByRole("button", { name: "Reply in thread" }).click(); await app.composerDragAndPasteFile("thread", getSampleFilePath("riot.png"), "image/png"); - await expect(page.locator(".mx_MImageBody")).toBeVisible(); + await expect(page.locator(".mx_ImageBody")).toBeVisible(); }); test.describe("when Control+Enter is required to send", () => { diff --git a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts index 67ca01bd09..2af553ed66 100644 --- a/apps/web/playwright/e2e/file-upload/image-upload.spec.ts +++ b/apps/web/playwright/e2e/file-upload/image-upload.spec.ts @@ -37,7 +37,7 @@ test.describe("Image Upload", () => { test("should allow upload via drag and drop", { tag: "@screenshot" }, async ({ page, app }) => { await app.composerDragAndUploadFiles("room", getSampleFilePath("riot.png"), "image/png"); await app.timeline.scrollToBottom(); - const imgTile = page.locator(".mx_MImageBody").first(); + const imgTile = page.locator(".mx_ImageBody").first(); await expect(imgTile).toBeVisible(); }); }); diff --git a/apps/web/playwright/e2e/modules/custom-component.spec.ts b/apps/web/playwright/e2e/modules/custom-component.spec.ts index 5d2dc34aef..32615ea6b1 100644 --- a/apps/web/playwright/e2e/modules/custom-component.spec.ts +++ b/apps/web/playwright/e2e/modules/custom-component.spec.ts @@ -105,12 +105,16 @@ test.describe("Custom Component API", () => { }); await app.timeline.scrollToBottom(); - const imgTile = page.locator(".mx_MImageBody").first(); + const imgTile = page.locator(".mx_ImageBody").first(); await expect(imgTile).toBeVisible(); + const image = imgTile.getByRole("img", { name: "bad.png" }); + await expect(image).toBeVisible(); await imgTile.hover(); await expect(page.getByRole("button", { name: "Download" })).not.toBeVisible(); - await imgTile.click(); - await expect(page.getByLabel("Image view").getByLabel("Download")).not.toBeVisible(); + await image.click(); + const imageView = page.getByLabel("Image view"); + await expect(imageView).toBeVisible(); + await expect(imageView.getByLabel("Download")).not.toBeVisible(); }); test("should allow downloading media when the allowDownloading hint is set to true", async ({ page, @@ -127,12 +131,16 @@ test.describe("Custom Component API", () => { }); await app.timeline.scrollToBottom(); - const imgTile = page.locator(".mx_MImageBody").first(); + const imgTile = page.locator(".mx_ImageBody").first(); await expect(imgTile).toBeVisible(); + const image = imgTile.getByRole("img", { name: "good.png" }); + await expect(image).toBeVisible(); await imgTile.hover(); await expect(page.getByRole("button", { name: "Download" })).toBeVisible(); - await imgTile.click(); - await expect(page.getByLabel("Image view").getByLabel("Download")).toBeVisible(); + await image.click(); + const imageView = page.getByLabel("Image view"); + await expect(imageView).toBeVisible(); + await expect(imageView.getByLabel("Download")).toBeVisible(); }); test( "should render the next registered component if the filter function throws", diff --git a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts index e89c10b20f..94d3c69544 100644 --- a/apps/web/playwright/e2e/right-panel/file-panel.spec.ts +++ b/apps/web/playwright/e2e/right-panel/file-panel.spec.ts @@ -87,9 +87,9 @@ test.describe("FilePanel", () => { await expect(filePanelMessageList.getByText(NAME)).toHaveCount(3); // Detect the image file - const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody"); + const image = filePanelMessageList.locator(".mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody"); // Assert that the image is specified as thumbnail and has the alt string - await expect(image.locator("img[class='mx_MImageBody_thumbnail']")).toBeVisible(); + await expect(image.locator("img.mx_ImageBody_image")).toBeVisible(); await expect(image.locator("img[alt='riot.png']")).toBeVisible(); // Detect the audio file @@ -113,7 +113,7 @@ test.describe("FilePanel", () => { "flex-end", ); // Assert that all of the file tiles are visible before taking a snapshot - await expect(filePanelMessageList.locator(".mx_MImageBody")).toBeVisible(); // top + await expect(filePanelMessageList.locator(".mx_ImageBody")).toBeVisible(); // top await expect(filePanelMessageList.locator(".mx_MAudioBody")).toBeVisible(); // middle const senderDetails = filePanelMessageList.locator(".mx_EventTile_last .mx_EventTile_senderDetails"); await expect(senderDetails.locator(".mx_DisambiguatedProfile")).toBeVisible(); @@ -184,7 +184,7 @@ test.describe("FilePanel", () => { // Detect the image file on the panel const imageBody = page.locator( - ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_MImageBody", + ".mx_FilePanel .mx_RoomView_MessageList .mx_EventTile_mediaLine.mx_EventTile_image .mx_ImageBody", ); const link = imageBody.locator(".mx_MFileBody a"); diff --git a/apps/web/playwright/e2e/timeline/timeline.spec.ts b/apps/web/playwright/e2e/timeline/timeline.spec.ts index a9a25bcbb4..75e1c4557d 100644 --- a/apps/web/playwright/e2e/timeline/timeline.spec.ts +++ b/apps/web/playwright/e2e/timeline/timeline.spec.ts @@ -907,7 +907,7 @@ test.describe("Timeline", () => { await sendImage(bot, room.roomId, NEW_AVATAR); await app.timeline.scrollToBottom(); - const imgTile = page.locator(".mx_MImageBody").first(); + const imgTile = page.locator(".mx_ImageBody").first(); await expect(imgTile).toBeVisible(); await imgTile.hover(); await page.getByRole("button", { name: "Hide" }).click(); @@ -1314,7 +1314,7 @@ test.describe("Timeline", () => { await sendImage(app.client, room.roomId, NEW_AVATAR); await app.timeline.scrollToBottom(); - await expect(page.locator(".mx_MImageBody").first()).toBeVisible(); + await expect(page.locator(".mx_ImageBody").first()).toBeVisible(); // Exclude timestamp and read marker from snapshot const screenshotOptions = { diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index a215241bbf..9615dc52f9 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -224,7 +224,6 @@ @import "./views/messages/_DisambiguatedProfile.pcss"; @import "./views/messages/_LegacyCallEvent.pcss"; @import "./views/messages/_MFileBody.pcss"; -@import "./views/messages/_MImageBody.pcss"; @import "./views/messages/_MImageReplyBody.pcss"; @import "./views/messages/_MLocationBody.pcss"; @import "./views/messages/_MPollBody.pcss"; diff --git a/apps/web/res/css/structures/_FilePanel.pcss b/apps/web/res/css/structures/_FilePanel.pcss index 615e2a32ae..3261c26a1c 100644 --- a/apps/web/res/css/structures/_FilePanel.pcss +++ b/apps/web/res/css/structures/_FilePanel.pcss @@ -45,6 +45,10 @@ Please see LICENSE files in the repository root for full details. margin-top: var(--cpd-space-4x); } + .mx_ImageBody { + gap: 0; + } + /* anchor link as wrapper */ .mx_EventTile_senderDetailsLink { text-decoration: none; diff --git a/apps/web/res/css/views/messages/_MImageBody.pcss b/apps/web/res/css/views/messages/_MImageBody.pcss deleted file mode 100644 index 0e73c1d55c..0000000000 --- a/apps/web/res/css/views/messages/_MImageBody.pcss +++ /dev/null @@ -1,81 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2021 The Matrix.org Foundation C.I.C. -Copyright 2015, 2016 OpenMarket Ltd - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -.mx_MImageBody_banner { - position: absolute; - bottom: $spacing-4; - left: $spacing-4; - padding: $spacing-4; - border-radius: var(--MBody-border-radius); - font-size: $font-15px; - user-select: none; /* prevent banner text from being selected */ - pointer-events: none; /* let the cursor go through to the media underneath */ - - /* Trying to match the width of the image is surprisingly difficult, so arbitrarily break it off early. */ - max-width: min(100%, 350px); - - text-overflow: ellipsis; - white-space: nowrap; - overflow: hidden; - - /* Hardcoded colours because it's the same on all themes */ - background-color: rgb(0, 0, 0, 0.6); - color: #ffffff; -} - -.mx_MImageBody_placeholder { - /* Position the placeholder on top of the thumbnail, so that the reveal animation can work */ - position: absolute; - left: 0; - top: 0; - height: 100%; - width: 100%; - - background-color: $background; - - .mx_Blurhash > canvas { - animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1); - } -} - -.mx_MImageBody_thumbnail_container { - border-radius: var(--MBody-border-radius); - - /* Necessary for the border radius to apply correctly to the placeholder */ - overflow: hidden; - contain: paint; -} - -.mx_MImageBody_thumbnail { - display: block; - - /* Force the image to be the full size of the container, even if the */ - /* pixel size is smaller. The problem here is that we don't know what */ - /* thumbnail size the HS is going to give us, but we have to commit to */ - /* a container size immediately and not change it when the image loads */ - /* or we'll get a scroll jump (or have to leave blank space). */ - /* This will obviously result in an upscaled image which will be a bit */ - /* blurry. The best fix would be for the HS to advertise what size thumbnails */ - /* it guarantees to produce. */ - height: 100%; - width: 100%; -} - -.mx_MImageBody_gifLabel { - position: absolute; - display: block; - top: 0px; - left: 14px; - padding: 5px; - border-radius: 5px; - background: $imagebody-giflabel; - border: 2px solid $imagebody-giflabel-border; - color: $imagebody-giflabel-color; - pointer-events: none; -} diff --git a/apps/web/res/css/views/messages/_MImageReplyBody.pcss b/apps/web/res/css/views/messages/_MImageReplyBody.pcss index 9576e75fd8..e8c2c4047d 100644 --- a/apps/web/res/css/views/messages/_MImageReplyBody.pcss +++ b/apps/web/res/css/views/messages/_MImageReplyBody.pcss @@ -6,6 +6,64 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ +.mx_MImageReplyBody, +.mx_MStickerBody_wrapper { + .mx_MImageBody_banner { + position: absolute; + bottom: $spacing-4; + left: $spacing-4; + padding: $spacing-4; + border-radius: var(--MBody-border-radius); + font-size: $font-15px; + user-select: none; + pointer-events: none; + max-width: min(100%, 350px); + text-overflow: ellipsis; + white-space: nowrap; + overflow: hidden; + background-color: rgb(0, 0, 0, 0.6); + color: #ffffff; + } + + .mx_MImageBody_placeholder { + position: absolute; + left: 0; + top: 0; + height: 100%; + width: 100%; + background-color: $background; + + .mx_Blurhash > canvas { + animation: mx--anim-pulse 1.75s infinite cubic-bezier(0.4, 0, 0.6, 1); + } + } + + .mx_MImageBody_thumbnail_container { + border-radius: var(--MBody-border-radius); + overflow: hidden; + contain: paint; + } + + .mx_MImageBody_thumbnail { + display: block; + height: 100%; + width: 100%; + } + + .mx_MImageBody_gifLabel { + position: absolute; + display: block; + top: 0px; + left: 14px; + padding: 5px; + border-radius: 5px; + background: $imagebody-giflabel; + border: 2px solid $imagebody-giflabel-border; + color: $imagebody-giflabel-color; + pointer-events: none; + } +} + .mx_MImageReplyBody { display: flex; column-gap: $spacing-4; diff --git a/apps/web/res/css/views/rooms/_EventBubbleTile.pcss b/apps/web/res/css/views/rooms/_EventBubbleTile.pcss index 6c4febd10f..18dac9547a 100644 --- a/apps/web/res/css/views/rooms/_EventBubbleTile.pcss +++ b/apps/web/res/css/views/rooms/_EventBubbleTile.pcss @@ -156,8 +156,8 @@ Please see LICENSE files in the repository root for full details. padding-right: 48px !important; } - .mx_MImageBody { - .mx_MImageBody_thumbnail_container { + .mx_ImageBody { + .mx_ImageBody_container { justify-content: center; min-height: calc(1.8rem + var(--gutterSize) + var(--gutterSize)); min-width: calc(1.8rem + var(--gutterSize) + var(--gutterSize)); @@ -181,8 +181,8 @@ Please see LICENSE files in the repository root for full details. .mx_EventTile_line { border-bottom-right-radius: var(--cornerRadius); - .mx_MImageBody .mx_MImageBody_thumbnail_container, - .mx_MImageBody::before, + .mx_ImageBody .mx_ImageBody_container, + .mx_ImageBody::before, .mx_MVideoBody .mx_MVideoBody_container, .mx_MediaBody, .mx_MLocationBody_map, @@ -220,8 +220,8 @@ Please see LICENSE files in the repository root for full details. margin-inline-start: auto; border-bottom-left-radius: var(--cornerRadius); - .mx_MImageBody .mx_MImageBody_thumbnail_container, - .mx_MImageBody::before, + .mx_ImageBody .mx_ImageBody_container, + .mx_ImageBody::before, .mx_MVideoBody .mx_MVideoBody_container, .mx_MediaBody, .mx_MLocationBody_map, @@ -334,16 +334,12 @@ Please see LICENSE files in the repository root for full details. } } - .mx_MImageBody { + .mx_ImageBody { width: 100%; - - .mx_MImageBody_thumbnail.mx_MImageBody_thumbnail--blurhash { - position: unset; - } } /* noinspection CssReplaceWithShorthandSafely */ - .mx_MImageBody .mx_MImageBody_thumbnail_container, + .mx_ImageBody .mx_ImageBody_container, .mx_MVideoBody .mx_MVideoBody_container, .mx_MediaBody { border-radius: unset; @@ -375,9 +371,9 @@ Please see LICENSE files in the repository root for full details. &.mx_EventTile_continuation[data-self="false"] .mx_EventTile_line { border-top-left-radius: 0; - .mx_MImageBody .mx_MImageBody_thumbnail_container, + .mx_ImageBody .mx_ImageBody_container, .mx_MVideoBody .mx_MVideoBody_container, - .mx_MImageBody::before, + .mx_ImageBody::before, .mx_MediaBody, .mx_MLocationBody_map, .mx_MBeaconBody { @@ -387,9 +383,9 @@ Please see LICENSE files in the repository root for full details. &.mx_EventTile_lastInSection[data-self="false"] .mx_EventTile_line { border-bottom-left-radius: var(--cornerRadius); - .mx_MImageBody .mx_MImageBody_thumbnail_container, + .mx_ImageBody .mx_ImageBody_container, .mx_MVideoBody .mx_MVideoBody_container, - .mx_MImageBody::before, + .mx_ImageBody::before, .mx_MediaBody, .mx_MLocationBody_map, .mx_MBeaconBody { @@ -400,9 +396,9 @@ Please see LICENSE files in the repository root for full details. &.mx_EventTile_continuation[data-self="true"] .mx_EventTile_line { border-top-right-radius: 0; - .mx_MImageBody .mx_MImageBody_thumbnail_container, + .mx_ImageBody .mx_ImageBody_container, .mx_MVideoBody .mx_MVideoBody_container, - .mx_MImageBody::before, + .mx_ImageBody::before, .mx_MediaBody, .mx_MLocationBody_map, .mx_MBeaconBody { @@ -412,9 +408,9 @@ Please see LICENSE files in the repository root for full details. &.mx_EventTile_lastInSection[data-self="true"] .mx_EventTile_line { border-bottom-right-radius: var(--cornerRadius); - .mx_MImageBody .mx_MImageBody_thumbnail_container, + .mx_ImageBody .mx_ImageBody_container, .mx_MVideoBody .mx_MVideoBody_container, - .mx_MImageBody::before, + .mx_ImageBody::before, .mx_MediaBody, .mx_MLocationBody_map, .mx_MBeaconBody { diff --git a/apps/web/res/css/views/rooms/_EventTile.pcss b/apps/web/res/css/views/rooms/_EventTile.pcss index ffe0fcc13d..78c17bf1d8 100644 --- a/apps/web/res/css/views/rooms/_EventTile.pcss +++ b/apps/web/res/css/views/rooms/_EventTile.pcss @@ -78,8 +78,8 @@ $left-gutter: 64px; min-width: 100px; } - .mx_MImageBody { - .mx_MImageBody_thumbnail_container { + .mx_ImageBody { + .mx_ImageBody_container { display: flex; align-items: center; /* on every layout */ } @@ -156,8 +156,8 @@ $left-gutter: 64px; position: absolute; } - .mx_MImageBody { - .mx_MImageBody_thumbnail_container { + .mx_ImageBody { + .mx_ImageBody_container { justify-content: flex-start; min-height: $font-44px; min-width: $font-44px; diff --git a/apps/web/src/components/views/messages/MBodyFactory.tsx b/apps/web/src/components/views/messages/MBodyFactory.tsx index deb10cd822..6da7a95e4e 100644 --- a/apps/web/src/components/views/messages/MBodyFactory.tsx +++ b/apps/web/src/components/views/messages/MBodyFactory.tsx @@ -7,9 +7,11 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, type RefObject, useContext, useEffect, useRef } from "react"; import { MsgType } from "matrix-js-sdk/src/matrix"; +import { type ImageContent } from "matrix-js-sdk/src/types"; import { DecryptionFailureBodyView, FileBodyView, + ImageBodyView, RedactedBodyView, VideoBodyView, useCreateAutoDisposedViewModel, @@ -21,8 +23,10 @@ import { LocalDeviceVerificationStateContext } from "../../../contexts/LocalDevi import { useMediaVisible } from "../../../hooks/useMediaVisible"; import { DecryptionFailureBodyViewModel } from "../../../viewmodels/room/timeline/event-tile/body/DecryptionFailureBodyViewModel"; import { FileBodyViewModel } from "../../../viewmodels/message-body/FileBodyViewModel"; +import { ImageBodyViewModel } from "../../../viewmodels/message-body/ImageBodyViewModel"; import { RedactedBodyViewModel } from "../../../viewmodels/message-body/RedactedBodyViewModel"; import { VideoBodyViewModel } from "../../../viewmodels/message-body/VideoBodyViewModel"; +import { isMimeTypeAllowed } from "../../../utils/blobs"; type MBodyComponent = React.ComponentType; @@ -134,6 +138,122 @@ export function VideoBodyFactory({ ); } +export function ImageBodyFactory({ + mxEvent, + mediaEventHelper, + forExport, + maxImageHeight, + permalinkCreator, + showFileInfo, +}: Readonly< + Pick< + IBodyProps, + "mxEvent" | "mediaEventHelper" | "forExport" | "maxImageHeight" | "permalinkCreator" | "showFileInfo" + > +>): JSX.Element { + const { timelineRenderingType } = useContext(RoomContext); + const [mediaVisible, setMediaVisible] = useMediaVisible(mxEvent); + const imageRef = useRef(null); + const content = mxEvent.getContent(); + const shouldFallbackToFileBody = + mediaEventHelper?.media.isEncrypted === true && + !isMimeTypeAllowed(content.info?.mimetype ?? "") && + !content.info?.thumbnail_info; + + const vm = useCreateAutoDisposedViewModel( + () => + new ImageBodyViewModel({ + mxEvent, + mediaEventHelper, + forExport, + maxImageHeight, + mediaVisible, + permalinkCreator, + timelineRenderingType, + imageRef, + setMediaVisible, + }), + ); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.loadInitialMediaIfVisible(); + }, [shouldFallbackToFileBody, vm]); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.setEvent(mxEvent, mediaEventHelper); + }, [mediaEventHelper, mxEvent, shouldFallbackToFileBody, vm]); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.setForExport(forExport); + }, [forExport, shouldFallbackToFileBody, vm]); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.setMaxImageHeight(maxImageHeight); + }, [maxImageHeight, shouldFallbackToFileBody, vm]); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.setMediaVisible(mediaVisible); + }, [mediaVisible, shouldFallbackToFileBody, vm]); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.setPermalinkCreator(permalinkCreator); + }, [permalinkCreator, shouldFallbackToFileBody, vm]); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.setTimelineRenderingType(timelineRenderingType); + }, [shouldFallbackToFileBody, timelineRenderingType, vm]); + + useEffect(() => { + if (shouldFallbackToFileBody) return; + vm.setSetMediaVisible(setMediaVisible); + }, [setMediaVisible, shouldFallbackToFileBody, vm]); + + const showFileBody = + !forExport && + timelineRenderingType !== TimelineRenderingType.Room && + timelineRenderingType !== TimelineRenderingType.Pinned && + timelineRenderingType !== TimelineRenderingType.Search && + timelineRenderingType !== TimelineRenderingType.Thread && + timelineRenderingType !== TimelineRenderingType.ThreadsList; + + if (shouldFallbackToFileBody) { + return ( + + ); + } + + return ( + + {showFileBody ? ( + + ) : null} + + ); +} + export function RedactedBodyFactory({ mxEvent, ref }: Pick): JSX.Element { const vm = useCreateAutoDisposedViewModel(() => new RedactedBodyViewModel({ mxEvent })); @@ -164,6 +284,7 @@ export function DecryptionFailureBodyFactory({ mxEvent, ref }: Pick([ + [MsgType.Image, ImageBodyFactory], [MsgType.File, FileBodyFactory], [MsgType.Video, VideoBodyFactory], ]); diff --git a/apps/web/src/components/views/messages/MImageBody.tsx b/apps/web/src/components/views/messages/MImageBody.tsx deleted file mode 100644 index 351a40e000..0000000000 --- a/apps/web/src/components/views/messages/MImageBody.tsx +++ /dev/null @@ -1,714 +0,0 @@ -/* -Copyright 2024 New Vector Ltd. -Copyright 2015-2021 The Matrix.org Foundation C.I.C. -Copyright 2018, 2019 Michael Telatynski <7t3chguy@gmail.com> - -SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial -Please see LICENSE files in the repository root for full details. -*/ - -import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react"; -import { Blurhash } from "react-blurhash"; -import classNames from "classnames"; -import { CSSTransition, SwitchTransition } from "react-transition-group"; -import { logger } from "matrix-js-sdk/src/logger"; -import { ClientEvent } from "matrix-js-sdk/src/matrix"; -import { type ImageContent } from "matrix-js-sdk/src/types"; -import { Tooltip } from "@vector-im/compound-web"; -import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; -import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components"; - -import Modal from "../../../Modal"; -import { _t } from "../../../languageHandler"; -import SettingsStore from "../../../settings/SettingsStore"; -import Spinner from "../elements/Spinner"; -import { type Media, mediaFromContent } from "../../../customisations/Media"; -import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media"; -import ImageView from "../elements/ImageView"; -import { type IBodyProps } from "./IBodyProps"; -import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize"; -import { MatrixClientPeg } from "../../../MatrixClientPeg"; -import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; -import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image"; -import { presentableTextForFile } from "../../../utils/FileUtils"; -import { createReconnectedListener } from "../../../utils/connection"; -import MediaProcessingError from "./shared/MediaProcessingError"; -import { DecryptError, DownloadError } from "../../../utils/DecryptFile"; -import { useMediaVisible } from "../../../hooks/useMediaVisible"; -import { isMimeTypeAllowed } from "../../../utils/blobs.ts"; -import { FileBodyFactory, renderMBody } from "./MBodyFactory"; - -enum Placeholder { - NoImage, - Blurhash, -} - -interface IState { - contentUrl: string | null; - thumbUrl: string | null; - isAnimated?: boolean; - error?: unknown; - imgError: boolean; - imgLoaded: boolean; - loadedImageDimensions?: { - naturalWidth: number; - naturalHeight: number; - }; - hover: boolean; - focus: boolean; - placeholder: Placeholder; -} - -interface IProps extends IBodyProps { - /** - * Should the media be behind a preview. - */ - mediaVisible: boolean; - /** - * Set the visibility of the media event. - * @param visible Should the event be visible. - */ - setMediaVisible: (visible: boolean) => void; -} - -/** - * @private Only use for inheritance. Use the default export for presentation. - */ -export class MImageBodyInner extends React.Component { - public static contextType = RoomContext; - declare public context: React.ContextType; - - private unmounted = false; - private image = createRef(); - private placeholder = createRef(); - private timeout?: number; - private sizeWatcher?: string; - - public state: IState = { - contentUrl: null, - thumbUrl: null, - imgError: false, - imgLoaded: false, - hover: false, - focus: false, - placeholder: Placeholder.NoImage, - }; - - protected onClick = (ev: React.MouseEvent): void => { - if (ev.button === 0 && !ev.metaKey) { - ev.preventDefault(); - if (!this.props.mediaVisible) { - this.props.setMediaVisible(true); - return; - } - - const content = this.props.mxEvent.getContent(); - - let httpUrl = this.state.contentUrl; - if ( - this.props.mediaEventHelper?.media.isEncrypted && - !isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "") - ) { - // contentUrl will be a blob URI mime-type=application/octet-stream so fall back to the thumbUrl instead - httpUrl = this.state.thumbUrl; - } - - if (!httpUrl) return; - const params: Omit, "onFinished"> = { - src: httpUrl, - name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"), - mxEvent: this.props.mxEvent, - permalinkCreator: this.props.permalinkCreator, - }; - - if (content.info) { - params.width = content.info.w; - params.height = content.info.h; - params.fileSize = content.info.size; - } - - if (this.image.current) { - const clientRect = this.image.current.getBoundingClientRect(); - - params.thumbnailInfo = { - width: clientRect.width, - height: clientRect.height, - positionX: clientRect.x, - positionY: clientRect.y, - }; - } - - Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); - } - }; - - private get shouldAutoplay(): boolean { - return !( - !this.state.contentUrl || - !this.props.mediaVisible || - !this.state.isAnimated || - SettingsStore.getValue("autoplayGifs") - ); - } - - protected onImageEnter = (): void => { - this.setState({ hover: true }); - }; - - protected onImageLeave = (): void => { - this.setState({ hover: false }); - }; - - private onFocus = (): void => { - this.setState({ focus: true }); - }; - - private onBlur = (): void => { - this.setState({ focus: false }); - }; - - private reconnectedListener = createReconnectedListener((): void => { - MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); - this.setState({ imgError: false }); - }); - - private onImageError = (): void => { - // If the thumbnail failed to load then try again using the contentUrl - if (this.state.thumbUrl) { - this.setState({ - thumbUrl: null, - }); - return; - } - - this.clearBlurhashTimeout(); - this.setState({ - imgError: true, - }); - MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener); - }; - - private onImageLoad = (): void => { - this.clearBlurhashTimeout(); - - let loadedImageDimensions: IState["loadedImageDimensions"]; - - if (this.image.current) { - const { naturalWidth, naturalHeight } = this.image.current; - // this is only used as a fallback in case content.info.w/h is missing - loadedImageDimensions = { naturalWidth, naturalHeight }; - } - this.setState({ imgLoaded: true, loadedImageDimensions }); - }; - - private getContentUrl(): string | null { - // During export, the content url will point to the MSC, which will later point to a local url - if (this.props.forExport) return this.media.srcMxc; - return this.media.srcHttp; - } - - private get media(): Media { - return mediaFromContent(this.props.mxEvent.getContent()); - } - - private getThumbUrl(): string | null { - // FIXME: we let images grow as wide as you like, rather than capped to 800x600. - // So either we need to support custom timeline widths here, or reimpose the cap, otherwise the - // thumbnail resolution will be unnecessarily reduced. - // custom timeline widths seems preferable. - const thumbWidth = 800; - const thumbHeight = 600; - - const content = this.props.mxEvent.getContent(); - const media = mediaFromContent(content); - const info = content.info; - - if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) { - // Special-case to return clientside sender-generated thumbnails for SVGs, if any, - // given we deliberately don't thumbnail them serverside to prevent billion lol attacks and similar. - return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale"); - } - - // we try to download the correct resolution for hi-res images (like retina screenshots). - // Synapse only supports 800x600 thumbnails for now though, - // so we'll need to download the original image for this to work well for now. - // First, let's try a few cases that let us avoid downloading the original, including: - // - When displaying a GIF, we always want to thumbnail so that we can - // properly respect the user's GIF autoplay setting (which relies on - // thumbnailing to produce the static preview image) - // - On a low DPI device, always thumbnail to save bandwidth - // - If there's no sizing info in the event, default to thumbnail - if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) { - return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); - } - - // We should only request thumbnails if the image is bigger than 800x600 (or 1600x1200 on retina) otherwise - // the image in the timeline will just end up resampled and de-retina'd for no good reason. - // Ideally the server would pre-gen 1600x1200 thumbnails in order to provide retina thumbnails, - // but we don't do this currently in synapse for fear of disk space. - // As a compromise, let's switch to non-retina thumbnails only if the original image is both - // physically too large and going to be massive to load in the timeline (e.g. >1MB). - - const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight; - const isLargeFileSize = info.size > 1 * 1024 * 1024; // 1mb - - if (isLargeFileSize && isLargerThanThumbnail) { - // image is too large physically and byte-wise to clutter our timeline so, - // we ask for a thumbnail, despite knowing that it will be max 800x600 - // despite us being retina (as synapse doesn't do 1600x1200 thumbs yet). - return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); - } - - // download the original image otherwise, so we can scale it client side to take pixelRatio into account. - return media.srcHttp; - } - - private async downloadImage(): Promise { - if (this.state.contentUrl) return; // already downloaded - - let thumbUrl: string | null; - let contentUrl: string | null; - if (this.props.mediaEventHelper?.media.isEncrypted) { - try { - [contentUrl, thumbUrl] = await Promise.all([ - this.props.mediaEventHelper.sourceUrl.value, - this.props.mediaEventHelper.thumbnailUrl.value, - ]); - } catch (error) { - if (this.unmounted) return; - - if (error instanceof DecryptError) { - logger.error("Unable to decrypt attachment: ", error); - } else if (error instanceof DownloadError) { - logger.error("Unable to download attachment to decrypt it: ", error); - } else { - logger.error("Error encountered when downloading encrypted attachment: ", error); - } - - // Set a placeholder image when we can't decrypt the image. - this.setState({ error }); - return; - } - } else { - thumbUrl = this.getThumbUrl(); - contentUrl = this.getContentUrl(); - } - - const content = this.props.mxEvent.getContent(); - let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype); - - // If there is no included non-animated thumbnail then we will generate our own, we can't depend on the server - // because 1. encryption and 2. we can't ask the server specifically for a non-animated thumbnail. - if (isAnimated && !SettingsStore.getValue("autoplayGifs")) { - if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) { - const img = document.createElement("img"); - const loadPromise = new Promise((resolve, reject) => { - img.onload = resolve; - img.onerror = reject; - }); - img.crossOrigin = "Anonymous"; // CORS allow canvas access - img.src = contentUrl ?? ""; - - try { - await loadPromise; - } catch (error) { - logger.error("Unable to download attachment: ", error); - this.setState({ error: error as Error }); - return; - } - - try { - // If we didn't receive the MSC4230 is_animated flag - // then we need to check if the image is animated by downloading it. - if ( - content.info?.["org.matrix.msc4230.is_animated"] === false || - (await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false - ) { - isAnimated = false; - } - - if (isAnimated) { - const thumb = await createThumbnail( - img, - img.width, - img.height, - content.info?.mimetype ?? "image/jpeg", - false, - ); - thumbUrl = URL.createObjectURL(thumb.thumbnail); - } - } catch (error) { - // This is a non-critical failure, do not surface the error or bail the method here - logger.warn("Unable to generate thumbnail for animated image: ", error); - } - } - } - - if (this.unmounted) return; - this.setState({ - contentUrl, - thumbUrl, - isAnimated, - }); - } - - private clearBlurhashTimeout(): void { - if (this.timeout) { - clearTimeout(this.timeout); - this.timeout = undefined; - } - } - - public componentDidMount(): void { - this.unmounted = false; - - if (this.props.mediaVisible) { - // noinspection JSIgnoredPromiseFromCall - this.downloadImage(); - } - - // Add a 150ms timer for blurhash to first appear. - if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) { - this.clearBlurhashTimeout(); - this.timeout = window.setTimeout(() => { - if (!this.state.imgLoaded || !this.state.imgError) { - this.setState({ - placeholder: Placeholder.Blurhash, - }); - } - }, 150); - } - - this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => { - this.forceUpdate(); // we don't really have a reliable thing to update, so just update the whole thing - }); - } - - public componentDidUpdate(prevProps: Readonly): void { - if (!prevProps.mediaVisible && this.props.mediaVisible) { - // noinspection JSIgnoredPromiseFromCall - this.downloadImage(); - } - } - - public componentWillUnmount(): void { - this.unmounted = true; - MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); - this.clearBlurhashTimeout(); - SettingsStore.unwatchSetting(this.sizeWatcher); - if (this.state.isAnimated && this.state.thumbUrl) { - URL.revokeObjectURL(this.state.thumbUrl); - } - } - - protected getBanner(content: ImageContent): ReactNode { - // Hide it for the threads list & the file panel where we show it as text anyway. - if ( - [TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType) - ) { - return null; - } - - return ( - - {presentableTextForFile(content, _t("common|image"), true, true)} - - ); - } - - protected messageContent( - contentUrl: string | null, - thumbUrl: string | null, - content: ImageContent, - forcedHeight?: number, - ): ReactNode { - if (!thumbUrl) thumbUrl = contentUrl; // fallback - - // magic number - // edge case for this not to be set by conditions below - let infoWidth = 500; - let infoHeight = 500; - let infoSvg = false; - - if (content.info?.w && content.info?.h) { - infoWidth = content.info.w; - infoHeight = content.info.h; - infoSvg = content.info.mimetype === "image/svg+xml"; - } else if (thumbUrl && contentUrl) { - // Whilst the image loads, display nothing. We also don't display a blurhash image - // because we don't really know what size of image we'll end up with. - // - // Once loaded, use the loaded image dimensions stored in `loadedImageDimensions`. - // - // By doing this, the image "pops" into the timeline, but is still restricted - // by the same width and height logic below. - if (!this.state.loadedImageDimensions) { - let imageElement: JSX.Element; - if (!this.props.mediaVisible) { - imageElement = ( - - {_t("timeline|m.image|show_image")} - - ); - } else { - imageElement = ( - {content.body} - ); - } - return this.wrapImage(contentUrl, imageElement); - } - infoWidth = this.state.loadedImageDimensions.naturalWidth; - infoHeight = this.state.loadedImageDimensions.naturalHeight; - } - - // The maximum size of the thumbnail as it is rendered as an , - // accounting for any height constraints - const { w: maxWidth, h: maxHeight } = suggestedImageSize( - SettingsStore.getValue("Images.size") as ImageSize, - { w: infoWidth, h: infoHeight }, - forcedHeight ?? this.props.maxImageHeight, - ); - - let img: JSX.Element | undefined; - let placeholder: JSX.Element | undefined; - let gifLabel: JSX.Element | undefined; - - if (!this.props.forExport && !this.state.imgLoaded) { - const classes = classNames("mx_MImageBody_placeholder", { - "mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD], - }); - - placeholder = ( -
- {this.getPlaceholder(maxWidth, maxHeight)} -
- ); - } - - let showPlaceholder = Boolean(placeholder); - - const hoverOrFocus = this.state.hover || this.state.focus; - if (thumbUrl && !this.state.imgError) { - let url = thumbUrl; - if (hoverOrFocus && this.shouldAutoplay) { - url = this.state.contentUrl!; - } - - // Restrict the width of the thumbnail here, otherwise it will fill the container - // which has the same width as the timeline - // mx_MImageBody_thumbnail resizes img to exactly container size - img = ( - {content.body} - ); - } - - if (!this.props.mediaVisible) { - img = ( -
- - {_t("timeline|m.image|show_image")} - -
- ); - showPlaceholder = false; // because we're hiding the image, so don't show the placeholder. - } - - if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) { - // XXX: Arguably we may want a different label when the animated image is WEBP and not GIF - gifLabel =

GIF

; - } - - let banner: ReactNode | undefined; - if (this.props.mediaVisible && hoverOrFocus) { - banner = this.getBanner(content); - } - - // many SVGs don't have an intrinsic size if used in elements. - // due to this we have to set our desired width directly. - // this way if the image is forced to shrink, the height adapts appropriately. - const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth }; - - if (!this.props.forExport) { - placeholder = ( - - - { - showPlaceholder ? ( - placeholder - ) : ( -
- ) /* Transition always expects a child */ - } - - - ); - } - - const tooltipProps = this.getTooltipProps(); - let thumbnail = ( -
- {placeholder} - -
- {img} - {gifLabel} - {banner} -
- - {/* HACK: This div fills out space while the image loads, to prevent scroll jumps */} - {!this.props.forExport && !this.state.imgLoaded && !placeholder && ( -
- )} -
- ); - - if (tooltipProps) { - // We specify isTriggerInteractive=true and make the div interactive manually as a workaround for - // https://github.com/element-hq/compound/issues/294 - thumbnail = ( - - {thumbnail} - - ); - } - - return this.wrapImage(contentUrl, thumbnail); - } - - // Overridden by MStickerBody - protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { - if (contentUrl) { - return ( - - {children} - - ); - } - return children; - } - - // Overridden by MStickerBody - protected getPlaceholder(width: number, height: number): ReactNode { - const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]; - - if (blurhash) { - if (this.state.placeholder === Placeholder.NoImage) { - return null; - } else if (this.state.placeholder === Placeholder.Blurhash) { - return ; - } - } - return ; - } - - // Overridden by MStickerBody - protected getTooltipProps(): ComponentProps | null { - return null; - } - - // Overridden by MStickerBody - protected getFileBody(): ReactNode { - if (this.props.forExport) return null; - /* - * In the room timeline or the thread context we don't need the download - * link as the message action bar will fulfill that - */ - const hasMessageActionBar = - this.context.timelineRenderingType === TimelineRenderingType.Room || - this.context.timelineRenderingType === TimelineRenderingType.Pinned || - this.context.timelineRenderingType === TimelineRenderingType.Search || - this.context.timelineRenderingType === TimelineRenderingType.Thread || - this.context.timelineRenderingType === TimelineRenderingType.ThreadsList; - if (!hasMessageActionBar) { - return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory); - } - } - - public render(): React.ReactNode { - const content = this.props.mxEvent.getContent(); - - // Fall back to file-body view if we are unable to render this image e.g. in the case of a blob svg - if ( - this.props.mediaEventHelper?.media.isEncrypted && - !isMimeTypeAllowed(content.info?.mimetype ?? "") && - !content.info?.thumbnail_info - ) { - return renderMBody(this.props, FileBodyFactory); - } - - if (this.state.error) { - let errorText = _t("timeline|m.image|error"); - if (this.state.error instanceof DecryptError) { - errorText = _t("timeline|m.image|error_decrypting"); - } else if (this.state.error instanceof DownloadError) { - errorText = _t("timeline|m.image|error_downloading"); - } - - return ( - - {errorText} - - ); - } - - let contentUrl = this.state.contentUrl; - let thumbUrl: string | null; - if (this.props.forExport) { - contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url; - thumbUrl = contentUrl; - } else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) { - thumbUrl = contentUrl; - } else { - thumbUrl = this.state.thumbUrl ?? this.state.contentUrl; - } - - const thumbnail = this.messageContent(contentUrl, thumbUrl, content); - const fileBody = this.getFileBody(); - - return ( -
- {thumbnail} - {fileBody} -
- ); - } -} - -// Wrap MImageBody component so we can use a hook here. -const MImageBody: React.FC = (props) => { - const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); - return ; -}; - -export default MImageBody; diff --git a/apps/web/src/components/views/messages/MImageReplyBody.tsx b/apps/web/src/components/views/messages/MImageReplyBody.tsx index 5f04df724d..87c6a9666c 100644 --- a/apps/web/src/components/views/messages/MImageReplyBody.tsx +++ b/apps/web/src/components/views/messages/MImageReplyBody.tsx @@ -6,16 +6,621 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com Please see LICENSE files in the repository root for full details. */ -import React, { type JSX } from "react"; +import React, { type JSX, type ComponentProps, createRef, type ReactNode } from "react"; +import { Blurhash } from "react-blurhash"; +import classNames from "classnames"; +import { CSSTransition, SwitchTransition } from "react-transition-group"; +import { logger } from "matrix-js-sdk/src/logger"; +import { ClientEvent } from "matrix-js-sdk/src/matrix"; import { type ImageContent } from "matrix-js-sdk/src/types"; +import { Tooltip } from "@vector-im/compound-web"; +import { ImageErrorIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { HiddenMediaPlaceholder } from "@element-hq/web-shared-components"; -import { MImageBodyInner } from "./MImageBody"; +import Modal from "../../../Modal"; +import { _t } from "../../../languageHandler"; +import SettingsStore from "../../../settings/SettingsStore"; +import Spinner from "../elements/Spinner"; +import { type Media, mediaFromContent } from "../../../customisations/Media"; +import { BLURHASH_FIELD, createThumbnail } from "../../../utils/image-media"; +import ImageView from "../elements/ImageView"; import { type IBodyProps } from "./IBodyProps"; +import { type ImageSize, suggestedSize as suggestedImageSize } from "../../../settings/enums/ImageSize"; +import { MatrixClientPeg } from "../../../MatrixClientPeg"; +import RoomContext, { TimelineRenderingType } from "../../../contexts/RoomContext"; +import { blobIsAnimated, mayBeAnimated } from "../../../utils/Image"; +import { presentableTextForFile } from "../../../utils/FileUtils"; +import { createReconnectedListener } from "../../../utils/connection"; +import MediaProcessingError from "./shared/MediaProcessingError"; +import { DecryptError, DownloadError } from "../../../utils/DecryptFile"; import { useMediaVisible } from "../../../hooks/useMediaVisible"; +import { isMimeTypeAllowed } from "../../../utils/blobs.ts"; +import { FileBodyFactory, renderMBody } from "./MBodyFactory"; + +enum Placeholder { + NoImage, + Blurhash, +} + +interface IState { + contentUrl: string | null; + thumbUrl: string | null; + isAnimated?: boolean; + error?: unknown; + imgError: boolean; + imgLoaded: boolean; + loadedImageDimensions?: { + naturalWidth: number; + naturalHeight: number; + }; + hover: boolean; + focus: boolean; + placeholder: Placeholder; +} + +export interface ImageBodyBaseProps extends IBodyProps { + mediaVisible: boolean; + setMediaVisible: (visible: boolean) => void; +} + +export class ImageBodyBaseInner extends React.Component { + public static contextType = RoomContext; + declare public context: React.ContextType; + + private unmounted = false; + private image = createRef(); + private placeholder = createRef(); + private timeout?: number; + private sizeWatcher?: string; + + public state: IState = { + contentUrl: null, + thumbUrl: null, + imgError: false, + imgLoaded: false, + hover: false, + focus: false, + placeholder: Placeholder.NoImage, + }; + + protected onClick = (ev: React.MouseEvent): void => { + if (ev.button === 0 && !ev.metaKey) { + ev.preventDefault(); + if (!this.props.mediaVisible) { + this.props.setMediaVisible(true); + return; + } + + const content = this.props.mxEvent.getContent(); + + let httpUrl = this.state.contentUrl; + if ( + this.props.mediaEventHelper?.media.isEncrypted && + !isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "") + ) { + httpUrl = this.state.thumbUrl; + } + + if (!httpUrl) return; + const params: Omit, "onFinished"> = { + src: httpUrl, + name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"), + mxEvent: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }; + + if (content.info) { + params.width = content.info.w; + params.height = content.info.h; + params.fileSize = content.info.size; + } + + if (this.image.current) { + const clientRect = this.image.current.getBoundingClientRect(); + + params.thumbnailInfo = { + width: clientRect.width, + height: clientRect.height, + positionX: clientRect.x, + positionY: clientRect.y, + }; + } + + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); + } + }; + + private get shouldAutoplay(): boolean { + return !( + !this.state.contentUrl || + !this.props.mediaVisible || + !this.state.isAnimated || + SettingsStore.getValue("autoplayGifs") + ); + } + + protected onImageEnter = (): void => { + this.setState({ hover: true }); + }; + + protected onImageLeave = (): void => { + this.setState({ hover: false }); + }; + + private onFocus = (): void => { + this.setState({ focus: true }); + }; + + private onBlur = (): void => { + this.setState({ focus: false }); + }; + + private reconnectedListener = createReconnectedListener((): void => { + MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); + this.setState({ imgError: false }); + }); + + private onImageError = (): void => { + if (this.state.thumbUrl) { + this.setState({ + thumbUrl: null, + }); + return; + } + + this.clearBlurhashTimeout(); + this.setState({ + imgError: true, + }); + MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener); + }; + + private onImageLoad = (): void => { + this.clearBlurhashTimeout(); + + let loadedImageDimensions: IState["loadedImageDimensions"]; + + if (this.image.current) { + const { naturalWidth, naturalHeight } = this.image.current; + loadedImageDimensions = { naturalWidth, naturalHeight }; + } + this.setState({ imgLoaded: true, loadedImageDimensions }); + }; + + private getContentUrl(): string | null { + if (this.props.forExport) return this.media.srcMxc; + return this.media.srcHttp; + } + + private get media(): Media { + return mediaFromContent(this.props.mxEvent.getContent()); + } + + private getThumbUrl(): string | null { + const thumbWidth = 800; + const thumbHeight = 600; + + const content = this.props.mxEvent.getContent(); + const media = mediaFromContent(content); + const info = content.info; + + if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) { + return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale"); + } + + if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) { + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight; + const isLargeFileSize = info.size > 1 * 1024 * 1024; + + if (isLargeFileSize && isLargerThanThumbnail) { + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + return media.srcHttp; + } + + private async downloadImage(): Promise { + if (this.state.contentUrl) return; + + let thumbUrl: string | null; + let contentUrl: string | null; + if (this.props.mediaEventHelper?.media.isEncrypted) { + try { + [contentUrl, thumbUrl] = await Promise.all([ + this.props.mediaEventHelper.sourceUrl.value, + this.props.mediaEventHelper.thumbnailUrl.value, + ]); + } catch (error) { + if (this.unmounted) return; + + if (error instanceof DecryptError) { + logger.error("Unable to decrypt attachment: ", error); + } else if (error instanceof DownloadError) { + logger.error("Unable to download attachment to decrypt it: ", error); + } else { + logger.error("Error encountered when downloading encrypted attachment: ", error); + } + + this.setState({ error }); + return; + } + } else { + thumbUrl = this.getThumbUrl(); + contentUrl = this.getContentUrl(); + } + + const content = this.props.mxEvent.getContent(); + let isAnimated = content.info?.["org.matrix.msc4230.is_animated"] ?? mayBeAnimated(content.info?.mimetype); + + if (isAnimated && !SettingsStore.getValue("autoplayGifs")) { + if (!thumbUrl || !content?.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) { + const img = document.createElement("img"); + const loadPromise = new Promise((resolve, reject) => { + img.onload = resolve; + img.onerror = reject; + }); + img.crossOrigin = "Anonymous"; + img.src = contentUrl ?? ""; + + try { + await loadPromise; + } catch (error) { + logger.error("Unable to download attachment: ", error); + this.setState({ error: error as Error }); + return; + } + + try { + if ( + content.info?.["org.matrix.msc4230.is_animated"] === false || + (await blobIsAnimated(await this.props.mediaEventHelper!.sourceBlob.value)) === false + ) { + isAnimated = false; + } + + if (isAnimated) { + const thumb = await createThumbnail( + img, + img.width, + img.height, + content.info?.mimetype ?? "image/jpeg", + false, + ); + thumbUrl = URL.createObjectURL(thumb.thumbnail); + } + } catch (error) { + logger.warn("Unable to generate thumbnail for animated image: ", error); + } + } + } + + if (this.unmounted) return; + this.setState({ + contentUrl, + thumbUrl, + isAnimated, + }); + } + + private clearBlurhashTimeout(): void { + if (this.timeout) { + clearTimeout(this.timeout); + this.timeout = undefined; + } + } + + public componentDidMount(): void { + this.unmounted = false; + + if (this.props.mediaVisible) { + void this.downloadImage(); + } + + if (this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]) { + this.clearBlurhashTimeout(); + this.timeout = window.setTimeout(() => { + if (!this.state.imgLoaded || !this.state.imgError) { + this.setState({ + placeholder: Placeholder.Blurhash, + }); + } + }, 150); + } + + this.sizeWatcher = SettingsStore.watchSetting("Images.size", null, () => { + this.forceUpdate(); + }); + } + + public componentDidUpdate(prevProps: Readonly): void { + if (!prevProps.mediaVisible && this.props.mediaVisible) { + void this.downloadImage(); + } + } + + public componentWillUnmount(): void { + this.unmounted = true; + MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); + this.clearBlurhashTimeout(); + SettingsStore.unwatchSetting(this.sizeWatcher); + if (this.state.isAnimated && this.state.thumbUrl) { + URL.revokeObjectURL(this.state.thumbUrl); + } + } + + protected getBanner(content: ImageContent): ReactNode { + if ( + [TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(this.context.timelineRenderingType) + ) { + return null; + } + + return ( + + {presentableTextForFile(content, _t("common|image"), true, true)} + + ); + } + + protected messageContent( + contentUrl: string | null, + thumbUrl: string | null, + content: ImageContent, + forcedHeight?: number, + ): ReactNode { + if (!thumbUrl) thumbUrl = contentUrl; + + let infoWidth = 500; + let infoHeight = 500; + let infoSvg = false; + + if (content.info?.w && content.info?.h) { + infoWidth = content.info.w; + infoHeight = content.info.h; + infoSvg = content.info.mimetype === "image/svg+xml"; + } else if (thumbUrl && contentUrl) { + if (!this.state.loadedImageDimensions) { + let imageElement: JSX.Element; + if (!this.props.mediaVisible) { + imageElement = ( + + {_t("timeline|m.image|show_image")} + + ); + } else { + imageElement = ( + {content.body} + ); + } + return this.wrapImage(contentUrl, imageElement); + } + infoWidth = this.state.loadedImageDimensions.naturalWidth; + infoHeight = this.state.loadedImageDimensions.naturalHeight; + } + + const { w: maxWidth, h: maxHeight } = suggestedImageSize( + SettingsStore.getValue("Images.size") as ImageSize, + { w: infoWidth, h: infoHeight }, + forcedHeight ?? this.props.maxImageHeight, + ); + + let img: JSX.Element | undefined; + let placeholder: JSX.Element | undefined; + let gifLabel: JSX.Element | undefined; + + if (!this.props.forExport && !this.state.imgLoaded) { + const classes = classNames("mx_MImageBody_placeholder", { + "mx_MImageBody_placeholder--blurhash": this.props.mxEvent.getContent().info?.[BLURHASH_FIELD], + }); + + placeholder = ( +
+ {this.getPlaceholder(maxWidth, maxHeight)} +
+ ); + } + + let showPlaceholder = Boolean(placeholder); + + const hoverOrFocus = this.state.hover || this.state.focus; + if (thumbUrl && !this.state.imgError) { + let url = thumbUrl; + if (hoverOrFocus && this.shouldAutoplay) { + url = this.state.contentUrl!; + } + + img = ( + {content.body} + ); + } + + if (!this.props.mediaVisible) { + img = ( +
+ + {_t("timeline|m.image|show_image")} + +
+ ); + showPlaceholder = false; + } + + if (this.state.isAnimated && !SettingsStore.getValue("autoplayGifs") && !hoverOrFocus) { + gifLabel =

GIF

; + } + + let banner: ReactNode | undefined; + if (this.props.mediaVisible && hoverOrFocus) { + banner = this.getBanner(content); + } + + const sizing = infoSvg ? { maxHeight, maxWidth, width: maxWidth } : { maxHeight, maxWidth }; + + if (!this.props.forExport) { + placeholder = ( + + + {showPlaceholder ? placeholder :
} + + + ); + } + + const tooltipProps = this.getTooltipProps(); + let thumbnail = ( +
+ {placeholder} + +
+ {img} + {gifLabel} + {banner} +
+ + {!this.props.forExport && !this.state.imgLoaded && !placeholder && ( +
+ )} +
+ ); + + if (tooltipProps) { + thumbnail = ( + + {thumbnail} + + ); + } + + return this.wrapImage(contentUrl, thumbnail); + } + + protected wrapImage(contentUrl: string | null | undefined, children: JSX.Element): ReactNode { + if (contentUrl) { + return ( + + {children} + + ); + } + return children; + } + + protected getPlaceholder(width: number, height: number): ReactNode { + const blurhash = this.props.mxEvent.getContent().info?.[BLURHASH_FIELD]; + + if (blurhash) { + if (this.state.placeholder === Placeholder.NoImage) { + return null; + } else if (this.state.placeholder === Placeholder.Blurhash) { + return ; + } + } + return ; + } + + protected getTooltipProps(): ComponentProps | null { + return null; + } + + protected getFileBody(): ReactNode { + if (this.props.forExport) return null; + const hasMessageActionBar = + this.context.timelineRenderingType === TimelineRenderingType.Room || + this.context.timelineRenderingType === TimelineRenderingType.Pinned || + this.context.timelineRenderingType === TimelineRenderingType.Search || + this.context.timelineRenderingType === TimelineRenderingType.Thread || + this.context.timelineRenderingType === TimelineRenderingType.ThreadsList; + if (!hasMessageActionBar) { + return renderMBody({ ...this.props, showFileInfo: false }, FileBodyFactory); + } + } + + public render(): React.ReactNode { + const content = this.props.mxEvent.getContent(); + + if ( + this.props.mediaEventHelper?.media.isEncrypted && + !isMimeTypeAllowed(content.info?.mimetype ?? "") && + !content.info?.thumbnail_info + ) { + return renderMBody(this.props, FileBodyFactory); + } + + if (this.state.error) { + let errorText = _t("timeline|m.image|error"); + if (this.state.error instanceof DecryptError) { + errorText = _t("timeline|m.image|error_decrypting"); + } else if (this.state.error instanceof DownloadError) { + errorText = _t("timeline|m.image|error_downloading"); + } + + return ( + + {errorText} + + ); + } + + let contentUrl = this.state.contentUrl; + let thumbUrl: string | null; + if (this.props.forExport) { + contentUrl = this.props.mxEvent.getContent().url ?? this.props.mxEvent.getContent().file?.url; + thumbUrl = contentUrl; + } else if (this.state.isAnimated && SettingsStore.getValue("autoplayGifs")) { + thumbUrl = contentUrl; + } else { + thumbUrl = this.state.thumbUrl ?? this.state.contentUrl; + } + + const thumbnail = this.messageContent(contentUrl, thumbUrl, content); + const fileBody = this.getFileBody(); + + return ( +
+ {thumbnail} + {fileBody} +
+ ); + } +} const FORCED_IMAGE_HEIGHT = 44; -class MImageReplyBodyInner extends MImageBodyInner { +class MImageReplyBodyInner extends ImageBodyBaseInner { public onClick = (ev: React.MouseEvent): void => { ev.preventDefault(); }; @@ -37,6 +642,7 @@ class MImageReplyBodyInner extends MImageBodyInner { return
{thumbnail}
; } } + const MImageReplyBody: React.FC = (props) => { const [mediaVisible, setVisible] = useMediaVisible(props.mxEvent); return ; diff --git a/apps/web/src/components/views/messages/MStickerBody.tsx b/apps/web/src/components/views/messages/MStickerBody.tsx index f0beea72aa..a9ae9ed92c 100644 --- a/apps/web/src/components/views/messages/MStickerBody.tsx +++ b/apps/web/src/components/views/messages/MStickerBody.tsx @@ -9,13 +9,13 @@ import React, { type JSX, type ComponentProps, type ReactNode } from "react"; import { type Tooltip } from "@vector-im/compound-web"; import { type MediaEventContent } from "matrix-js-sdk/src/types"; -import { MImageBodyInner } from "./MImageBody"; +import { ImageBodyBaseInner } from "./MImageReplyBody"; import { BLURHASH_FIELD } from "../../../utils/image-media"; import IconsShowStickersSvg from "../../../../res/img/icons-show-stickers.svg"; import { type IBodyProps } from "./IBodyProps"; import { useMediaVisible } from "../../../hooks/useMediaVisible"; -class MStickerBodyInner extends MImageBodyInner { +class MStickerBodyInner extends ImageBodyBaseInner { // Mostly empty to prevent default behaviour of MImageBody protected onClick = (ev: React.MouseEvent): void => { ev.preventDefault(); diff --git a/apps/web/src/components/views/messages/MessageEvent.tsx b/apps/web/src/components/views/messages/MessageEvent.tsx index 409a9e0f97..967b37e1a9 100644 --- a/apps/web/src/components/views/messages/MessageEvent.tsx +++ b/apps/web/src/components/views/messages/MessageEvent.tsx @@ -25,7 +25,6 @@ import { Mjolnir } from "../../../mjolnir/Mjolnir"; import { type IMediaBody } from "./IMediaBody"; import { MediaEventHelper } from "../../../utils/MediaEventHelper"; import { type IBodyProps } from "./IBodyProps"; -import MImageBody from "./MImageBody"; import MVoiceOrAudioBody from "./MVoiceOrAudioBody"; import MStickerBody from "./MStickerBody"; import MPollBody from "./MPollBody"; @@ -36,6 +35,7 @@ import { MjolnirBodyViewModel } from "../../../viewmodels/room/timeline/event-ti import { DecryptionFailureBodyFactory, FileBodyFactory, + ImageBodyFactory, RedactedBodyFactory, VideoBodyFactory, renderMBody, @@ -67,7 +67,7 @@ const baseBodyTypes = new Map>([ [MsgType.Text, TextualBodyFactory], [MsgType.Notice, TextualBodyFactory], [MsgType.Emote, TextualBodyFactory], - [MsgType.Image, MImageBody], + [MsgType.Image, ImageBodyFactory], [MsgType.File, (props: IBodyProps) => renderMBody(props, FileBodyFactory)!], [MsgType.Audio, MVoiceOrAudioBody], [MsgType.Video, VideoBodyFactory], @@ -283,7 +283,7 @@ export default class MessageEvent extends React.Component implements IMe } if ( - ((BodyType === MImageBody || BodyType === VideoBodyFactory) && + ((BodyType === ImageBodyFactory || BodyType === VideoBodyFactory) && !this.validateImageOrVideoMimetype(content)) || (BodyType === MStickerBody && !this.validateStickerMimetype(content)) ) { diff --git a/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts b/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts new file mode 100644 index 0000000000..664615e6f5 --- /dev/null +++ b/apps/web/src/viewmodels/message-body/ImageBodyViewModel.ts @@ -0,0 +1,674 @@ +/* + * 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 { type ComponentProps, type MouseEvent, type RefObject } from "react"; +import { logger } from "matrix-js-sdk/src/logger"; +import { ClientEvent, type MatrixEvent } from "matrix-js-sdk/src/matrix"; +import { type ImageContent } from "matrix-js-sdk/src/types"; +import { + BaseViewModel, + ImageBodyViewPlaceholder, + ImageBodyViewState, + type ImageBodyViewModel as ImageBodyViewModelInterface, + type ImageBodyViewSnapshot, +} from "@element-hq/web-shared-components"; + +import Modal from "../../Modal"; +import { MatrixClientPeg } from "../../MatrixClientPeg"; +import { _t } from "../../languageHandler"; +import { mediaFromContent } from "../../customisations/Media"; +import { TimelineRenderingType } from "../../contexts/RoomContext"; +import SettingsStore from "../../settings/SettingsStore"; +import { type ImageSize, suggestedSize as suggestedImageSize } from "../../settings/enums/ImageSize"; +import { presentableTextForFile } from "../../utils/FileUtils"; +import { type MediaEventHelper } from "../../utils/MediaEventHelper"; +import { blobIsAnimated, mayBeAnimated } from "../../utils/Image"; +import { type RoomPermalinkCreator } from "../../utils/permalinks/Permalinks"; +import { createReconnectedListener } from "../../utils/connection"; +import { DecryptError, DownloadError } from "../../utils/DecryptFile"; +import { BLURHASH_FIELD, createThumbnail } from "../../utils/image-media"; +import { isMimeTypeAllowed } from "../../utils/blobs"; +import ImageView from "../../components/views/elements/ImageView"; + +export interface ImageBodyViewModelProps { + /** + * Image event being rendered. + */ + mxEvent: MatrixEvent; + /** + * Helper for resolving encrypted media sources. + */ + mediaEventHelper?: MediaEventHelper; + /** + * Whether the image is being rendered for export instead of the live timeline. + */ + forExport?: boolean; + /** + * Optional maximum height applied when computing the rendered image dimensions. + */ + maxImageHeight?: number; + /** + * Whether the media should currently be shown instead of the hidden-media preview. + */ + mediaVisible: boolean; + /** + * Permalink helper passed to the image lightbox. + */ + permalinkCreator?: RoomPermalinkCreator; + /** + * Timeline context used to decide which labels and supplemental content should be shown. + */ + timelineRenderingType: TimelineRenderingType; + /** + * Ref to the underlying image element used for load dimensions and lightbox animation. + */ + imageRef: RefObject; + /** + * Callback invoked when hidden media is revealed. + */ + setMediaVisible?: (visible: boolean) => void; +} + +interface LoadedImageDimensions { + naturalWidth: number; + naturalHeight: number; +} + +interface InternalState { + contentUrl: string | null; + thumbUrl: string | null; + isAnimated: boolean; + error: unknown | null; + imgError: boolean; + imgLoaded: boolean; + loadedImageDimensions?: LoadedImageDimensions; + placeholder: ImageBodyViewPlaceholder; + imageSize: ImageSize; + generatedThumbnailUrl: string | null; +} + +type ImageInfoWithAnimationFlag = NonNullable & { + "org.matrix.msc4230.is_animated"?: boolean; +}; + +/** + * View model for the image message body, encapsulating media loading, sizing, + * visibility, animated-image previews, and lightbox interactions. + */ +export class ImageBodyViewModel + extends BaseViewModel + implements ImageBodyViewModelInterface +{ + private state: InternalState; + private blurhashTimeout?: number; + + private readonly reconnectedListener = createReconnectedListener((): void => { + MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); + + if (!this.state.imgError) { + return; + } + + this.state = { + ...this.state, + imgError: false, + }; + this.updateSnapshotFromState(); + }); + + public constructor(props: ImageBodyViewModelProps) { + const initialState = ImageBodyViewModel.createInitialState(props.mxEvent); + super(props, ImageBodyViewModel.computeSnapshot(props, initialState)); + this.state = initialState; + + const imageSizeWatcherRef = SettingsStore.watchSetting("Images.size", null, (_s, _r, _l, _nvl, value) => { + this.setImageSize(value as ImageSize); + }); + this.disposables.track(() => SettingsStore.unwatchSetting(imageSizeWatcherRef)); + } + + private static createInitialState(mxEvent: MatrixEvent): InternalState { + return { + contentUrl: null, + thumbUrl: null, + isAnimated: false, + error: null, + imgError: false, + imgLoaded: false, + loadedImageDimensions: undefined, + placeholder: mxEvent.getContent().info?.[BLURHASH_FIELD] + ? ImageBodyViewPlaceholder.NONE + : ImageBodyViewPlaceholder.SPINNER, + imageSize: SettingsStore.getValue("Images.size") as ImageSize, + generatedThumbnailUrl: null, + }; + } + + private static getImageDimensions( + props: ImageBodyViewModelProps, + state: InternalState, + ): Pick { + const content = props.mxEvent.getContent(); + const info = content.info; + const naturalWidth = info?.w ?? state.loadedImageDimensions?.naturalWidth; + const naturalHeight = info?.h ?? state.loadedImageDimensions?.naturalHeight; + + if (!naturalWidth || !naturalHeight) { + return { + maxWidth: undefined, + maxHeight: undefined, + aspectRatio: undefined, + isSvg: info?.mimetype === "image/svg+xml", + }; + } + + const { w: maxWidth, h: maxHeight } = suggestedImageSize( + state.imageSize, + { w: naturalWidth, h: naturalHeight }, + props.maxImageHeight, + ); + + return { + maxWidth, + maxHeight, + aspectRatio: `${naturalWidth}/${naturalHeight}`, + isSvg: info?.mimetype === "image/svg+xml", + }; + } + + private static computeErrorLabel(error: unknown, imgError: boolean): string { + if (error instanceof DecryptError) return _t("timeline|m.image|error_decrypting"); + if (error instanceof DownloadError) return _t("timeline|m.image|error_downloading"); + if (imgError || error) return _t("timeline|m.image|error"); + + return _t("timeline|m.image|error"); + } + + private static shouldShowBanner(timelineRenderingType: TimelineRenderingType): boolean { + return ![TimelineRenderingType.ThreadsList, TimelineRenderingType.File].includes(timelineRenderingType); + } + + private static computeSnapshot(props: ImageBodyViewModelProps, state: InternalState): ImageBodyViewSnapshot { + const content = props.mxEvent.getContent(); + const dimensions = ImageBodyViewModel.getImageDimensions(props, state); + const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean; + const contentUrl = ImageBodyViewModel.getContentUrl(props, state); + const thumbnailSrc = props.forExport + ? (contentUrl ?? undefined) + : state.isAnimated && autoplayGifs + ? (contentUrl ?? undefined) + : (state.thumbUrl ?? contentUrl ?? undefined); + + if (state.error || state.imgError) { + return { + state: ImageBodyViewState.ERROR, + errorLabel: ImageBodyViewModel.computeErrorLabel(state.error, state.imgError), + ...dimensions, + }; + } + + if (!props.mediaVisible) { + return { + state: ImageBodyViewState.HIDDEN, + hiddenButtonLabel: _t("timeline|m.image|show_image"), + ...dimensions, + }; + } + + return { + state: ImageBodyViewState.READY, + alt: content.body, + src: contentUrl ?? undefined, + thumbnailSrc, + showAnimatedContentOnHover: state.isAnimated && !autoplayGifs && !!contentUrl, + placeholder: !props.forExport && !state.imgLoaded ? state.placeholder : ImageBodyViewPlaceholder.NONE, + blurhash: content.info?.[BLURHASH_FIELD], + gifLabel: state.isAnimated && !autoplayGifs ? "GIF" : undefined, + bannerLabel: ImageBodyViewModel.shouldShowBanner(props.timelineRenderingType) + ? presentableTextForFile(content, _t("common|image"), true, true) + : undefined, + linkUrl: contentUrl ?? undefined, + linkTarget: props.forExport ? "_blank" : undefined, + ...dimensions, + }; + } + + private static getContentUrl(props: ImageBodyViewModelProps, state: InternalState): string | null { + if (props.forExport) { + return ( + props.mxEvent.getContent().url ?? + props.mxEvent.getContent().file?.url ?? + null + ); + } + + return state.contentUrl; + } + + public loadInitialMediaIfVisible(): void { + if (!this.props.mediaVisible) { + return; + } + + this.scheduleBlurhashPlaceholder(); + void this.downloadImage(); + } + + private updateSnapshotFromState(): void { + this.snapshot.set(ImageBodyViewModel.computeSnapshot(this.props, this.state)); + } + + private resetState(mxEvent: MatrixEvent): void { + this.clearBlurhashTimeout(); + MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); + this.revokeGeneratedThumbnailUrl(); + this.state = ImageBodyViewModel.createInitialState(mxEvent); + } + + private revokeGeneratedThumbnailUrl(): void { + if (!this.state.generatedThumbnailUrl) { + return; + } + + URL.revokeObjectURL(this.state.generatedThumbnailUrl); + this.state = { + ...this.state, + generatedThumbnailUrl: null, + }; + } + + private clearBlurhashTimeout(): void { + if (!this.blurhashTimeout) { + return; + } + + clearTimeout(this.blurhashTimeout); + this.blurhashTimeout = undefined; + } + + private scheduleBlurhashPlaceholder(): void { + if ( + !this.props.mxEvent.getContent().info?.[BLURHASH_FIELD] || + this.state.imgLoaded || + this.state.imgError + ) { + return; + } + + this.clearBlurhashTimeout(); + this.blurhashTimeout = window.setTimeout(() => { + if (this.isDisposed || this.state.imgLoaded || this.state.imgError) { + return; + } + + this.state = { + ...this.state, + placeholder: ImageBodyViewPlaceholder.BLURHASH, + }; + this.snapshot.merge({ placeholder: ImageBodyViewPlaceholder.BLURHASH }); + }, 150); + } + + private getThumbUrl(): string | null { + const thumbWidth = 800; + const thumbHeight = 600; + + const content = this.props.mxEvent.getContent(); + const media = mediaFromContent(content); + const info = content.info; + + if (info?.mimetype === "image/svg+xml" && media.hasThumbnail) { + return media.getThumbnailHttp(thumbWidth, thumbHeight, "scale"); + } + + if (this.state.isAnimated || window.devicePixelRatio === 1.0 || !info || !info.w || !info.h || !info.size) { + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + const isLargerThanThumbnail = info.w > thumbWidth || info.h > thumbHeight; + const isLargeFileSize = info.size > 1 * 1024 * 1024; + + if (isLargeFileSize && isLargerThanThumbnail) { + return media.getThumbnailOfSourceHttp(thumbWidth, thumbHeight); + } + + return media.srcHttp; + } + + private async downloadImage(): Promise { + if (this.state.contentUrl || this.props.forExport) { + return; + } + + let thumbUrl: string | null; + let contentUrl: string | null; + + if (this.props.mediaEventHelper?.media.isEncrypted) { + try { + [contentUrl, thumbUrl] = await Promise.all([ + this.props.mediaEventHelper.sourceUrl.value, + this.props.mediaEventHelper.thumbnailUrl.value, + ]); + } catch (error) { + if (this.isDisposed) { + return; + } + + if (error instanceof DecryptError) { + logger.error("Unable to decrypt attachment: ", error); + } else if (error instanceof DownloadError) { + logger.error("Unable to download attachment to decrypt it: ", error); + } else { + logger.error("Error encountered when downloading encrypted attachment: ", error); + } + + this.state = { + ...this.state, + error: error as Error, + }; + this.updateSnapshotFromState(); + return; + } + } else { + contentUrl = mediaFromContent(this.props.mxEvent.getContent()).srcHttp; + thumbUrl = this.getThumbUrl(); + } + + const content = this.props.mxEvent.getContent(); + let generatedThumbnailUrl: string | null = null; + let isAnimated = (content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"]; + if (isAnimated === undefined) { + isAnimated = mayBeAnimated(content.info?.mimetype); + } + + const autoplayGifs = SettingsStore.getValue("autoplayGifs") as boolean; + if (isAnimated && !autoplayGifs) { + if (!thumbUrl || !content.info?.thumbnail_info || mayBeAnimated(content.info.thumbnail_info.mimetype)) { + const image = document.createElement("img"); + const loadPromise = new Promise((resolve, reject) => { + image.onload = (): void => resolve(); + image.onerror = (): void => reject(new Error("Unable to load image")); + }); + + image.crossOrigin = "Anonymous"; + image.src = contentUrl ?? ""; + + try { + await loadPromise; + } catch (error) { + logger.error("Unable to download attachment: ", error); + this.state = { + ...this.state, + error: error as Error, + }; + this.updateSnapshotFromState(); + return; + } + + try { + if ( + (content.info as ImageInfoWithAnimationFlag | undefined)?.["org.matrix.msc4230.is_animated"] === + false || + (this.props.mediaEventHelper && + (await blobIsAnimated(await this.props.mediaEventHelper.sourceBlob.value)) === false) + ) { + isAnimated = false; + } + + if (isAnimated) { + const thumbnail = await createThumbnail( + image, + image.width, + image.height, + content.info?.mimetype ?? "image/jpeg", + false, + ); + generatedThumbnailUrl = URL.createObjectURL(thumbnail.thumbnail); + thumbUrl = generatedThumbnailUrl; + } + } catch (error) { + logger.warn("Unable to generate thumbnail for animated image: ", error); + } + } + } + + if (this.isDisposed) { + if (generatedThumbnailUrl) { + URL.revokeObjectURL(generatedThumbnailUrl); + } + return; + } + + this.revokeGeneratedThumbnailUrl(); + this.state = { + ...this.state, + contentUrl, + thumbUrl, + isAnimated, + error: null, + generatedThumbnailUrl, + }; + this.updateSnapshotFromState(); + } + + private openImageViewer(event: MouseEvent): void { + if (event.button !== 0 || event.metaKey) { + return; + } + + event.preventDefault(); + + if (!this.props.mediaVisible) { + this.props.setMediaVisible?.(true); + return; + } + + const content = this.props.mxEvent.getContent(); + + let httpUrl = this.state.contentUrl; + if ( + this.props.mediaEventHelper?.media.isEncrypted && + !isMimeTypeAllowed(this.props.mediaEventHelper.sourceBlob.cachedValue?.type ?? "") + ) { + httpUrl = this.state.thumbUrl; + } + + if (!httpUrl) { + return; + } + + const params: Omit, "onFinished"> = { + src: httpUrl, + name: content.body && content.body.length > 0 ? content.body : _t("common|attachment"), + mxEvent: this.props.mxEvent, + permalinkCreator: this.props.permalinkCreator, + }; + + if (content.info) { + params.width = content.info.w; + params.height = content.info.h; + params.fileSize = content.info.size; + } + + if (this.props.imageRef.current) { + const clientRect = this.props.imageRef.current.getBoundingClientRect(); + params.thumbnailInfo = { + width: clientRect.width, + height: clientRect.height, + positionX: clientRect.x, + positionY: clientRect.y, + }; + } + + Modal.createDialog(ImageView, params, "mx_Dialog_lightbox", undefined, true); + } + + public onLinkClick = (event: MouseEvent): void => { + this.openImageViewer(event); + }; + + public onHiddenButtonClick = (): void => { + this.props.setMediaVisible?.(true); + }; + + public onImageError = (): void => { + if (this.state.thumbUrl && this.state.thumbUrl !== this.state.contentUrl) { + this.state = { + ...this.state, + thumbUrl: null, + }; + this.updateSnapshotFromState(); + return; + } + + this.clearBlurhashTimeout(); + + if (this.state.imgError) { + return; + } + + this.state = { + ...this.state, + imgError: true, + }; + MatrixClientPeg.safeGet().on(ClientEvent.Sync, this.reconnectedListener); + this.updateSnapshotFromState(); + }; + + public onImageLoad = (): void => { + this.clearBlurhashTimeout(); + + let loadedImageDimensions: LoadedImageDimensions | undefined; + if (this.props.imageRef.current) { + const { naturalWidth, naturalHeight } = this.props.imageRef.current; + loadedImageDimensions = { naturalWidth, naturalHeight }; + } + + this.state = { + ...this.state, + imgLoaded: true, + loadedImageDimensions, + placeholder: ImageBodyViewPlaceholder.NONE, + }; + this.updateSnapshotFromState(); + }; + + public setEvent(mxEvent: MatrixEvent, mediaEventHelper?: MediaEventHelper): void { + if (this.props.mxEvent === mxEvent && this.props.mediaEventHelper === mediaEventHelper) { + return; + } + + const previousVisible = this.props.mediaVisible; + this.props = { + ...this.props, + mxEvent, + mediaEventHelper, + }; + this.resetState(mxEvent); + this.updateSnapshotFromState(); + + if (previousVisible) { + this.scheduleBlurhashPlaceholder(); + void this.downloadImage(); + } + } + + public setForExport(forExport?: boolean): void { + if (this.props.forExport === forExport) { + return; + } + + this.props = { + ...this.props, + forExport, + }; + this.updateSnapshotFromState(); + } + + public setMaxImageHeight(maxImageHeight?: number): void { + if (this.props.maxImageHeight === maxImageHeight) { + return; + } + + this.props = { + ...this.props, + maxImageHeight, + }; + this.updateSnapshotFromState(); + } + + public setMediaVisible(mediaVisible: boolean): void { + if (this.props.mediaVisible === mediaVisible) { + return; + } + + const wasVisible = this.props.mediaVisible; + this.props = { + ...this.props, + mediaVisible, + }; + this.updateSnapshotFromState(); + + if (!wasVisible && mediaVisible) { + this.scheduleBlurhashPlaceholder(); + void this.downloadImage(); + } + } + + public setPermalinkCreator(permalinkCreator?: RoomPermalinkCreator): void { + if (this.props.permalinkCreator === permalinkCreator) { + return; + } + + this.props = { + ...this.props, + permalinkCreator, + }; + } + + public setTimelineRenderingType(timelineRenderingType: TimelineRenderingType): void { + if (this.props.timelineRenderingType === timelineRenderingType) { + return; + } + + this.props = { + ...this.props, + timelineRenderingType, + }; + this.snapshot.merge(ImageBodyViewModel.computeSnapshot(this.props, this.state)); + } + + public setSetMediaVisible(setMediaVisible?: (visible: boolean) => void): void { + if (this.props.setMediaVisible === setMediaVisible) { + return; + } + + this.props = { + ...this.props, + setMediaVisible, + }; + } + + private setImageSize(imageSize: ImageSize): void { + if (this.state.imageSize === imageSize) { + return; + } + + this.state = { + ...this.state, + imageSize, + }; + this.updateSnapshotFromState(); + } + + public dispose(): void { + this.clearBlurhashTimeout(); + MatrixClientPeg.get()?.off(ClientEvent.Sync, this.reconnectedListener); + this.revokeGeneratedThumbnailUrl(); + super.dispose(); + } +} diff --git a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx index 4a25bcb57f..aab11a84aa 100644 --- a/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MBodyFactory-test.tsx @@ -20,17 +20,26 @@ import { import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; import SettingsStore from "../../../../../src/settings/SettingsStore"; import { + DecryptionFailureBodyFactory, FileBodyFactory, + ImageBodyFactory, + RedactedBodyFactory, VideoBodyFactory, renderMBody, } from "../../../../../src/components/views/messages/MBodyFactory"; import { TimelineRenderingType } from "../../../../../src/contexts/RoomContext.ts"; import { ScopedRoomContextProvider } from "../../../../../src/contexts/ScopedRoomContext.tsx"; +import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible"; jest.mock("matrix-encrypt-attachment", () => ({ decryptAttachment: jest.fn(), })); +jest.mock("../../../../../src/hooks/useMediaVisible", () => ({ + __esModule: true, + useMediaVisible: jest.fn(), +})); + describe("MBodyFactory", () => { const userId = "@user:server"; const deviceId = "DEADB33F"; @@ -59,7 +68,7 @@ describe("MBodyFactory", () => { onMessageAllowed: jest.fn(), permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)), }; - const mkEvent = (msgtype?: string): MatrixEvent => + const mkEvent = (msgtype?: string, content: Record = {}): MatrixEvent => new MatrixEvent({ room_id: "!room:server", sender: userId, @@ -68,13 +77,26 @@ describe("MBodyFactory", () => { body: "alt", ...(msgtype ? { msgtype } : {}), url: "mxc://server/file", + ...content, }, }); beforeEach(() => { jest.spyOn(SettingsStore, "getValue").mockRestore(); + jest.mocked(useMediaVisible).mockReturnValue([true, jest.fn()]); }); + const encryptedImageHelper = (): MediaEventHelper => + ({ + media: { isEncrypted: true }, + sourceUrl: { value: Promise.resolve("blob:source") }, + thumbnailUrl: { value: Promise.resolve("blob:thumbnail") }, + sourceBlob: { + value: Promise.resolve(new Blob(["image"], { type: "image/jpeg" })), + cachedValue: new Blob(["image"], { type: "image/jpeg" }), + }, + }) as unknown as MediaEventHelper; + describe("renderMBody", () => { it("renders download button for m.file in file rendering type", () => { const mediaEvent = mkEvent("m.file"); @@ -102,6 +124,10 @@ describe("MBodyFactory", () => { expect(renderMBody({ ...props, mxEvent: mkEvent("m.video") })?.type).toBe(VideoBodyFactory); }); + it("returns the image body factory for m.image", () => { + expect(renderMBody({ ...props, mxEvent: mkEvent("m.image") })?.type).toBe(ImageBodyFactory); + }); + it("returns null when msgtype is missing", () => { expect(renderMBody({ ...props, mxEvent: mkEvent() })).toBeNull(); }); @@ -156,4 +182,160 @@ describe("MBodyFactory", () => { expect(container).toMatchSnapshot(); }, ); + + describe("ImageBodyFactory", () => { + const imageContent = { + info: { + mimetype: "image/jpeg", + w: 320, + h: 240, + size: 48_000, + }, + }; + + it("renders the shared image view in room timelines", () => { + const mediaEvent = mkEvent("m.image", imageContent); + + const { container } = render( + + + , + ); + + expect(container.querySelector(".mx_ImageBody")).not.toBeNull(); + expect(container.querySelector(".mx_MFileBody")).toBeNull(); + }); + + it("renders the file fallback child in notification timelines", () => { + const mediaEvent = mkEvent("m.image", imageContent); + + const { container, getByRole } = render( + + + , + ); + + expect(container.querySelector(".mx_ImageBody")).not.toBeNull(); + expect(container.querySelector(".mx_MFileBody")).not.toBeNull(); + expect(getByRole("link", { name: /Download/ })).toBeInTheDocument(); + }); + + it("renders only a file body for encrypted unsafe images without thumbnails", () => { + const mediaEvent = mkEvent("m.image", { + file: { url: "mxc://server/encrypted-file" }, + url: undefined, + info: { + mimetype: "text/html", + }, + }); + + const { container, getByRole } = render( + + + , + ); + + expect(container.querySelector(".mx_ImageBody")).toBeNull(); + expect(container.querySelector(".mx_MFileBody")).not.toBeNull(); + expect(getByRole("button", { name: "alt" })).toBeInTheDocument(); + }); + + it("keeps the image body for encrypted unsafe images when a thumbnail is available", () => { + const mediaEvent = mkEvent("m.image", { + file: { url: "mxc://server/encrypted-file" }, + url: undefined, + info: { + mimetype: "text/html", + thumbnail_info: { mimetype: "image/jpeg" }, + }, + }); + + const { container } = render( + + + , + ); + + expect(container.querySelector(".mx_ImageBody")).not.toBeNull(); + expect(container.querySelector(".mx_MFileBody")).toBeNull(); + }); + }); + + describe("VideoBodyFactory", () => { + const videoContent = { + info: { + mimetype: "video/mp4", + w: 320, + h: 240, + size: 48_000, + }, + }; + + it("renders without a file fallback in room timelines", () => { + const mediaEvent = mkEvent("m.video", videoContent); + + const { container } = render( + + + , + ); + + expect(container.querySelector(".mx_MVideoBody")).not.toBeNull(); + expect(container.querySelector(".mx_MFileBody")).toBeNull(); + }); + + it("renders the file fallback child outside room timelines", () => { + const mediaEvent = mkEvent("m.video", videoContent); + + const { container, getByRole } = render( + + + , + ); + + expect(container.querySelector(".mx_MVideoBody")).not.toBeNull(); + expect(container.querySelector(".mx_MFileBody")).not.toBeNull(); + expect(getByRole("link", { name: /Download/ })).toBeInTheDocument(); + }); + }); + + it("renders the redacted body wrapper", () => { + const mediaEvent = mkEvent("m.text"); + + const { container } = render(); + + expect(container.querySelector(".mx_RedactedBody")).not.toBeNull(); + }); + + it("renders the decryption failure body wrapper", () => { + const mediaEvent = mkEvent("m.text"); + Object.defineProperty(mediaEvent, "decryptionFailureReason", { + configurable: true, + value: "MEGOLM_UNKNOWN_INBOUND_SESSION_ID", + }); + + const { container } = render(); + + expect(container.querySelector(".mx_DecryptionFailureBody")).not.toBeNull(); + }); }); diff --git a/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx deleted file mode 100644 index 882e1fa5d8..0000000000 --- a/apps/web/test/unit-tests/components/views/messages/MImageBody-test.tsx +++ /dev/null @@ -1,376 +0,0 @@ -/* -Copyright 2024, 2025 New Vector Ltd. -Copyright 2022 The Matrix.org Foundation C.I.C. - -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 { fireEvent, render, screen, waitFor, waitForElementToBeRemoved, within } from "jest-matrix-react"; -import { EventType, getHttpUriForMxc, MatrixEvent, Room } from "matrix-js-sdk/src/matrix"; -import fetchMock from "@fetch-mock/jest"; -import encrypt from "matrix-encrypt-attachment"; -import { mocked } from "jest-mock"; -import fs from "fs"; -import path from "path"; -import userEvent from "@testing-library/user-event"; - -import MImageBody from "../../../../../src/components/views/messages/MImageBody"; -import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; -import { - getMockClientWithEventEmitter, - mockClientMethodsCrypto, - mockClientMethodsDevice, - mockClientMethodsServer, - mockClientMethodsUser, - withClientContextRenderOptions, -} from "../../../../test-utils"; -import { MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; -import SettingsStore from "../../../../../src/settings/SettingsStore"; -import { MediaPreviewValue } from "../../../../../src/@types/media_preview"; - -jest.mock("matrix-encrypt-attachment", () => ({ - decryptAttachment: jest.fn(), -})); - -describe("", () => { - const ourUserId = "@user:server"; - const senderUserId = "@other_use:server"; - const deviceId = "DEADB33F"; - const cli = getMockClientWithEventEmitter({ - ...mockClientMethodsUser(ourUserId), - ...mockClientMethodsServer(), - ...mockClientMethodsDevice(deviceId), - ...mockClientMethodsCrypto(), - getRooms: jest.fn().mockReturnValue([]), - getRoom: jest.fn(), - getIgnoredUsers: jest.fn(), - getVersions: jest.fn().mockResolvedValue({ - unstable_features: { - "org.matrix.msc3882": true, - "org.matrix.msc3886": true, - }, - }), - }); - const url = "https://server/_matrix/media/v3/download/server/encrypted-image"; - // eslint-disable-next-line no-restricted-properties - cli.mxcUrlToHttp.mockImplementation( - (mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => { - return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks); - }, - ); - const encryptedMediaEvent = new MatrixEvent({ - event_id: "$foo:bar", - room_id: "!room:server", - sender: senderUserId, - type: EventType.RoomMessage, - content: { - body: "alt for a test image", - info: { - w: 40, - h: 50, - mimetype: "image/png", - }, - file: { - url: "mxc://server/encrypted-image", - }, - }, - }); - - const props = { - onMessageAllowed: jest.fn(), - permalinkCreator: new RoomPermalinkCreator(new Room(encryptedMediaEvent.getRoomId()!, cli, cli.getUserId()!)), - }; - - beforeEach(() => { - jest.spyOn(SettingsStore, "getValue").mockRestore(); - fetchMock.mockReset(); - }); - - afterEach(() => { - SettingsStore.reset(); - mocked(encrypt.decryptAttachment).mockReset(); - }); - - it("should show a thumbnail while image is being downloaded", async () => { - fetchMock.getOnce(url, { status: 200 }); - - const { container } = render( - , - withClientContextRenderOptions(cli), - ); - - // thumbnail with dimensions present - expect(container).toMatchSnapshot(); - }); - - it("should show error when encrypted media cannot be downloaded", async () => { - fetchMock.getOnce(url, { status: 500 }); - - render( - , - withClientContextRenderOptions(cli), - ); - - expect(fetchMock).toHaveFetched(url); - - await screen.findByText("Error downloading image"); - }); - - it("should show error when encrypted media cannot be decrypted", async () => { - fetchMock.getOnce(url, "thisistotallyanencryptedpng"); - mocked(encrypt.decryptAttachment).mockRejectedValue(new Error("Failed to decrypt")); - - render( - , - withClientContextRenderOptions(cli), - ); - - await screen.findByText("Error decrypting image"); - }); - - describe("with image previews/thumbnails disabled", () => { - beforeEach(() => { - const origFn = SettingsStore.getValue; - jest.spyOn(SettingsStore, "getValue").mockImplementation((setting, ...args) => { - if (setting === "mediaPreviewConfig") { - return { invite_avatars: MediaPreviewValue.Off, media_previews: MediaPreviewValue.Off }; - } - return origFn(setting, ...args); - }); - }); - - it("should not download image", async () => { - fetchMock.getOnce(url, { status: 200 }); - - render( - , - withClientContextRenderOptions(cli), - ); - - expect(screen.getByText("Show image")).toBeInTheDocument(); - - expect(fetchMock).toHaveFetchedTimes(0, url); - }); - - it("should render hidden image placeholder", async () => { - fetchMock.getOnce(url, { status: 200 }); - - render( - , - withClientContextRenderOptions(cli), - ); - - expect(screen.getByText("Show image")).toBeInTheDocument(); - - fireEvent.click(screen.getByRole("button")); - - expect(fetchMock).toHaveFetched(url); - - // Show image is asynchronous since it applies through a settings watcher hook, so - // be sure to wait here. - await waitFor(() => { - // spinner while downloading image - expect(screen.getByRole("progressbar")).toBeInTheDocument(); - }); - }); - }); - - it("should fall back to /download/ if /thumbnail/ fails", async () => { - const thumbUrl = "https://server/_matrix/media/v3/thumbnail/server/image?width=800&height=600&method=scale"; - const downloadUrl = "https://server/_matrix/media/v3/download/server/image"; - - const event = new MatrixEvent({ - room_id: "!room:server", - sender: senderUserId, - type: EventType.RoomMessage, - content: { - body: "alt for a test image", - info: { - w: 40, - h: 50, - }, - url: "mxc://server/image", - }, - }); - - const { container } = render( - , - withClientContextRenderOptions(cli), - ); - - const img = container.querySelector(".mx_MImageBody_thumbnail")!; - expect(img).toHaveProperty("src", thumbUrl); - - fireEvent.error(img); - expect(img).toHaveProperty("src", downloadUrl); - }); - - it("should generate a thumbnail if one isn't included for animated media", async () => { - Object.defineProperty(global.Image.prototype, "src", { - set(src) { - window.setTimeout(() => this.onload?.()); - }, - }); - Object.defineProperty(global.Image.prototype, "height", { - get() { - return 600; - }, - }); - Object.defineProperty(global.Image.prototype, "width", { - get() { - return 800; - }, - }); - - mocked(global.URL.createObjectURL).mockReturnValue("blob:generated-thumb"); - - fetchMock.getOnce("https://server/_matrix/media/v3/download/server/image", { - body: fs.readFileSync(path.resolve(__dirname, "..", "..", "..", "images", "animated-logo.webp")), - }); - - const event = new MatrixEvent({ - room_id: "!room:server", - sender: senderUserId, - type: EventType.RoomMessage, - content: { - body: "alt for a test image", - info: { - w: 40, - h: 50, - mimetype: "image/webp", - }, - url: "mxc://server/image", - }, - }); - - const { container } = render( - , - withClientContextRenderOptions(cli), - ); - - // Wait for spinners to go away - await waitForElementToBeRemoved(screen.getAllByRole("progressbar")); - // thumbnail with dimensions present - expect(container).toMatchSnapshot(); - }); - - it("should show banner on hover", async () => { - const event = new MatrixEvent({ - room_id: "!room:server", - sender: senderUserId, - type: EventType.RoomMessage, - content: { - body: "alt for a test image", - info: { - w: 40, - h: 50, - }, - url: "mxc://server/image", - }, - }); - - const { container } = render( - , - withClientContextRenderOptions(cli), - ); - - const img = container.querySelector(".mx_MImageBody_thumbnail")!; - await userEvent.hover(img); - - expect(container.querySelector(".mx_MImageBody_banner")).toHaveTextContent("...alt for a test image"); - }); - - it("should render MFileBody for svg with no thumbnail", async () => { - const event = new MatrixEvent({ - room_id: "!room:server", - sender: senderUserId, - type: EventType.RoomMessage, - content: { - info: { - w: 40, - h: 50, - mimetype: "image/svg+xml", - }, - file: { - url: "mxc://server/encrypted-svg", - }, - }, - }); - - const { container, asFragment } = render( - , - withClientContextRenderOptions(cli), - ); - - expect(container.querySelector(".mx_MFileBody")).toHaveTextContent("Attachment"); - expect(asFragment()).toMatchSnapshot(); - }); - - it("should open ImageView using thumbnail for encrypted svg", async () => { - const url = "https://server/_matrix/media/v3/download/server/encrypted-svg"; - fetchMock.getOnce(url, { status: 200 }); - const thumbUrl = "https://server/_matrix/media/v3/download/server/svg-thumbnail"; - fetchMock.getOnce(thumbUrl, { status: 200 }); - - const event = new MatrixEvent({ - room_id: "!room:server", - sender: senderUserId, - type: EventType.RoomMessage, - origin_server_ts: 1234567890, - content: { - info: { - w: 40, - h: 50, - mimetype: "image/svg+xml", - thumbnail_file: { - url: "mxc://server/svg-thumbnail", - }, - thumbnail_info: { mimetype: "image/png" }, - }, - file: { - url: "mxc://server/encrypted-svg", - }, - }, - }); - - const mediaEventHelper = new MediaEventHelper(event); - mediaEventHelper.thumbnailUrl["prom"] = Promise.resolve(thumbUrl); - mediaEventHelper.sourceUrl["prom"] = Promise.resolve(url); - - const { findByRole } = render( - , - withClientContextRenderOptions(cli), - ); - - fireEvent.click(await findByRole("link")); - - const dialog = await screen.findByRole("dialog"); - await expect(within(dialog).findByRole("img")).resolves.toHaveAttribute( - "src", - "https://server/_matrix/media/v3/download/server/svg-thumbnail", - ); - expect(dialog).toMatchSnapshot(); - }); -}); diff --git a/apps/web/test/unit-tests/components/views/messages/MImageReplyBody-test.tsx b/apps/web/test/unit-tests/components/views/messages/MImageReplyBody-test.tsx new file mode 100644 index 0000000000..e8b955292d --- /dev/null +++ b/apps/web/test/unit-tests/components/views/messages/MImageReplyBody-test.tsx @@ -0,0 +1,620 @@ +/* +Copyright 2026 Element Creations Ltd. + +SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE files in the repository root for full details. +*/ + +import React, { createRef } from "react"; +import { act, fireEvent, render, screen, waitFor } from "jest-matrix-react"; +import { ClientEvent, EventType, getHttpUriForMxc, MatrixEvent, Room, SyncState } from "matrix-js-sdk/src/matrix"; + +import Modal from "../../../../../src/Modal"; +import SettingsStore from "../../../../../src/settings/SettingsStore"; +import { ImageSize } from "../../../../../src/settings/enums/ImageSize"; +import { mediaFromContent } from "../../../../../src/customisations/Media"; +import { BLURHASH_FIELD, createThumbnail } from "../../../../../src/utils/image-media"; +import { blobIsAnimated } from "../../../../../src/utils/Image"; +import { DecryptError, DownloadError } from "../../../../../src/utils/DecryptFile"; +import { type MediaEventHelper } from "../../../../../src/utils/MediaEventHelper"; +import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permalinks"; +import RoomContext, { TimelineRenderingType } from "../../../../../src/contexts/RoomContext"; +import MImageReplyBody, { ImageBodyBaseInner } from "../../../../../src/components/views/messages/MImageReplyBody"; +import { + getMockClientWithEventEmitter, + mockClientMethodsCrypto, + mockClientMethodsDevice, + mockClientMethodsServer, + mockClientMethodsUser, +} from "../../../../test-utils"; +import { useMediaVisible } from "../../../../../src/hooks/useMediaVisible"; + +jest.mock("../../../../../src/customisations/Media", () => ({ + mediaFromContent: jest.fn(), +})); + +jest.mock("../../../../../src/utils/Image", () => ({ + ...jest.requireActual("../../../../../src/utils/Image"), + blobIsAnimated: jest.fn(), +})); + +jest.mock("../../../../../src/utils/image-media", () => ({ + ...jest.requireActual("../../../../../src/utils/image-media"), + createThumbnail: jest.fn(), +})); + +jest.mock("../../../../../src/hooks/useMediaVisible", () => ({ + __esModule: true, + useMediaVisible: jest.fn(), +})); + +describe("", () => { + const userId = "@user:server"; + const deviceId = "DEADB33F"; + const cli = getMockClientWithEventEmitter({ + ...mockClientMethodsUser(userId), + ...mockClientMethodsServer(), + ...mockClientMethodsDevice(deviceId), + ...mockClientMethodsCrypto(), + getRoom: jest.fn(), + getRooms: jest.fn().mockReturnValue([]), + getIgnoredUsers: jest.fn(), + getVersions: jest.fn().mockResolvedValue({ + unstable_features: { + "org.matrix.msc3882": true, + "org.matrix.msc3886": true, + }, + }), + }); + // eslint-disable-next-line no-restricted-properties + cli.mxcUrlToHttp.mockImplementation( + (mxcUrl: string, width?: number, height?: number, resizeMethod?: string, allowDirectLinks?: boolean) => { + return getHttpUriForMxc("https://server", mxcUrl, width, height, resizeMethod, allowDirectLinks); + }, + ); + + const mockedMediaFromContent = jest.mocked(mediaFromContent); + const mockedUseMediaVisible = jest.mocked(useMediaVisible); + const mockedBlobIsAnimated = jest.mocked(blobIsAnimated); + const mockedCreateThumbnail = jest.mocked(createThumbnail); + const originalGetValue = SettingsStore.getValue.bind(SettingsStore); + + const createEvent = ({ + body = "demo image", + content = {}, + }: { + body?: string; + content?: Record; + } = {}): MatrixEvent => { + const { info: infoOverride, ...restContent } = content; + const info = + infoOverride === null + ? undefined + : { + w: 320, + h: 240, + size: 48_000, + mimetype: "image/jpeg", + ...(infoOverride as Record | undefined), + }; + + return new MatrixEvent({ + type: EventType.RoomMessage, + room_id: "!room:server", + event_id: "$image:server", + sender: userId, + content: { + msgtype: "m.image", + body, + url: "mxc://server/image", + ...restContent, + ...(info ? { info } : {}), + }, + }); + }; + + const createMockMedia = (content: Record) => ({ + isEncrypted: !!content.file, + srcMxc: content.url ?? content.file?.url ?? "mxc://server/image", + srcHttp: "https://server/full.png", + thumbnailMxc: content.info?.thumbnail_url ?? "mxc://server/thumb", + thumbnailHttp: "https://server/thumb.png", + hasThumbnail: content.info?.thumbnail_url !== null, + getThumbnailHttp: jest.fn().mockReturnValue("https://server/thumb.png"), + getThumbnailOfSourceHttp: jest.fn().mockReturnValue("https://server/thumb.png"), + getSquareThumbnailHttp: jest.fn(), + downloadSource: jest.fn(), + }); + + const createMediaEventHelper = ({ + encrypted = true, + thumbnailUrl = "blob:thumbnail", + sourceUrl = "blob:source", + sourceBlob = new Blob(["image"], { type: "image/jpeg" }), + }: { + encrypted?: boolean; + thumbnailUrl?: string | null | Promise; + sourceUrl?: string | null | Promise; + sourceBlob?: Blob | Promise; + } = {}): MediaEventHelper => + ({ + media: { isEncrypted: encrypted }, + thumbnailUrl: { value: Promise.resolve(thumbnailUrl) }, + sourceUrl: { value: Promise.resolve(sourceUrl) }, + sourceBlob: { value: Promise.resolve(sourceBlob), cachedValue: sourceBlob }, + }) as unknown as MediaEventHelper; + + const props = { + mxEvent: createEvent(), + mediaVisible: true, + setMediaVisible: jest.fn(), + onMessageAllowed: jest.fn(), + permalinkCreator: new RoomPermalinkCreator(new Room("!room:server", cli, cli.getUserId()!)), + }; + + const renderBase = ({ + timelineRenderingType = TimelineRenderingType.Room, + overrides = {}, + }: { + timelineRenderingType?: TimelineRenderingType; + overrides?: Partial>; + } = {}) => { + const ref = createRef(); + const result = render( + + + , + ); + return { ...result, ref }; + }; + + beforeEach(() => { + jest.clearAllMocks(); + Object.defineProperty(window, "devicePixelRatio", { + configurable: true, + value: 1, + }); + mockedMediaFromContent.mockImplementation((content: Record) => createMockMedia(content) as any); + mockedUseMediaVisible.mockReturnValue([true, jest.fn()]); + mockedBlobIsAnimated.mockResolvedValue(true); + mockedCreateThumbnail.mockResolvedValue({ thumbnail: new Blob(["thumbnail"], { type: "image/jpeg" }) } as any); + jest.spyOn(SettingsStore, "getValue").mockImplementation(((setting, ...args) => { + if (setting === "Images.size") return ImageSize.Normal; + if (setting === "autoplayGifs") return false; + return (originalGetValue as any)(setting, ...args); + }) as typeof SettingsStore.getValue); + jest.spyOn(SettingsStore, "watchSetting").mockReturnValue("image-reply-watch"); + jest.spyOn(SettingsStore, "unwatchSetting").mockImplementation(jest.fn()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + afterAll(() => { + jest.restoreAllMocks(); + }); + + it("renders a visible unencrypted image and file fallback outside room timelines", async () => { + const { container } = renderBase({ timelineRenderingType: TimelineRenderingType.Notification }); + + await waitFor(() => expect(screen.getAllByRole("img", { name: "demo image" })).toHaveLength(2)); + + expect(container.querySelector(".mx_MImageBody")).not.toBeNull(); + expect(container.querySelector(".mx_MFileBody")).not.toBeNull(); + expect(container.querySelector("a[href='https://server/full.png']")).not.toBeNull(); + expect(container.querySelector("img.mx_MImageBody_thumbnail")).toHaveAttribute( + "src", + "https://server/thumb.png", + ); + expect(screen.getByRole("link", { name: /Download/ })).toBeInTheDocument(); + }); + + it("reveals hidden media through the supplied setter", () => { + const setMediaVisible = jest.fn(); + renderBase({ + overrides: { + mediaVisible: false, + setMediaVisible, + }, + }); + + fireEvent.click(screen.getByRole("button", { name: "Show image" })); + + expect(setMediaVisible).toHaveBeenCalledWith(true); + }); + + it("opens the image viewer with thumbnail geometry", async () => { + const { container } = renderBase(); + await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument()); + const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement; + image.getBoundingClientRect = () => ({ width: 100, height: 80, x: 10, y: 20 }) as DOMRect; + jest.spyOn(Modal, "createDialog").mockReturnValue({} as any); + + fireEvent.click(screen.getByRole("link", { name: "demo image" }), { button: 0 }); + + expect(Modal.createDialog).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + src: "https://server/full.png", + name: "demo image", + width: 320, + height: 240, + fileSize: 48_000, + thumbnailInfo: { + width: 100, + height: 80, + positionX: 10, + positionY: 20, + }, + }), + "mx_Dialog_lightbox", + undefined, + true, + ); + }); + + it("updates load dimensions and toggles hover/focus banner state", async () => { + const { container, ref } = renderBase(); + await waitFor(() => expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument()); + const image = container.querySelector("img.mx_MImageBody_thumbnail") as HTMLImageElement; + Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 }); + Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 }); + + act(() => { + ref.current!["onImageLoad"](); + ref.current!.setState({ isAnimated: true, imgLoaded: true }); + }); + expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 }); + + fireEvent.mouseEnter(image); + expect(ref.current!.state.hover).toBe(true); + expect(container.querySelector(".mx_MImageBody_banner")).not.toBeNull(); + expect(image).toHaveAttribute("src", "https://server/full.png"); + + fireEvent.mouseLeave(image); + expect(ref.current!.state.hover).toBe(false); + + const link = screen.getByRole("link", { name: /demo image/ }); + fireEvent.focus(link); + expect(ref.current!.state.focus).toBe(true); + fireEvent.blur(link); + expect(ref.current!.state.focus).toBe(false); + }); + + it("uses the decrypted thumbnail in the image viewer when the source mime type is unsafe", async () => { + renderBase({ + overrides: { + mxEvent: createEvent({ + body: "unsafe image", + content: { + file: { url: "mxc://server/encrypted-image" }, + url: undefined, + info: { + mimetype: "image/svg+xml", + thumbnail_info: { mimetype: "image/jpeg" }, + }, + }, + }), + mediaEventHelper: createMediaEventHelper({ + sourceUrl: "blob:unsafe-source", + thumbnailUrl: "blob:safe-thumbnail", + sourceBlob: new Blob(["html"], { type: "text/html" }), + }), + }, + }); + jest.spyOn(Modal, "createDialog").mockReturnValue({} as any); + await waitFor(() => expect(screen.getByRole("img", { name: "unsafe image" })).toBeInTheDocument()); + + fireEvent.click(screen.getByRole("link", { name: "unsafe image" }), { button: 0 }); + + expect(Modal.createDialog).toHaveBeenCalledWith( + expect.any(Function), + expect.objectContaining({ + src: "blob:safe-thumbnail", + name: "unsafe image", + }), + "mx_Dialog_lightbox", + undefined, + true, + ); + }); + + it("falls back from thumbnail errors and clears image errors after reconnecting", async () => { + const onSpy = jest.spyOn(cli, "on"); + const offSpy = jest.spyOn(cli, "off"); + const { ref } = renderBase(); + await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png")); + + act(() => { + ref.current!["onImageError"](); + }); + expect(ref.current!.state.thumbUrl).toBeNull(); + + act(() => { + ref.current!["onImageError"](); + }); + expect(ref.current!.state.imgError).toBe(true); + expect(onSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function)); + + const listener = onSpy.mock.calls.at(-1)![1] as (...args: unknown[]) => void; + act(() => { + listener(SyncState.Syncing, SyncState.Error); + }); + + expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, listener); + expect(ref.current!.state.imgError).toBe(false); + }); + + it.each([ + [new DecryptError(new Error("decrypt failed")), "Error decrypting image"], + [new DownloadError(new Error("download failed")), "Error downloading image"], + [new Error("display failed"), "Unable to show image due to error"], + ])("renders media processing errors for %s", async (error, label) => { + const { container, ref } = renderBase(); + + act(() => { + ref.current!.setState({ error }); + }); + + expect(container.querySelector(".mx_MImageBody")).not.toBeNull(); + expect(screen.getByText(label)).toBeInTheDocument(); + }); + + it.each([ + [new DecryptError(new Error("decrypt failed")), "Error decrypting image"], + [new DownloadError(new Error("download failed")), "Error downloading image"], + [new Error("download failed"), "Unable to show image due to error"], + ])("renders encrypted download failures for %s", async (error, label) => { + renderBase({ + overrides: { + mxEvent: createEvent({ + content: { + file: { url: "mxc://server/encrypted-image" }, + url: undefined, + }, + }), + mediaEventHelper: createMediaEventHelper({ + sourceUrl: Promise.reject(error), + }), + }, + }); + + await waitFor(() => expect(screen.getByText(label)).toBeInTheDocument()); + }); + + it("renders export images directly from the event MXC URL", () => { + renderBase({ + overrides: { + forExport: true, + mxEvent: createEvent({ + content: { + url: undefined, + file: { url: "mxc://server/encrypted-image" }, + }, + }), + }, + }); + + expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute( + "href", + "mxc://server/encrypted-image", + ); + expect(screen.getByRole("link", { name: "demo image" })).toHaveAttribute("target", "_blank"); + expect(screen.queryByRole("link", { name: /Download/ })).toBeNull(); + }); + + it("switches blurhash placeholders on after the delay", () => { + jest.useFakeTimers(); + const { container } = renderBase({ + overrides: { + mxEvent: createEvent({ + content: { + info: { + [BLURHASH_FIELD]: "LEHV6nWB2yk8pyo0adR*.7kCMdnj", + }, + }, + }), + }, + }); + + expect(container.querySelector(".mx_Blurhash")).toBeNull(); + + act(() => { + jest.advanceTimersByTime(150); + }); + + expect(container.querySelector(".mx_Blurhash")).not.toBeNull(); + }); + + it("downloads media when visibility changes after mount", async () => { + const ref = createRef(); + const mxEvent = createEvent(); + const { rerender } = render( + + + , + ); + + expect(ref.current!.state.contentUrl).toBeNull(); + + rerender( + + + , + ); + + await waitFor(() => expect(ref.current!.state.contentUrl).toBe("https://server/full.png")); + }); + + it("renders missing-size media after loading natural dimensions", async () => { + const { container, ref } = renderBase({ + overrides: { + mxEvent: createEvent({ content: { info: null } }), + }, + }); + await waitFor(() => expect(container.querySelector("img[style*='display: none']")).not.toBeNull()); + const image = container.querySelector("img[style*='display: none']") as HTMLImageElement; + Object.defineProperty(image, "naturalWidth", { configurable: true, value: 640 }); + Object.defineProperty(image, "naturalHeight", { configurable: true, value: 480 }); + + act(() => { + ref.current!["onImageLoad"](); + }); + + expect(ref.current!.state.loadedImageDimensions).toEqual({ naturalWidth: 640, naturalHeight: 480 }); + expect(container.querySelector(".mx_MImageBody_thumbnail_container")).not.toBeNull(); + }); + + it("generates a static thumbnail for animated images without a safe thumbnail", async () => { + let createdImage: any; + const originalCreateElement = document.createElement.bind(document); + const createElementSpy = jest.spyOn(document, "createElement").mockImplementation(((tagName: string) => { + if (tagName !== "img") { + return originalCreateElement(tagName); + } + createdImage = originalCreateElement(tagName) as HTMLImageElement; + Object.defineProperty(createdImage, "width", { configurable: true, value: 320 }); + Object.defineProperty(createdImage, "height", { configurable: true, value: 240 }); + return createdImage; + }) as typeof document.createElement); + const { ref } = renderBase({ + overrides: { + mxEvent: createEvent({ + content: { + file: { url: "mxc://server/encrypted-image" }, + url: undefined, + info: { + "mimetype": "image/gif", + "thumbnail_info": { mimetype: "image/gif" }, + "org.matrix.msc4230.is_animated": true, + }, + }, + }), + mediaEventHelper: createMediaEventHelper({ + sourceUrl: "blob:animated-source", + thumbnailUrl: null, + sourceBlob: new Blob(["gif"], { type: "image/gif" }), + }), + }, + }); + + await waitFor(() => expect(createdImage).toBeDefined()); + await act(async () => { + createdImage.onload(); + await Promise.resolve(); + }); + + await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("blob")); + expect(mockedBlobIsAnimated).toHaveBeenCalled(); + expect(mockedCreateThumbnail).toHaveBeenCalledWith(expect.any(HTMLImageElement), 320, 240, "image/gif", false); + expect(ref.current!.state.isAnimated).toBe(true); + createElementSpy.mockRestore(); + }); + + it("uses SVG thumbnails when available", async () => { + const { ref } = renderBase({ + overrides: { + mxEvent: createEvent({ + content: { + info: { + mimetype: "image/svg+xml", + thumbnail_url: "mxc://server/thumb", + }, + }, + }), + }, + }); + + await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/thumb.png")); + + expect( + mockedMediaFromContent.mock.results.some((result: any) => + result.value.getThumbnailHttp.mock.calls.some( + (call: unknown[]) => call[0] === 800 && call[1] === 600 && call[2] === "scale", + ), + ), + ).toBe(true); + }); + + it("uses the full source as thumbnail for small high-dpi images", async () => { + Object.defineProperty(window, "devicePixelRatio", { + configurable: true, + value: 2, + }); + + const { ref } = renderBase(); + + await waitFor(() => expect(ref.current!.state.thumbUrl).toBe("https://server/full.png")); + }); + + it("renders the file body instead of unsafe encrypted images without thumbnails", () => { + renderBase({ + overrides: { + mxEvent: createEvent({ + content: { + file: { url: "mxc://server/encrypted-file" }, + url: undefined, + info: { + mimetype: "text/html", + }, + }, + }), + mediaEventHelper: { + media: { isEncrypted: true }, + sourceUrl: { value: Promise.resolve("blob:source") }, + thumbnailUrl: { value: Promise.resolve(null) }, + sourceBlob: { + value: Promise.resolve(new Blob(["html"], { type: "text/html" })), + cachedValue: new Blob(["html"], { type: "text/html" }), + }, + } as unknown as MediaEventHelper, + mediaVisible: false, + }, + }); + + expect(screen.getByRole("button", { name: /demo image/ })).toBeInTheDocument(); + expect(screen.queryByRole("img", { name: "demo image" })).toBeNull(); + }); + + it("renders the compact reply body through the hook wrapper", async () => { + const setMediaVisible = jest.fn(); + mockedUseMediaVisible.mockReturnValue([true, setMediaVisible]); + + const { container } = render(); + + await waitFor(() => expect(container.querySelector(".mx_MImageReplyBody")).not.toBeNull()); + expect(screen.getByRole("img", { name: "demo image" })).toBeInTheDocument(); + }); + + it("cleans up settings watchers, listeners and generated animated thumbnails on unmount", async () => { + const offSpy = jest.spyOn(cli, "off"); + const { ref, unmount } = renderBase(); + await waitFor(() => expect(ref.current).not.toBeNull()); + + act(() => { + ref.current!.setState({ + isAnimated: true, + thumbUrl: "blob:animated-thumbnail", + }); + }); + + unmount(); + + expect(SettingsStore.unwatchSetting).toHaveBeenCalledWith("image-reply-watch"); + expect(offSpy).toHaveBeenCalledWith(ClientEvent.Sync, expect.any(Function)); + expect(URL.revokeObjectURL).toHaveBeenCalledWith("blob:animated-thumbnail"); + }); +}); diff --git a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx index 476a1ba666..4f437b986d 100644 --- a/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx +++ b/apps/web/test/unit-tests/components/views/messages/MessageEvent-test.tsx @@ -20,15 +20,11 @@ import { RoomPermalinkCreator } from "../../../../../src/utils/permalinks/Permal import MatrixClientContext from "../../../../../src/contexts/MatrixClientContext"; import { Mjolnir } from "../../../../../src/mjolnir/Mjolnir"; -jest.mock("../../../../../src/components/views/messages/MImageBody", () => ({ - __esModule: true, - default: () =>
, -})); - jest.mock("../../../../../src/components/views/messages/MBodyFactory", () => ({ __esModule: true, DecryptionFailureBodyFactory: () =>
, FileBodyFactory: () =>
, + ImageBodyFactory: () =>
, RedactedBodyFactory: () =>
Message deleted by Moderator
, VideoBodyFactory: () =>
+ +
+ - -
- + +
> renders LargeSectionList story 1`] = ` aria-rowindex="3" role="row" > - - +
+
+ + +
+ - -
- + +
> renders LargeSectionList story 1`] = ` aria-rowindex="4" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="5" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="6" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="7" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="8" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="9" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="10" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="11" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="12" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="13" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="14" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="15" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="16" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="17" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="18" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="19" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="20" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="21" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="22" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="23" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="24" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="25" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="26" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="27" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="28" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="29" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="30" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="31" role="row" > -
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="32" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="33" role="row" > - - +
+
+ + +
+ - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="34" role="row" > - - +
- + + + + + + + - - - + +
> renders LargeSectionList story 1`] = ` aria-rowindex="35" role="row" > - - +
+
+ + +
+ - - - + + @@ -13663,6 +13771,9 @@ exports[` > renders SmallSectionList story 1`] = ` class="Flex-module_flex RoomListView-module_list" style="--mx-flex-display: flex; --mx-flex-direction: column; --mx-flex-align: start; --mx-flex-justify: start; --mx-flex-gap: 0; --mx-flex-wrap: nowrap;" > +
> renders SmallSectionList story 1`] = ` aria-rowindex="1" role="row" > -
+
+ + +
+ -
- - + +
> renders SmallSectionList story 1`] = ` aria-rowindex="2" role="row" > - - +
- + + + + + + + - - - + +
React.ReactElement; + }; + +const RoomListItemDragOverlayWrapperImpl = ({ + onOpenRoom, + onMarkAsRead, + onMarkAsUnread, + onToggleFavorite, + onToggleLowPriority, + onInvite, + onCopyRoomLink, + onLeaveRoom, + onSetRoomNotifState, + onCreateSection, + onToggleSection, + renderAvatar: renderAvatarProp, + ...rest +}: RoomListItemDragOverlayProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onOpenRoom, + onMarkAsRead, + onMarkAsUnread, + onToggleFavorite, + onToggleLowPriority, + onInvite, + onCopyRoomLink, + onLeaveRoom, + onSetRoomNotifState, + onCreateSection, + onToggleSection, + }); + return ; +}; +const RoomListItemDragOverlayWrapper = withViewDocs(RoomListItemDragOverlayWrapperImpl, RoomListItemDragOverlayView); + +const meta = { + title: "Room List/RoomListItemDragOverlayView", + component: RoomListItemDragOverlayWrapper, + tags: ["autodocs"], + decorators: [ + (Story) => ( +
+ +
+ ), + ], + args: { + ...defaultSnapshot, + ...mockedActions, + renderAvatar, + }, +} satisfies Meta; + +export default meta; +type Story = StoryObj; + +export const Default: Story = {}; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx new file mode 100644 index 0000000000..863fed8b13 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.test.tsx @@ -0,0 +1,23 @@ +/* + * 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 { render, screen } from "@test-utils"; +import { composeStories } from "@storybook/react-vite"; +import { describe, it, expect } from "vitest"; + +import * as stories from "./RoomListItemDragOverlayView.stories"; +import { defaultSnapshot } from "../RoomListItemWrapper/RoomListItemView/default-snapshot"; + +const { Default } = composeStories(stories); + +describe("", () => { + it("renders the room name from the view model", () => { + render(); + expect(screen.getByTestId("room-name")).toHaveTextContent(defaultSnapshot.name); + }); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx new file mode 100644 index 0000000000..a5ff9727d9 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/RoomListItemDragOverlayView.tsx @@ -0,0 +1,46 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, memo, type ReactNode } from "react"; +import classNames from "classnames"; + +import { Flex } from "../../../core/utils/Flex"; +import { type Room, RoomListItemContent, type RoomListItemViewModel } from "../RoomListItemWrapper/RoomListItemView"; +import roomListItemStyles from "../RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css"; +import styles from "./RoomListItemDragOverlayView.module.css"; + +/** + * Props for {@link RoomListItemDragOverlayView}. + */ +export interface RoomListItemDragOverlayViewProps { + /** The room item view model — same one used by the real list item */ + vm: RoomListItemViewModel; + /** Function to render the room avatar */ + renderAvatar: (room: Room) => ReactNode; +} + +/** + * Visual clone of a room list item rendered inside the dnd drag overlay. + * + * Reuses {@link RoomListItemContent} for the inner layout and adds the outer + * wrapper styles that the live list item normally provides (height, width, + * typography), so the floating clone matches a real item. + */ +export const RoomListItemDragOverlayView = memo(function RoomListItemDragOverlayView({ + vm, + renderAvatar, +}: RoomListItemDragOverlayViewProps): JSX.Element { + return ( + + + + ); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts new file mode 100644 index 0000000000..0e0b212f74 --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemDragOverlayView/index.ts @@ -0,0 +1,9 @@ +/* + * 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 { RoomListItemDragOverlayView } from "./RoomListItemDragOverlayView"; +export type { RoomListItemDragOverlayViewProps } from "./RoomListItemDragOverlayView"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx new file mode 100644 index 0000000000..4843a3463f --- /dev/null +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemContent.tsx @@ -0,0 +1,79 @@ +/* + * Copyright 2026 Element Creations Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX, memo, type ReactNode } from "react"; +import { Text } from "@vector-im/compound-web"; +import classNames from "classnames"; + +import { Flex } from "../../../../core/utils/Flex"; +import { useViewModel } from "../../../../core/viewmodel"; +import { NotificationDecoration } from "./NotificationDecoration"; +import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu"; +import { type Room, type RoomListItemViewModel } from "./RoomListItemView"; +import styles from "./RoomListItemView.module.css"; + +/** + * Props for {@link RoomListItemContent}. + */ +export interface RoomListItemContentProps { + /** The room item view model */ + vm: RoomListItemViewModel; + /** Function to render the room avatar */ + renderAvatar: (room: Room) => ReactNode; + /** Whether the item is being dragged */ + isDragging?: boolean; +} + +/** + * The inner content of a room list item: avatar, room name, message preview, + * hover menu and notification decoration. Used both inside the full + * {@link RoomListItemView} and inside the drag overlay. + */ +export const RoomListItemContent = memo(function RoomListItemContent({ + vm, + renderAvatar, + isDragging = false, +}: RoomListItemContentProps): JSX.Element { + const item = useViewModel(vm); + + return ( + + {renderAvatar(item.room)} + + {/* We truncate the room name when too long. Title here is to show the full name on hover */} +
+
+ {item.name} +
+ {item.messagePreview && ( + + {item.messagePreview} + + )} +
+ {!isDragging && (item.showMoreOptionsMenu || item.showNotificationMenu) && ( + + )} + + {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} +
+ +
+
+
+ ); +}); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css index 50b72d2429..fcb2e38ecc 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.module.css @@ -70,6 +70,11 @@ min-width: 0; } +.dragging { + outline: 1px solid var(--cpd-color-border-interactive-hovered); + background-color: color-mix(in srgb, var(--cpd-color-bg-action-tertiary-hovered) 90%, transparent); +} + .content { flex: 1; min-width: 0; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx index 363a25feb0..b043857425 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/RoomListItemView.tsx @@ -5,14 +5,14 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, memo, useEffect, useRef, type ReactNode } from "react"; +import React, { type JSX, memo, useEffect, useRef, type ReactNode, type Ref } from "react"; import classNames from "classnames"; -import { Text } from "@vector-im/compound-web"; +import { useMergeRefs } from "react-merge-refs"; import { Flex } from "../../../../core/utils/Flex"; -import { NotificationDecoration, type NotificationDecorationData } from "./NotificationDecoration"; -import { RoomListItemHoverMenu } from "./RoomListItemHoverMenu"; +import { type NotificationDecorationData } from "./NotificationDecoration"; import { RoomListItemContextMenu } from "./RoomListItemContextMenu"; +import { RoomListItemContent } from "./RoomListItemContent"; import { type RoomNotifState } from "./RoomNotifs"; import styles from "./RoomListItemView.module.css"; import { useViewModel, type ViewModel } from "../../../../core/viewmodel"; @@ -150,6 +150,7 @@ export interface RoomListItemViewProps extends Omit ReactNode; + ref?: Ref; } /** @@ -164,14 +165,16 @@ export const RoomListItemView = memo(function RoomListItemView({ isFirstItem, isLastItem, renderAvatar, + ref, ...props }: RoomListItemViewProps): JSX.Element { - const ref = useRef(null); + const internalRef = useRef(null); + const mergedRef = useMergeRefs([ref, internalRef]); const item = useViewModel(vm); useEffect(() => { if (isFocused) { - ref.current?.focus({ preventScroll: true, focusVisible: true } as FocusOptions); + internalRef.current?.focus({ preventScroll: true } as FocusOptions); } }, [isFocused]); @@ -182,7 +185,7 @@ export const RoomListItemView = memo(function RoomListItemView({ ) => onFocus(item.id, e)} tabIndex={isFocused ? 0 : -1} + aria-selected={props.role === "option" ? isSelected : undefined} {...props} > - - {renderAvatar(item.room)} - - {/* We truncate the room name when too long. Title here is to show the full name on hover */} -
-
- {item.name} -
- {item.messagePreview && ( - - {item.messagePreview} - - )} -
- {(item.showMoreOptionsMenu || item.showNotificationMenu) && ( - - )} - - {/* aria-hidden because we summarise the unread count/notification status in a11yLabel */} -
- -
-
-
+
); diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts index 72f2f98119..9d9b6595d6 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemView/index.ts @@ -14,6 +14,8 @@ export type { RoomListItemViewProps, Section, } from "./RoomListItemView"; +export { RoomListItemContent } from "./RoomListItemContent"; +export type { RoomListItemContentProps } from "./RoomListItemContent"; export { RoomListItemNotificationMenu } from "./RoomListItemNotificationMenu"; export type { RoomListItemNotificationMenuProps } from "./RoomListItemNotificationMenu"; export { RoomListItemMoreOptionsMenu, MoreOptionContent } from "./RoomListItemMoreOptionsMenu"; diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx index 1165e7a0b7..c19209d574 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListItemWrapper/RoomListItemWrapper.tsx @@ -6,9 +6,14 @@ */ import React, { memo, type JSX } from "react"; +import { useDraggable } from "@dnd-kit/react"; +import { Feedback } from "@dnd-kit/dom"; +import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers"; +import { useMergeRefs } from "react-merge-refs"; import { RoomListItemView, type RoomListItemViewProps } from "./RoomListItemView"; import { getItemAccessibleProps } from "../../../core/VirtualizedList"; +import { useViewModel } from "../../../core/viewmodel"; export interface RoomListItemWrapperProps extends RoomListItemViewProps { /** Index of this room in the list */ @@ -22,19 +27,8 @@ export interface RoomListItemWrapperProps extends RoomListItemViewProps { } /** - * Wrapper around RoomListItemView that adds accessibility props based on the room's position in the list and whether the list is flat or grouped. - * In a flat list, each item gets listbox item props. In a grouped list, each item gets treegrid cell props. - * - * @example - * `` - * - * ``` + * Wraps RoomListItemView with the correct accessibility and drag-and-drop props + * based on whether the list is flat (listbox) or grouped (treegrid). */ export const RoomListItemWrapper = memo(function RoomListItemWrapper({ roomIndex, @@ -43,9 +37,30 @@ export const RoomListItemWrapper = memo(function RoomListItemWrapper({ isInFlatList, ...rest }: RoomListItemWrapperProps): JSX.Element { - const itemA11yProps = isInFlatList ? getItemAccessibleProps("listbox", roomIndex, roomCount) : { role: "gridcell" }; - const item = ; + if (isInFlatList) { + return ; + } - if (isInFlatList) return item; - return
{item}
; + return ( +
+
+ +
+
+ ); }); + +/** + * Wraps RoomListItemView with the drag-and-drop functionality. This is only used for treegrid mode, as flat list items are not draggable. + */ +function DraggableWrapper(props: RoomListItemViewProps): JSX.Element { + const item = useViewModel(props.vm); + const { ref: draggableRef, handleRef } = useDraggable({ + id: item.id, + // We clone the item in the dnd overlay to avoid putting a hole in the list + plugins: [Feedback.configure({ feedback: "clone" })], + modifiers: [RestrictToVerticalAxis], + }); + const dndRef = useMergeRefs([draggableRef, handleRef]); + return ; +} diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css index d587c8014f..9bc9982c30 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.module.css @@ -87,6 +87,10 @@ padding-bottom: 0; } +.dropTarget { + box-shadow: inset 0 0 0 2px var(--cpd-color-border-accent-primary); +} + .menu { display: none; } diff --git a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx index 50b92c8112..115f112d10 100644 --- a/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx +++ b/packages/shared-components/src/room-list/VirtualizedRoomListView/RoomListSectionHeaderView/RoomListSectionHeaderView.tsx @@ -10,6 +10,7 @@ import ChevronRightIcon from "@vector-im/compound-design-tokens/assets/web/icons import classNames from "classnames"; import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; import { OverflowHorizontalIcon, EditIcon, DeleteIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { useDroppable } from "@dnd-kit/react"; import { useViewModel, type ViewModel } from "../../../core/viewmodel"; import styles from "./RoomListSectionHeaderView.module.css"; @@ -103,12 +104,17 @@ export const RoomListSectionHeaderView = memo(function RoomListSectionHeaderView const { id, title, isExpanded, isUnread, displaySectionMenu } = useViewModel(vm); const isLastSection = sectionIndex === sectionCount - 1; + const { ref, isDropTarget } = useDroppable({ + id, + }); + return (