From aa84b2e07cc160e2e8cdd881de8ec56d06ebc531 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 19 Dec 2025 15:40:45 +0000 Subject: [PATCH 1/3] fix: Allow wrapping in `Banner` component. (#31532) * fix: Allow wrapping in `Banner` component. * chore: Remove translations from stories, update snapshot. --- .../room-banner--with-action-linux.png | Bin 17305 -> 18589 bytes ...om-banner--with-loads-of-content-linux.png | Bin 0 -> 58061 bytes .../src/composer/Banner/Banner.module.css | 4 +- .../src/composer/Banner/Banner.stories.tsx | 32 +++++++++----- .../src/composer/Banner/Banner.tsx | 2 +- .../Banner/__snapshots__/Banner.test.tsx.snap | 39 +++++++++++------- .../HistoryVisibleBannerView.test.tsx.snap | 4 +- 7 files changed, 51 insertions(+), 30 deletions(-) create mode 100644 packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-action-linux.png index 563cadf02736848d95f956045c3988585c105eef..b3968f9db7c68999b31aaea77912ce80a5aae1a6 100644 GIT binary patch literal 18589 zcmeIaS5(tk^!Uq+!!RQPGlPnPz$k+#2vHHDK!}baDkUOKT2SdFL()z-F^J=^DjT{-Y#n1_(4YIH<{~KE;&TyuF&OU9eEEIMY6|C z(Y0@?-!z4c9SzXCYjXMQ0`$e;zK{7GMa_p^jci?9OZ<8+uG#6xh4;fpkAU8{Yu!k_ zGylstBzY_`eE(wk!vLFH2>929nZNqO{|L^xO$~Ol34XjwE8_O2+r9m)s!q2 zNVmI(z|6)D$Gn8cREXJojG7p>Rj{jJCh0~0YQ#|tTZfBdz_zyWWj}0u&pJ48N`g&YhBJwk$IiRP(?6xQkb~{WGy`b+I zx$G$(S2?!(bGQ|1GY6b3#bP8I{9kO_WeY?!FigbMF$=O>9*RfD`1^OqnjZk{gisoW zh-(WLkB15Hi<|BDMZJzl+V;R!{EHXUz2#W@t*j42XucsA2Nq8b&1B{%s2Mk{Y*DOA z2X4Q+FUbue9sEJ&J?h>}7uJEC+ggedj#>g?`R<{^XG+N}qGGW~P{~v_S)RF!$-;y< z&(9t0NxjOcSNdrFUOK}!oe`?<>J6;RW`X85U6g9pcE3HD zE1O~9Ru@Sp4x0kRi!{4(gr&nozk_Ey2aq`&mL*!n5R}U7vepgI5CR1$6kwZ4Kff~Us^I8Wy6QAY90cuF8iVH_UGEL()uN7 zfG~nvhV2o%*4n$sP20u57Kyx87a(Zc%X2Ryf1%zvUpg)8h>En3`KjAkvhGyvfr`7c z&D#L%^h6h=oKN_+fEEIW$iERq45;fObW^H}MBPvME zTmo0gO0tXwm3VzTBUBLeC(|O1aBa8+SjD7gsnx2ZWyw%^+7>ux-MPPQ5aVhzQ~^Z4Ns0<3l2& zbvds_J8B8h7IU*Xs?4m2UuAZFkCANGWT;D3iG6>R<7m>flucZd!726dB|mkfUWXsL zSzlb)q~M&q^#I#yI&Z7Jm(;dGaC5_sKbK5)coNp8?TKW#Sc&L2?K6*GC;+d&M2q-H zDZevSo7vd2cpZGs>x!9~G5WGejEFJmW@@CVmK$3mY8DB|%c2F+P|ij2davGw%Be)` z7D0`~UBL3UwT4>DruF>x9r{h_7fo}Yu>$ENkGUlc@x(82<^fyZnl#%G-5+~Pb|?Dr zuECrG)g&(~ba(%AtyJvrwUpe`2=DNiKSG0#|IyRdymsqlfJ<|Q`I&NlrG?x^sg8KD zFkmD5)&_}suKARdC+}pP1faFCTU~(t)_+X^P7Z;2VSSm*0_pttAni+Qgj;ybdUsXq zEF&r2N5k`c1b+{tJ1BDl;Oa;br7&B41LxM&v$kH=H3zq1r)u*{Iy0pL8>K3Uj*03h ze*ANJk`t^I9X`{THXnip{QQxDn(QCcVsl=%-|8Sc!`eY1yARdViK? zDpVqtHo?TDhYbzDJInH9YQ(N_x>5?Dwgunnq(d|%pyybpOT(`YBj%kmc#4^`+LlgG zVCe}}mzPf&BqV>r*2OGo`moVvRjLK2L=gSc*0*Ho7rOYv0$5r~86?iXYJf)b#xp%* zwqGgZM8ZZ3ErTc_%X4SVGS!mvGC!r&29_{_vu;_eU*Acw1UBaSpIcm9wIgIZ4dprP zWW|jYq}1dh;a-$Ceq-zm(SnVxc$C)F)2ZxN6SBf4L}7-V=LfULOh>X|xXLWU=(YEM!*xiC*Bo468+-srb)ub1cfm?j6%hcMm1!~^swy0!Tk`4 zP^=cBDt%lrF_Kf!?Q2|3jl91ei8N~}St2+IH4SRGz;fcf=ZXO5ymnx^{&hto~1tk2fVA?^Ym39Qa+ zXZ1DlC9Gi7YCnzHy4KZ=f;W_+#hbM1WIU;cAdnbW8+vMr=b=FhtEVt+ITUq%s0&>yrsba8O1dtuIP|`)Fp*Vk z*Fns&J-rw+cC48YA%*(U3oebNP(O6rO1ItfpqcBb1TKflMwGWc`> zCY-%UDcFx!Xqb^({(6~)4BmTS;v30vi4fn?s^o}0QS1PjdV~TEv z;8jNnJd7F8*r26_W0&IhNk3Jczqg-d_A@3+b$KaDWV&^ANbLmo?$DDjjhG#Zm z;HZjs1)D1Bx1Xdn3W=|fn3AP-ilPVWqM4&JKc4~MbjsiGbX~i~bP;Lf)-Ugz#F>XJ z^c62~S~uI#MC2X#KssSabNwC}1IPz070>ZR!AeT^zmVUp{A&v-DI!&yHeV%Fy>34G z^MQ<~i*8Jhn`XM{qTA0s8jRc`CQQxl3LCK^nLG0 z5(cZ>v*Qq=SgbZdF|Vy&ozxmMQMVi-_EXy$M=4WH8=pK40pzor%9^PR(nl8y3$4Vx zKU>fN?~G#M-?Y7at5`{yv!4di< zEC^TaI{BjNc3U8CI;%Xzv7rV!+-53RijX!Msu<4>kxJiecQ02a5u80i2oH`%TlC`@ zKZlGiNGCm6Z=QA^UJ{O3O;uY8DacqV%WC!)b4{atU7L*jm(W(iDItf2a&8dy{*lF3z`lNfkP^Y>Q5NX`c3if^g=0wJcnnU1Y z!*yOA>y`7d%Qk|w{&TqU3inNM2HU%5>x}GV=`zU(D^wuDMn@y1jid9RTCF^A;D;@{ zo=tuPmtM7O&P_g*a{=8Wa64PRp47G90zzS;2dd^m)pYifW9)fbm+ZO)8Px$_=xH?s zFAMZ>-Ikq_$I9zPHP!OCmI~pw)ycR}J z3yvwT{${#SlXpQpmECj&gLzl#wG5MIFVYVf1G_Y&O!=G)+O14|U4VN2C(`Gw>g9vx zv2iw2ge7gHUDdRl+7b?caL+da z%~9tBmXT)Hmj+A;8mv)}N5#*T24fH_m9i(;?sEeJqVpcef{^p(fwO zLoo7r=SmaGZ?%vDL}|F1g?JYh)yye0-{Az_j$9q!C6otYYhfL6b*n)k^s&fAi=A03 z53{3_DKNbtg5E)RKy27XeO;D<)oy9Nx;utif+FSKNV&cBsHsU*s-eNL(TEoA!J-0u z0t$V+#NWn?>C|P{!sqETd!bnHU@CK1nYD^Wy%vOX{8<{W`q{x&0ttywRHyHD+gumC z6y zVr{eki3I@NBWw%5)+>NnU(6~OH_Zb!&C;r~*F|zghMKhJJ2s>7C7Ub6@ZL(^&|DK^ z(orh1!qGOyCihfuL~G!QeQjUyAZI7u%BR(Zfk1YRN%3etTcY=&{_|zSio$K zg*fn@m*!tLW<+<{BPE!2Em4Zt3xD10^L@`qOICb#{^4-u$3239SbXdaV`Q~w?7Nbk z_h{yp_|ed;Bz!1^>1o0h@Gk#szMeD-kBpt|Jt~rjig6CWRSx0#LN?z4e9$9$A-+bZh#cSkTsZu=ht}27 z*;!6o$)8ESQ{@G5ARdX>$&W~D-lN`(5F2Sl5w`}X{}iy6 zHXv^$*V$aVRujKm5S#>oi@6a zpSeDNp)(#xQ-38~q7RUlu7UA^e^+o`HXNrhe)BBe&d0s|V~@QT++FnO32k$yqIz2n z2{?kaJ=Xjf!LBwIH^f)sT}tJidr^!#k}4`=9}Dv0iG2G$TT{h_;-AdK}))$s*(?E+=I zshC8#6J{~|m2Oj+3BT?RZHWA~_2I6CvY&^`i!W)XRMSCjLRF##kRyj!j)M45%yyhYZuJS;0 zo-ot`sIi^~w(EO!4>FICTN*+4-FWM6nJ~;r-PS6hkjstvOlOrEZfo^+F3q>$r?z{YxJq$GKA>(yb8P}u5KbTQk1YNGi=A>3EHo6-7>Y81p~ z-bRgIu-i$jz3bI|rqVEG%D8&+%kLIGNfdZ1xrG0mC!bVgJt~ZMO}X&wc1d;QcuDxn z6B5_eW{!v8a|S~#)>y-rv(j@89P0*)RhG@{boOj8$@c)JYZtMrE1iHu(yX^~Q3VH& zq&El6@9!iN$3{Rg+sz2_pzf#YZ&Adk8Bu<36ObMIXy#RfG0i#CyfmymLgnDRlKO=J zMqFrob2zr4*ucfuPtwVCxtw&|!B#)fMAg)GH7daF-fh-cy|<-DN4sokgO6K~gS$(% z$-RJNW>5FqdO3Il0fur8r2XSiQl}66i9T9KG)mJXk1lj7iC5iJu{RrHC&m(#j}P3w zKl3!s8iC%a&7B@S3IsGvjy>%J4!yeRdj34M4qswm7|593CL+9c+BzLASo%R~)6NU* ziLR8gJ4;WKZpg=XR{j&>A)+T+>CPZ)fGxdLl~gX@tbY0Fk}3EcH_mWSBfX4-_*&)% zC2Z8W6BJKMKYKwGD##oy$IRm;?%QwXC*C6erH;*f>RY&f!jFDC@iVUXD-z>VY+Hx#UAl>iCMms2wb zdb3&*X6(LQsek>K3WCn1PFuy}-U%o?wWiJLOimT^HsNmf8`x|61M>5kIx|YBg!Yab zegIPF-@3Moh~uZAjsAbPe?38`jb-R(yGGAqVu^Zn@yWPs=*xhRFAUQ1Xu6eifAtC5 z$YA$YUn>*x*7Q=gkIEdl5SB(%nw4=?vSqS6_bA&teR(3x8s> zLZ)7b{DI+P{}eWa>CCD?P7FLo&y=iY6=nso%d^j)9d5$=2EeQ$XSeXpxL43~Conog z^fzsVy%)F-Y%ZfW8YU_6UY`E6T2h^%z#g(Z5wG#3Wuw`$|HH=%2dskFub{5y&q>xk z|E2`B&gBhEQOUQS9l9O6otWOxm3c+Kv5$>Dm8P46Sfq)#Y&z449e%e)T{V&1fg z^~?#ZA$z$QXFZkkHP$CrOef|nH^^$s?lDw{oN~K`Sed8=sjFqs-uWV^U%4C3%!uWc zX=^&2GSH(C+W9EF@}R*s6kX!aA91Wyu~wyViH|9R*hScpaN{#;{VVyzVZsVp@v8?R zOmod#fFIJpPFNi9%vw)RN(B>Z|5SO;+BAK4InueWNP#u><4FbXPm5U~oe3#=QA7nY2%PJ;}1P zwb{}041(L5oV-d0J(9n1V{eM-{+NU|T22_sWmXjcB&7L_Lq6)sQ`rRv!DGo0_ya)M_=7oZW3PU-*n_}*wQLj4 zsw58(AAi(CZpiWJw(RG&Z)33Z(TFI$`8ID%$n6$W#E8-UNo$lzn%TYQR>_B>@1N`= z_1sb+H)ymvwVm2$>us22kGZaD+!_{KA2F&-l%>&+q)HD$bWLL{{Goy2*D**pH?!rl ztI6s;)7QC#KtIGzl;0@6MSZ+^z`4mwG-WX=G**G2`Gr3EsIzo{4}`~hmb1B~5arDj zzRnNdIeAn!+X1uq1%?l}=Hr$#Q#c4X2}sNi$%^15R|3zyi}aCG($hWJKZGi(Y=ePB zIT}>WWYyE$xJS6+=!F88>>!Gm5Hm#Q-WhsnM_3mUzu=fP5b;3J5XXG2|67?ljl2_w zvFsnf6!i`r3R_%gh`$0NDCXXOa~?JJpaJEqJgdM0{sl0R#!1k63hV2_PVh%cVvM30 zZ|vb>oZ;0x^csI-Fr`#CX1*MacF8#}U2_o?1WXC~pBWnHgWlQ3d^$W=Bm_0jH2NJ` zQ+@61C!Aai_c4h)SBiLZ>((nphPHD4SDD_ z!>1CLu4Um(&l^jg zrA5bnd)K?A2u1f54XD$53Zrfnu~*dsIUcRE6Fx{>88n}&asM`V@dIWk-F zbCGo$)j1Xw>1bWvN(KpfqPw;zslVg|xY}LN&hK#~cK>9Pydt`Za!qjwV{FT$gcUSd z9-KA*$#-N(L;Mh!Q<>m@=i5K!=?Bsbpe=zNsm&8iL&WU2yHC7_X@m(JVS(X;$E4Jk zS41#L$W;)q+soN5#&7wI$jZ+!kB$mEbMs6@Smzktib9c=y@|XOq8-~LdTh<^>T+~H z7qGsW;|#8EJh|5k7CR2*U;G2Gxmcd`C<5G2IDe=Np`dMH`CrFu&ta%R|n$j%%E< z$xSmkcec$mPt99T@6!P$o82^c!X+zd-WugG&}-zj1XYX2t|kUaw(SVqn^uG>avtze?zVbyy;gu0<5^IrEI2P*K1)BZq($8 zR$|x}`P&o&QJP1ldi0)!PQmg;X3M#YU+X8EL)YF~TY6c=MBWYbb{YO;-42U3h`06SL9rD)QWfAf)J3wDI6@eLJ`G;g<5HOYDZ*kX9oB zOSk5EI0DIk1*s*N#=ybI&hQ}1*1~&Jy)4Hd#k`zr`&OD!eqL_AVbN`h#1rI}KLHJ^ z!>tcxeTr2M9(Z>k<&bK(pLm#>>f`Cp*u3J`Id->rX72{|h4R$}Ve?%pv)8WBX9Ts& zI<$$+4XUE28F}sg<$0&;)1Iii2FA2DSG&Ipy=)(|UOSdhGa{n(vU_qDH?M?kwo%DWVYBwym}w2- zZ~HVZ1h9LrBUDOra+<1$UgPy@At$3|bCgrttfA}!`YIrhTv8eGx$SJ^<9@Me+p$VQ zbVpOu2_sYB2@1XCTS8eBrtRmNN^^}LL{Q6nA2+G+tlUHsAl-!Xu(Sw>s75YC>4hPtyh~>V@X);?uz2R140Q#c~tzmPaTC znAbmRqWzxm^AC5k>wIE%TtJhge)B^_ctrNh^6?FboAr%GsvaJ(J2?ojV#$SYcY z$*S4Z2iPEl;6l390)#F48%L+FW>J{)%pKPKMFVxo(X2zSPUN?)3$I^JZS6+OyVTdl z&aba$jeEe7-8df6-RO;s{lP8hO*Qv982tJRy`GPY4+V||(fr@7t}`b6a_J4y=a&41 zWbxXvGbLrr9J1ZV_C$L^ zSJxwHx4A994c9l+1>w+(CIeS9;3%gu4ZPc z>`U2Q9j2`i{W>MgwLV`6BTC1Wj`r7k@6GKuWoLW7$ixIsQh?!CWJlr9F~RCl)JS!* zI%v*9tFQaOLG8%qbLWh;O(IhPCp7bta`T&nOb`#hhgQ!BxU@-XOku4 z&QQ!69gXy|;kByAgLVBz^t1e}nQ#3VgH!)#&2?*HNEcAvP3R8&@ra@0y`-jKC$1aT z8PrZQX(7$zy&8<_xv)+l)eZch86y|D2jc-jqBvipF-y5#Z0k1Fd!t%jl!=f4*0p9J z8tc*_$lQ&c@Snpo)Xk)2Ndlj)P#w*TQTa z6>RuG$&IVCmXlYOcsaVd*ADDg$6xnrY6$HsN+#w^DcYu6m$^3Gxuv@iZYg|~J*cqO zIa4y(K|a_Ov=4y;ZOazL`?wj;M)vi;n_#q6o$MVl@?-aqt4ppwhK9+5)aUg3enmY9&hY?pds(bvYzTTmiEXF9Xy_EN%p|lZW$+`5*P@StZ{D$XddA{Xt zWJt?u=+^7&MzI-Cx408# z6ZBG7w-+5c_>G>5GRZINgE@ixXA_#gqA%lrkgoi`N79|7^1$KPhH!V?j6&XNtTcT* zc`5>a8QT6&Vxe^hIw%PF*A1QYTz@naGrV{=OFbfjYLcdPwd4=(ihd7-LV*PhyzLeK zysZ@CdVg)*!$xh{RERO_EbTax_R{nA%YskGpB&UA^>uPV2G`~mc;P?_EVd1)Ro(W+ zLdBLG(c>KNXBx)o)*;^50{DB`g@WIueaq^wRqBlkr3;PS z8nk8+aeMERHjIkT& z(M8=Z{P_X+_~i}e2c)$|DH8aVZU;3xe-IZ5;RIjj^hAR5L^ty979YTS_>B%EtCtcj zZW)h07wNue+v;%TK=0u_A|b7RgR)f9O{bj}_*3y@PfD%fD8&RXU19l8K2Q0>Vn*kH z(qPj#$GQ}0#(1voY6AP)uCUY#wZZL%utTV0zmfAwd-VW%fT!TzZq0w7y$TmgMT+;b z17K55&5ds^2=en+dwd~S?;=7q0?BzoA{Kp0hy&nay%BURJp(;S?Ddd>PbsYmYCssw zpJS4)MjfSd!$X8I$R2i%7E$fI-{>j}d9SF}OTA&XO#9kL@Cl*$M{WP(1s3sE z2EEgY7HZm%9Ys`E%+C9&LK=xyN~~H<0Si@}>2;Aa^W9BAaF|zab5_O^8v7Y2wC?Tk zQ)T67f2)o(g&?vj_HRt>f@JT(2e_XP-XDdl1qgz^yz9Y^Dm2QqLvRE$CJWdyQ zr&PMtxwzVk7Q+!u{vW=?6`9K|6!#wAZ(*q+<@;amhPt?nd7v(j$4%*{Qef^)-kl|z zuN^R9{;dAP7V4EM^!1yF*2>CNZiHunq`2Ht^YM8q{gxpoF-x9e^e!?2X$*JA`??`( zx>H;%UmS7DxUq@R3gX}r9JnnX*sH0HKI>O)tD&b$f_k<-Ad>vXQ(eP`5+}<`@K_gj z((GiES%aWfE@8)k9VN8uBaSES&R<@Cx_Y@gJ0jQx?`tLM$=q)Ha2;Wwng1_zD!k)e z@1!9>IAWwWt@2?ctHEj`K3O`oIsMR1h}x(T3#T7D8kV@5!-oedQK{2v3qj%+0qL3U z=6)VEKW$vK&E6RQ#tkFSPgTf{0|#5Zm!7JHKwCKUkj7`lC9=aULonU$3PFTSK!<@5a7O^M@!L|OldW0)^{US2mlM21Fa!{ zAIa30o)*8zLij$dyj`!QmgjiJZ{$;(cdV0znH!azW9~DRi#OCWz&D;zo=YW$Ft*0r z@~2yZNA2L<`3@M?+xm~A%LfNMsb7XCTYcu%_2awsA$QA7A4csrf@->v1Khlp?Ql1{ z^YX7H=b6-_++g;FZG~I^*yX9<-VZ|HF|SYR_QJXkNHxeujlgZiS{GV5pz~R}8W~Zo zHIZ%zzt-pu{f&vX)Adm|cMeX{5s8p^ufLK?ozs0L0ML3;9g$E;{3dxkdfd9a0@gRn z_&E@sh3<@~Ty`}fj8sQdI)z|N>~3b?Xlr+jvD_Fn)ktE#HATcjV#66@y^q~&`Nplw zc*@eUpiP1c;2=O^tI9v-2>jR?H4qQ7iei_8j2r=}y69rxZVAntsIrM7nWLCqI8cMv zP@W?h>;38Qg0`ZWfJTt;*mmJp1IFL4FE+}`V?3q(q*4tOuh*(UA$m_mNp7h7hQO3y zh6@~%%sJv1l+7u`&?#Xp~YwPBso`;(49-gj_4e> zywh=p^r3n47Yb?~+v7NE&-^$~5ek!KWRP}c3fIh-1CBF{@YjjRMDVlzZ?=v=MeA$fJb-ZaQ3vx<>>rTAz zkX%7=-JEAOKB{A)tLurdWR-g__mr-_wQn}()o#`wHyt=;=E@xVvUUY+xl@*Fe=FBy z^~@6xN^o2b{Z~vTap(s#R-dPEtkgW+7L3|W2JOz}O%7q^f%|I)70_S|QAncZ4f z|C+#l$T9N0rEEYh=jUf>KO$(7XC|gnPPoL-+jdjl{*aqZ=IS{%%`tqG!uNZS11Fs= zi|b97bMgmp?sll5ipRUB%;B6e9F|`#xAyv1`H(!1XD~R*E zXk=$Dw}(H#oE*zgzhK^sT6#LerwplGfbo#d1FEKG!`zQ0Py8CTwRGXQ^8C-pXSL;e zn$fR?g}*z7HXHs=EMS{X1;Q}L@{X8)%O(KnqoxE#VNHfU*Y#CTch%zyzBiuOxVCKS zb!NfFGoJ!->W_JtgPU&@!k_I7WVR|Faz9sHc%B+1h-k- zp$g=Bdp^=ofrp+=yS_hzR~=*BrA1EY&)=CIMw7c6LMKQ1o;1-MxSi!m%SF9ZX9rJk zgtxFM@nmHoF28s5TMPL1A+Ujw;R|X8^h`-`?@s(k#{$DK4MiW)D|2rRbh<>XuOz$j zHM^V)Ci8Pi6?}dhKfvTfObMSRm! z(U3Sxme7#ZA;O=Wc44Z6!X)9&z4@IHXAY`F8Yc&?e|-)U0P>n%S7M&wzR$CV>Jr~A zl#jH@aYQ$HFEL7z0V{hpN{%6Ae0gT48B;6iU>;hZ0^TsxBE78EJo`084jjvp)ro1> z44aR$Goo^QdjtX7jv zMNUoZkRX2$rl8oGdU<&Qi1d|9J>q%h?v{&=($^EB%@XYmXG$qjN#6hf(y?z2j2Lal z{cYC%&$}v5;!%~fG!=#uh+jMobZ>rVPPacFz}_6*2C4I4L}zPG8H7A$`q(r^sdQXj z>1M%JC4kyA-Yk%ejqZ9T%=8&>C?_{4NCQ5UQdQ;}CVo{`?mO`^E65d^Xv>u6_2CGl zrgB3UGkn<6=?0I7d)fR#E9wv?;2Nv112XY zxVH-LY*rDBCk#%3_{#p!;Z17L!IU9*8%pxj%Ag>$13l0)jK~-SfUAj!8KuHLc58Y4 zYcIM2#Q_>NPMd>xTLIjfq5|(i>%DCUmV&POH8YF_iG4Fii>I0#I;Rh8FuF`qYuGBx zM>1^W*Y~K?N4Qs2^S2iINUt0vx?ni@;A(d)*ZPF49lO2Y89_39B5Ol+%X zn!F75vx!N*2I?DC19{YJSHUTO?rl6o@8*RgMpM=SFR$d%X26onI?odw^3r`fUOU3hYf<`+L znJHqK2{^>h&8v^!I^~$Woi+j8A1=m7MJOGcmZg} zJXB81%z9%9H^W|)O1?uVNd!;fm#>$8*IA#g>7Tp|=7vzc>AF3`-!&@7uH+{D#aVA0Z0jE8RGZSfxPf{xRXB;ez1; znK#2~i{MC)e~uTg#ivRaWTkW8Gu6NCUXTn}v%a35y83rSLR;fS)@g3xcS2&9$5=2I zOlpRR+J7AqQkn`15d2+aCxklS6(QXnf_&7sp~SsKNT%G02SqgC2XE}!qgGAvA~(+z zn~mX5?(!&kD0VGa+Ax%Qc!Q#>^HlBh*F9&T3NjaOYE)89L`y;cFGsSA@PNL#0Ep{L zhGgNug5~}zM^>4UP2Z`VRLm^#i+JhLfn9EA?T7H0oyW}RwKu;oK5zB`+(;`Ez?O?b zpa-sPY6BmK&{B-95NAFz7k)zwVp0{o^&b2n^VMdBPH0|#(N_?=?s@Ed!Y(k9tVTl2 zXZex1otPgkqdn>y+uJJN870mnhHUxA_tBX!gEDUt zXD3opO0|CEO-bI!Z1^!zDZ@?!SWnhUCA=@ZU1{Pc!_d6aTXY|K}LUMSt>% zUHg3O`~L;}XQ{t?GXLku=0AD-@8t2|7nujY{pW@L=YRjVs{h-J{ijU-H_CKJy1SQ` nO5Z*F&42gd=kMOa9+{3mFV=(GxgEP-WUgD-Ua9%}PTcCPxoR z9}*D}IeP!z?MEUa2ZgKqM}FNeT>Po=!-h&!c~b>;0x-u~d0+YZ_3ZoH2Xx`C!p3AQCcUE)p&!d6EZ3@AN8NWUkHy zS(0_pG-Kvm9X@P1@mJwB8Ep+E2^VKX_WvOw^772DPlZ2TpLr>~&R<`P{36`(>;JzU z+l``oGlaZk;b9kejJWsek&YS0%Zb{!cR#nZ51kzmE)U!k5fvKdz`i3wBmI8hs&LcI z-@pH7c6^6aDP{Qh$HXX*+9}nkhFW(O z6+6aVl0rYUy4HH;<2zi?u%K8UK`6qbxCkT!IK%0ypkgr2P(wYZ+>o_Q9y>Qx03Mnl zM6WPY2t0lafthS!?Ac1~k~uCSQi4;5vib*&qniE+z%odJ5B;RXm*TH)7XUlA7Ypwu z{Q+j7lgD-RI`cLoqnR{ya`a*+%{YrY{gO7Uw^@lQPeA8IP1%nUHzqO8{jm|Cif%lZ z%pU}`Fpo5SfpRHJ1^Y(5ZV8Q-6R6(0{R60i4kESCt6O&-9EiNyx%?gD%&DS8Li%E) zod3w(pGnXgM^D>?Et-gR2-No0Gd**TM}0u@*_#O9IyvI30!=ahihP)rOR)4$>z9m} zsw0CjVLfH|SQ5@ysJ?|ydk8dVCIp|!$Kv@~`;8KYj0Idxa5Z}@ zgMTTa#5s!80DbN!*hQayEWEeEtHDNsY@Ijg$IcN`nVxqku}p&VJ`tn2DHxKnaUcH( zy?vIEjpweF8|UqP7d4KNvANrLV7yai6%jP`C>Qv>VDxp zq;%5tnU?$;`+s~}85a1CeXYPx*(|Yn8M}1>yu1H+M{N*|FR@lAlC7A0bw!~ASrkLL z-Q7O07WSkqF!ANpoG8W&Q-EgX?$wM`qZkeb4E@?#gF}w(csLgu{||*)GxGChzp_@< zq2V)V1;LNQ>bL*Ar`ELa+Jw*kCmG6JRtj81L%F?STTG+^*I>S*eL*mXvtq@V7BD|B zf2u$h!q{R>HQ=A1fB&*44&C`g+1=f3T~*BI0*S*%D`+SjSG3IqmVm4ujqjC6WDB>r{#eI zD%tZA=##RWaG?yBYw)jcqu;&ZI^SG+oqQ3@D_4%N%yCv~>sw{;>FvokZro^uQ>HU# zLb^_S9=RfK=kSC*X>}gWXs99N1X&yqdBKj0g({=3pTniZz5FJ5;e;_qAZ^5`DL4W1QVCX}mDv%7oN=dZlITY{Sz^;#b7mR{ra=6hU69OJuik_3_h$il1q z$c@zeR=?gVLAQXq`m4yo4Qn9!4sU-=9>Bjpy~-q7WjiQ$Sju_NiVk^95I#wvp9^d- zG$~1;Up*@^6;XF;lxt_POZ98+ju>kQ)>f`Kr^@`;9L0Hq6yLgeX;P zIB^ag-c1XvKX=Vd%3FNl6AO3$5E8 z=5rPJi`4b@%lT%m9)85c+S@LyzwO7n(-aG>l{3q;jDj{wmtL>$zPM6Sf+%YA;4-sz zNZ_6=!*znlcB3KNu3fph^~Ax-7iNC_b;N;T@9tA~DG#4L9h|Sq^mN2H8}9vWB&T`J zjs1p{>50__=&!u%&$rcuny$?RW9RWI(eJT()saKR5{MPA)o)@tUv}7t4TWUF-4-ve zNp~goG`>wP*ZD*z9>8(aqiv-(OA_vsWbPeY^n~Jrw=MGo=1i7?d0u$e>Cww$sm;)_ z4@;IuGMg6N5+rly9QChx=7xI+R+`mYKPOt$Fj>E^XE;ZAF>=DG+5%RwU^g~jFxQ4| zY2C>7%#8{)I1{;`bW5;J)8>~5AjH~LK{z7^JhSP$QS*NogAf_oy=S6&%mh4;ey+nPCJHrjwe=N zR^s*GAvrsK7||97?Mfh4zM%oUDz0jJER$cDSXZ!WW6S$uC>1rm8md(Z%)Phj5b73W zbx!jcd-s|<50N!h>EI|>8IRM3(k*1KD~UH3j<>a$I;kNF$tZf}?Uu_ydPb|Ns+Isf z@0C{q5{~w0^d+qjtcIy|157%BP!kspiG6)wVwPouD6YL~?EmJFCTm)P$CW!V-oBo^X6YOpBikgU#o2<`lIDn)u&<$laZO^gIpQ9<4(|Xn#7! zdLz^q1|79qv;K9R=h2Sqe^<2&|jh#}NX$^f6(y3UsWGODE z;c8<|;Gk%`(Eb|zl~bm)gq#(2p%Ur9r z)r@E+|7e4Rx=dC)WqoWzS7sJMQKF4ZEUsEpqAumxvb?5^2RRC}L+m>e>Vv?dy_Dmc zLE4RSe>@PLtoG?&1}y`D9i5c z{*o!9T2Sg@Q(X&!$8Tn3KZtUf`=pqiy@7HJxKH0`#EC^$D(Y@H0ZP_9AG6$3383eO z=1-oAC|uOnpv<4?^>Y1lru3gXoMcR25ws<6QS~r+HY<8~>U#6MIdfhQjH}wr0-Lbf zz8}v>i~i8{0-uY_>{3ws4DL@63+-?ek8>-(G>Z86%|FVChBgWssm8s!MOZlt4wlw& zD|=t7uQeCiKNn-{+mn+-$k*R;Uo6Uif&(p}X@5L)(EEBL#Cf6tfut%0RZCyqXb z)>eAYwl$1USdy!9OY4RWH1e!QFEfn9w(;D-LMoQKDFNb9Z8y!R7r+qvzj2O?=}~xWex2{X7*+FpR$za zDU_Sm4azt$)!Bxb#q8jMNPxQ+dhfNmXNDZIVWA?7a&G$g1oqimZ(z zGc0I)emvd*l#hDhLpYw%R|Hpc#Mo#;gT|M$(-Q{#?ZSMV_^Jq&7-q65@(y++#uzuX zvtG`Ru-+pwrf z>l-u80aKdr8@JDUs5+s1`$k}z8cJ$&wIG}a7K5t^@SOSkAQ;#1rNC#t)lqvFb+83d z7Rol?)IVf0>=1$3=u9Q=Dnu~Liqc|qdoEWECcsmn=C`T=3mekUa|aAg4af8iSH?1Vb`Ip z0j)z-mkrfX11To(>axp81wW`2@NNvR4V&^vR&RvJqh#RvHo#9^zmU=ngH>_teb$I;Y<;wi{WNE>BCgGq zfFJ*49NdT`wmEhw1mj~|BNQm@?~Bz@)kNYHw{5bw&4xxP=hz3C@L&1PeA`Q2R(&0n zy1No3Va+=xdpkFr{fbD{a-L8Rp&R-Q7dw)J^7z*~}{ZG*qWFq%` zpF(%G>~-pQYlhqM+af3+`csJtYNcCTHj~Q&;U;%S>T8>R<0Q8(*T|X3?w3>7#oP0q z;#0Z;t76~fDoa)u zGI%~4Zos(BCaU^WE950GwC1%S_6A`=(>MEbt9+!)Dw=)6t{59=L@ZU>B$KwV2awI@ z_`PWrSsS+I$zmYX@5ysF$XnCbp=0mHxa*h|$)lO5qdjjxs`8-@2Cjcm{_Ov-V_HFL zcZj;BNBw<1E4w5?VN_c!jP*4eqlK&(-bwVdGAJyt-ikW5z>w2ls#a7ilR!Vf$K>x3 z1gDXZT>k0GCDLAD%|8@YqQl|UUNy%g7vbGw5f7)`A z!s6EbozRVjx&g%U7>Vx0>deNc)UEQz1wr_4v$&3)>MmGRZ}yRbHw;YTnwQX*xMA{;T`Vx z_83M!dumHt6J367k~?%>@zJEUy1K#y`o&0TZ&1mRUM-NXULPwnC2iajXm1aA{8+EC zxaGzJ?IKQrhLmDo$xtT1{*m3|N7u@V4Kl;e85dwvVZb`w)T|fSoV~@9=?Ph_3tjPm zWh7k~_;d4mNLHN#&9X@o>)@iJ5b3Qf1r_VtL>4bU8Yn3W$l$ewq5QjMn#h>jK}6|3~D3nMgw4}e)wRJ z^|Rd+1SsD@!=L-fF@2y+K7Bfs1UVEK=b}A@(?YUqtz645qI8WpwnG5s`qWWVPs?)Y zAp61k=5gf{BuB@V!0v25dG}VLev0doCpvQE%NSKV8uTbY4FgVeRqr(uD*3HDWUq`g#aZnthgO)Tt(HgE*lb*Y(@G$# z!CbIoZ|Z4#u`biMK=38Lj0Tc-l%@D7*foSr%1J0ch_6*Wnn-G%aJNRG{fXMvait*5 ztIr4O#$xli0HaV1bH{v@hMOI-f88oy-avc2hc=!@tg0ciHJ%2^| z3DS*NzZ0GL_GmUX0u!t+qXolmchF+Z$lLbd>MUCuqf0ZA_z+Vq`$xKqNeCaGM@U{K zy7z|_&Brs%#3|GFreMN-Txw8Scj3z>`jc10Iow~DRpd@6J-BXTHHA1O|JbPD!f6og z$mvs357*+RJe_BuEZde@_!QkQ6bv`2_jiqo5FmQ{N>o2z*U2Dms9jR2XmM}*q@%=1 zb4EU&`SkG(o1mAKr4e^rqT(3t5GktFec1!x{Ml=6DusADB7{_X+4b$6s?GE{&OI&X zc3ex~(7GEl>1~)F&e$e1Ua9n3WvdRf>h)vy@aPEBw?%A|nZ{x{CljZ6kMZOiU64|V zBG=fG2vJR&Yc2I>^ZvkZv|n7wzqXHv3q@!5iAw2$l^Vhvd(+ z!eF3rP23xFv>y+Rirh=l;M9htyJso*#Di)g7L%W>2GxYEye@)wU?G{R4ezz zaJ@=DNc61@8NFKi?Q3oQ)oicLK6!{uZN!1B;1gwT*qTF~?>mOVhXydJ#A3^NT>!=f}Y5-@|DJXXO( z=@{*Y?(HKB$kE9LeZ80=<%;qou~ofIA5adM(-*EG)!}-bDv$k$Y@4l!TCFdyfWex} znSUqUDPqz6sM}c|JCRC){D(n<4VoC$h*HJ*G!@m$bI>AR+H0SSW-W_TXz$29ewQ!c zdxlL%#U$-f;oV&ZWvy5=HjPma`^-ctGUtq##jC0TS27aifhkpJP?Tx3qYBQ^39*18 z4J*|5I@nn`S*Q1);hR|-6wz7WHv^D=+qh?&yx%%eta<{FGeD#$6lo_mLV>Sz9W;gO;<>FtB{Nmp< zF@u0wcP0)-l9`%Ege_K?lFOZ5IJ$yS?Xej-AH94MyQL4AF7>L(UcZvI~aEzQxroDzR@7R=~o6Bqi16wOr6JFe(Co*D=4ooVVvR3g;2cp6v_j)br3vnQ~~xE zm)7;SyRNr&8T;a420ygh(zbGDO8*`vS|IoSi9f4T*+fOxs+)b<*2$2V)vGaeqtQJu zrl?c20MMUK_pw#WLT`&HOW|5zRoV;Iy>r(HZYXHCh3sjNSMU_HdPBz6vc(%PIj8e# zDKzh4_<~Mz-oRU#a-XH}Xlwq%rq>hO+8OZkiUq#oYYm%ERPF2#JB@ctwcK}N=es06 znk811y~{5gYRMKBgG0w`#!q43U^lU^@p;C=ktyn79-31`{`xcVbW0RbIEQPoSY@K! zG!Vy}l7ezsX6v@s8(WIK=hA{QKzlRkZxKovayk(P_Ik@hbRRMbd>@rx2%y$$8n=My z^YDat!aWZYtjl4L8luhz)JiLBEsqe+GbMCx%E*CFh~SIfCkLAP_>LuRHa-Ggkr; z+5l^-IhnImbYFI(+{9VdGu@@%23E!9Pwem@{BXOS2CQRUv#%}p-41#K_(^BII2A+?KPM2O*WxEoyBlWvvqmxE(MOHgl|yUT<}=|vTr+a^Yge^i&Z#_Y5ZTY@ zH<4D#iQc8hsH^c^I2@NIOxI9_e@etCian9bY-Hf(Ni$AJrBCmG8#Mi|V)S9K*)34q z>a4)KOS>xpxiQ7&*<*8)OmraVVUqXkw+0Q|L4c-#&FAvUcP7%;kA`6JbH9)yF6Q>e zKGdQO3~@NItyHW3a#7LY}T*AT6_zzb)>wqiml3K zgp!_n4|nPNDVv(crj;D3wKRB)$JsxO^ztfqoBmJ`7TIt%>KvpI;Rt7VM231g@~4}) zSFYOYTr#H4iHp7D1??yR8jJFI_Ha^uKG%P8eA3EW0d(y<|^Q9t<{uAf<-Rg&9d#)GdnN0-xmd zh{Lr?hSwo|1!Jd~#>YW2Ulp1a@vz=kogA5+!IG^5bmc9_Ks`rz!>VO$Ey887!4)0R znE(mhB71}InU@gUbaTrh4NJcqt90nogK^9LXD_@rjqr4GyoIl=fh!}=8&eM9bADXn zn3+af&B&-TWCjUQh_l+=l2EQjL{4B} zPBX29Fmbw*dWgcqW>mZAiCDw%nD(x&^N%1hYfjEPac9pf&A0xe8`Sgs;o<_f&i;e! z1mf?L>BkbgZ4WJ#T+f8c5eMX?f2F=;^|V>}clUulNkJoS51Lm=!vCe3!jZ2JfsI0@;8D&rzVc5 z>4c?Nck&A5sMW{V5`A)im>XR~y5_SAE1BlOY8k{NSATppTd}VqV_pZg{>9|OB{Sr* zI55omCbcwcvHz66|2^?7@AWHX(jTwrd?XI6F)Rs=n~+;!ovK0A@-zAmbNW3((W{l0 z@2z>#Z|XnTp#=fA6(IL5R)+N5fcJN`@(+ku{OPjz(h$P9UDAiLaRQH*AN0x7`KJ3} zu->Eacod!jaUWjAbs+$?AlN?|RWh6Uwf0MJgXx2!n+xtF19$zWM@eb$=AxP+D#-+nHUCq>G~~nKj=%pT+`5RGZ zD50+tkp|JPd$CtpgwJ z{&y`vO2znF{yCseCh`p%HN1wKxx#(tLpOCoJ*U6E#?#NX9bmhd`ahk_aqJS0w|LDn zeX16*sk-XMTG1!x7*?L4^`ubz@0pav!3Sh)_D)IgwOV;aWViRgQ^)vtRLx)=l;V~n zw)rv-mc+mzfA16(hkU2uXdTw-Vj3rn<>m#fWgOUfxtx2 zZ}CLU4rsiEn+(C~U(ZZ8H};*~ZD093%hs_-Uu4 zN*=voD=;6pm$@|}%cAi7A9=!pvqe^8BdHWh=-nwF?aWN{!T9+q%U!!`(nvw>8bvehikiXYe0jiQE5j=@EiUs&arZ|y=kevx<+%HjYhzd}L-lxb zZiu#Jjss-QQj+X+v>Wjc$6pU;2n6p}@*El-F0CMCM$yj0)`ep%W_wwsmkN8wO>r)~DGGUO=_Ncsp!@`tT? zhkIX9sI4}0x;PnXy5iC@OTmxftRF_Kb_A>*Y;IJ|#vYSXc_2x!y!c5)-ry3{bp{4o zUNJYgx@Q(0_3N2`=uivxFFz>)Ru=*@8plLgJQO)u~P;6)s z&2{32n}-W7VIRX)kFmpU z=m|dE>Aj7SpgT5BX~PM|)#o!H1Y{#zd0ZbQmz;Epk!!ZehHNOz*E85w`BekSeZg>l z0K&^BY7U>ALCedD71e+Iac8)`-Xo#_d>xeaaiH;za?rpYkrYQ4}zo5E? zhK8Ghjc(!WApygH*J=W*i2I7_p^@h#HLI7Gn2Xlk)da)_Ir z^i=iqu32qC%g#K7q5U)~M-xe^(t@`Rqkwz-T2_ z3r;fAo(FH%P8otcGXf}WGMV{mO=XSXtPFKK@Iswr%Bn<(jR$5J*n}!8tf(stAkF>e zioWJIPqo#yitf?pFGDZjf~PYLal@PppzigVK)fa5L4^sDetG|LTqYnBtH*pxfx)ml zcJn*UaNbwdk9s)zp+lzDF;0V<+BZ55|GB&o4xdo!e=k90bOhK2uvqqUlTF@toLc{= zHxBNuVtO)2)%rszfDh8wPzK&e*R|J2tMr#+2#r{omI5#mEBf-*$y2#~B1w#Tzv<6V z8tGdmSOpc{M~A~WRJNum8p1uB4v4^JnGg#lW|9n1A2hgR4OlK!XYt(~$b0pwxA_RxGqNkxC334)lEd@Uh)UY7)F8h#Cg=xwru| zC_RI|C3sgngVokN=$1h41oD%(;9F7r^S_zGN}Yl{NwxY?=7tQftUxD(-i}q8 zw2ie>5J~kg$D+$#CwQyppBSrRu-m!Q6d$rLXsd@jSS*8Wk+`E6V+A(&lXoii)!KC| zCApjO{z{j)>D^rHMiwM_V9*!)!8q4*agWtt3gb<^*2jPx5Z*Tj7r-@Bxba#tuDgY1 zI@HH~5|_DFnQx=QY4`pw56_Ul5T3jz$+jKI_;HduEhBOQ*cQoG0u>(iOQRJ56X+Sw`*WRw~EMduOZZ^-@4f@=DBlpO_nlX9B z=e$Xj-wyYvV^)^5OcuNw+uu7DpW@cInzYeZomAHBHF@jkpm`!zdPg7d%Q5{I8<}wR zcgGh3fPz_Jte4Zf<`cI`??vUvT&RH_oF z4SkNu@+w4KkLZ(gEW8GXWg7dBQ1k2glC6r! z1g1^b`L@_&7cTZI4U|qdY?WV217_Rj%Lq%MTU^zXCzG>uTCVcOn=u1``G_7Erhg4I zcNH3j$?8o5Hb0w+a&pH^!CGqypu3H`%E)JH({spFlwRP3W`$lq$XOSYbk&8gfb_1 z6T8V)r8U@s^!j7-Bqp(}Wl>D7-QT%p7gE=CVG|}}=-#fL998AU+NBI$8HOPicls(k zFwE*aZAF3o5sv@1ooV1rufZJ0@<*8=+D=xiwBITUH=1paSm=n~_u@hj^?k8fRU)XWzMS1Bth&ui z#qJbdr+gE>;5Kc2dnkpcjhT#t=b4pFUTaHl%`AJHPD}JNwN$bio(7pVHq{EtB<=w) zzvY}w4}v8xWc`u$!b0I`i@rzTM{B%|ZdNv|x_M*v zG~BP>hD%LhO)4Jjbs4vUBva@sTnT5TICA%z=7cgM*@7OEks%%H+5g{C=UEJV=G7>~}IQD6& zmgbG=1a~Hb(7SG17j(QLT0%qb#JpiA?G81nl)+{`IZzkM`z4^M!OBWW4AxB89b0(`414SBsw?}e!9WkX{oC+bAybkA`CV@)U` zgL=;3M#T~cMI)7Y=7sMy?QT}1Q9Cd+Ahdf&2P5!Z%#Xgh0oNZNYe5!~agwA?jp!Q@ zAIRLNJ)t)=;q`pC&M2q$HA|QQ^2c>-=x}|Oe#_lK7Ot}2G-$A54bL>IedaJz78NJ5 zQ01iOJ)uj;q4g=3aL4s6;#Nk&?gc$&y`w5xS@t6}Y|wls}(*#sS>w`$>Gyss%t zieY&!HhunT9c;6;Z!<$Z+pYyveTT2H+NJW`h^-l4jVg)m?SY5uu=P__5RIYt30uvF ztE(NSAsAPG=jO1SC}j}}!fmHj-S){1e-2_@^pcfM)5gYc8C^cLxsK^700oQe+y@W` zg(f8Z)jL4MW>$=2;dY1muY^1Z*~M7)#ZAGyda%?{9pk0vq3G?Umxqm{b0a8%vm;_$ z65fabT;ujuNAord`3&oP?g|O6E<_&XsW&mX{<>4WXHWD#_VOC_vJGw?YGk`bUczzs`YzlP`#1!^@K zGkQ5x`rwOCg4=U*?cx|?_PVQnDA&{j1JfANS(eEEgYQQL(mW5d4GdtN046bE8#8}E(C`z#z;kTT6~h}*jG}LsO@JAu;I`R@VlQd^D`z0 zpUqAblJwv2e>BU}c4gC#|Nc$J0|3Q^6XXc{%PfzY37*+@+*~?+VmN&rdG05#oCM-^ z$0;O;`y8-%r5;U9UXpsX3|w;+ReLw|?kD+MU`rrN4H?h|3J&8DsjmOc4(9|7vD(Td z6$E?T=g(Y~0!C6d(16qEUxm=II%{iTSAe4nksuyG{x5a;3qS z`=nsa95n@}eK#-PyFhlLy*aFIFj^-PS=PF%;D9Y$=+royCpii+{RtAA{R9EupJJOm z{k5mIs38OJD0C^qcymA_R$SRf2wz`%N^P&^77pkB1Tz2oFK($9osdFUd+YYGw+D>|OS?8UyS22`D`!6^k8pr#AHsZ`>^qJy0 zHF%?+H233~W(^HG76WexuTzMe{s}EM7`HH86_n#~4JG{5Or5?5MmDnIhfNfmsdiwt z6hdg=gxdcPeLp#i08^00+)<{06Hg}l9*EqgQ}SC~y6Dz0-V=1R66sgm&^(&BcV|t= zQCsIrhYiJ&RU&?>uUjzGI?coGjY=Z%uJixJ(L-tw5Y~=>-!AZ+{|V3TQ!qOi*D+C+ zT4K&&m~e!x3$L8e8}Z%u)D6&rmxnpxO$&vdxvVxLmvNtJB#ILwwG16be^zC=QM=Wp zf6x8pr@$a98EEdRC6s;d)^g(-2^-XmJidMN!@XS@Bd%%T-J~Y&PBk!c^p22^tAG)} z#&|iftB{MmBRXWvsjOqu^_4~`IeR|bsNj|edIN?*j#3n6ztyPAdoy zrQ5I9>(2^hit{mS1_aRTQsQvQR@E&m?sf3kJxU+eyB-T#S}w10K=ude>pm9V|@ zKi1^=@1g$nsQdylrfByP!9QmK=>HdGj-4}BHx-23hD*y9)0spg2^8b$*^j{O5$^3zj9MG{2`eQlX^q0fkj+`nUS8}`@J*Z&KUtUN*h diff --git a/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png b/packages/shared-components/playwright/snapshots/room-banner--with-loads-of-content-linux.png new file mode 100644 index 0000000000000000000000000000000000000000..85372e48fa5e88fdd852495d83a38846e4453bec GIT binary patch literal 58061 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

epz*$4>aRbdA%KQM3 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 Tvs@R7mIBaJ)4lyh)h7Huwje>G literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/composer/Banner/Banner.module.css b/packages/shared-components/src/composer/Banner/Banner.module.css index 077212354e..7bb48a9cc2 100644 --- a/packages/shared-components/src/composer/Banner/Banner.module.css +++ b/packages/shared-components/src/composer/Banner/Banner.module.css @@ -27,8 +27,6 @@ padding: var(--cpd-space-4x); border-top: 1px solid var(--cpd-color-gray-400); - - white-space: nowrap; } .banner[data-type="success"] { @@ -90,4 +88,6 @@ flex-direction: row; gap: var(--cpd-space-1x); align-self: center; + + white-space: nowrap; } diff --git a/packages/shared-components/src/composer/Banner/Banner.stories.tsx b/packages/shared-components/src/composer/Banner/Banner.stories.tsx index 53d3941621..e1e3e110fb 100644 --- a/packages/shared-components/src/composer/Banner/Banner.stories.tsx +++ b/packages/shared-components/src/composer/Banner/Banner.stories.tsx @@ -11,7 +11,6 @@ import { type Meta, type StoryObj } from "@storybook/react-vite"; import { Button } from "@vector-im/compound-web"; import { Banner } from "./Banner"; -import { _t } from "../../utils/i18n"; const meta = { title: "room/Banner", @@ -46,17 +45,14 @@ export const WithAction: Story = { args: { children: (

- {_t( - "encryption|pinned_identity_changed", - { displayName: "Alice", userId: "@alice:example.org" }, - { - a: (sub) => {sub}, - b: (sub) => {sub}, - }, - )} + Alice's (@alice:example.com) identity was reset. Learn more

), - actions: , + actions: ( + + ), }, }; @@ -71,3 +67,19 @@ export const WithoutClose: Story = { onClose: undefined, }, }; + +export const WithLoadsOfContent: Story = { + args: { + type: "info", + children: ( +

+ Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed quis massa facilisis, venenatis risus + consectetur, sagittis libero. Aenean et scelerisque justo. Nunc luctus, mi sed facilisis suscipit, magna + ante pharetra sem, eu rutrum purus quam quis arcu. Sed eleifend arcu vitae magna sodales, sit amet + fermentum urna dictum. Mauris vel velit pulvinar enim mollis tincidunt. Vivamus egestas rhoncus + sagittis. Curabitur auctor vehicula massa, et cursus lacus laoreet a. Maecenas et sollicitudin lectus, + in ligula. +

+ ), + }, +}; diff --git a/packages/shared-components/src/composer/Banner/Banner.tsx b/packages/shared-components/src/composer/Banner/Banner.tsx index 392b2a2610..7781442ed9 100644 --- a/packages/shared-components/src/composer/Banner/Banner.tsx +++ b/packages/shared-components/src/composer/Banner/Banner.tsx @@ -79,7 +79,7 @@ export function Banner({ return (
{avatar ?? icon}
- {children} +
{children}
{actions} {onClose && ( diff --git a/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap index e790e79831..ebb8df0a3d 100644 --- a/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap +++ b/packages/shared-components/src/composer/Banner/__snapshots__/Banner.test.tsx.snap @@ -26,24 +26,33 @@ exports[`AvatarWithDetails renders a banner with an action 1`] = ` />
-

- encryption|pinned_identity_changed + Alice's ( + + @alice:example.com + + ) identity was reset. + + Learn more +

-
+
-

Hello! This is a status banner.

-
+
@@ -118,13 +127,13 @@ exports[`AvatarWithDetails renders a critical banner 1`] = ` />
-

Hello! This is a status banner.

-
+
@@ -168,13 +177,13 @@ exports[`AvatarWithDetails renders a default banner 1`] = ` />
-

Hello! This is a status banner.

-
+
@@ -219,13 +228,13 @@ exports[`AvatarWithDetails renders a info banner 1`] = ` />
-

Hello! This is a status banner.

-
+
@@ -265,13 +274,13 @@ exports[`AvatarWithDetails renders a success banner 1`] = ` />
-

Hello! This is a status banner.

-
+
diff --git a/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap index ca4674cb58..1ee67f9a37 100644 --- a/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap +++ b/packages/shared-components/src/composer/HistoryVisibleBannerView/__snapshots__/HistoryVisibleBannerView.test.tsx.snap @@ -27,7 +27,7 @@ exports[`HistoryVisibleBannerView renders a history visible banner 1`] = ` />
- @@ -43,7 +43,7 @@ exports[`HistoryVisibleBannerView renders a history visible banner 1`] = ` Learn More - +
From ce9c66ba4c25f3de5ceca5d244591d8aa1183ce8 Mon Sep 17 00:00:00 2001 From: Skye Elliot Date: Fri, 19 Dec 2025 15:41:09 +0000 Subject: [PATCH 2/3] Update algorithm for history visible banner. (#31577) * feat: Update algorithm for history visible banner. - The banner now only shows for rooms with `shared` or `worldReadable` history visibility. - The banner does not show in rooms in which the current user cannot send messages. * tests: Add `getHistoryVisibility` to stub room. * docs: Add description to `visible` condition check. * docs: Fix spelling. Co-authored-by: Florian Duros * chore: Remove `jest-sonar.xml`. --------- Co-authored-by: Florian Duros --- .../views/composer/HistoryVisibleBanner.tsx | 3 + .../views/rooms/MessageComposer.tsx | 6 +- .../HistoryVisibleBannerViewModel.tsx | 40 +++++++++---- test/test-utils/test-utils.ts | 1 + .../HistoryVisibleBannerViewModel-test.tsx | 58 +++++++++++++++++-- 5 files changed, 91 insertions(+), 17 deletions(-) diff --git a/src/components/views/composer/HistoryVisibleBanner.tsx b/src/components/views/composer/HistoryVisibleBanner.tsx index 09286ae8e1..85a6bd7fb9 100644 --- a/src/components/views/composer/HistoryVisibleBanner.tsx +++ b/src/components/views/composer/HistoryVisibleBanner.tsx @@ -16,6 +16,9 @@ export const HistoryVisibleBanner: React.FC<{ /** The room instance associated with this banner view model. */ room: Room; + /** Whether the current user can send messages in the room. */ + canSendMessages: boolean; + /** * If not null, specifies the ID of the thread currently being viewed in the thread timeline side view, * where the banner view is displayed as a child of the message composer. diff --git a/src/components/views/rooms/MessageComposer.tsx b/src/components/views/rooms/MessageComposer.tsx index b2546b02a3..c23bc7917d 100644 --- a/src/components/views/rooms/MessageComposer.tsx +++ b/src/components/views/rooms/MessageComposer.tsx @@ -675,7 +675,11 @@ export class MessageComposer extends React.Component { return (
- +
{ + private static readonly computeSnapshot = ({ + room, + canSendMessages, + threadId, + }: Props): HistoryVisibleBannerViewSnapshot => { const featureEnabled = SettingsStore.getValue("feature_share_history_on_invite"); const acknowledged = SettingsStore.getValue("acknowledgedHistoryVisibility", room.roomId); + const isHistoryVisible = BANNER_VISIBLE_LEVELS.includes(room.getHistoryVisibility()); + // This implements point 1. of the algorithm described above. In the order below, all + // of the following must be true for the banner to display: + // - The room history sharing feature must be enabled. + // - The room must be encrypted. + // - The user must be able to send messages. + // - The history must be visible. + // - The view should not be part of a thread timeline. + // - The user must not have acknowledged the banner. return { visible: featureEnabled && - !threadId && room.hasEncryptionStateEvent() && - room.getHistoryVisibility() !== HistoryVisibility.Joined && + canSendMessages && + isHistoryVisible && + !threadId && !acknowledged, }; }; @@ -92,7 +112,7 @@ export class HistoryVisibleBannerViewModel * @param props - Properties for this view model. See {@link Props}. */ public constructor(props: Props) { - super(props, HistoryVisibleBannerViewModel.computeSnapshot(props.room, props.threadId)); + super(props, HistoryVisibleBannerViewModel.computeSnapshot(props)); this.disposables.trackListener(props.room, RoomStateEvent.Update, () => this.setSnapshot()); @@ -126,7 +146,7 @@ export class HistoryVisibleBannerViewModel ); } - this.snapshot.set(HistoryVisibleBannerViewModel.computeSnapshot(this.props.room, this.props.threadId)); + this.snapshot.set(HistoryVisibleBannerViewModel.computeSnapshot(this.props)); } /** diff --git a/test/test-utils/test-utils.ts b/test/test-utils/test-utils.ts index 525f2e4ed8..9757905882 100644 --- a/test/test-utils/test-utils.ts +++ b/test/test-utils/test-utils.ts @@ -677,6 +677,7 @@ export function mkStubRoom( getCanonicalAlias: jest.fn(), getDMInviter: jest.fn(), getEventReadUpTo: jest.fn(() => null), + getHistoryVisibility: jest.fn().mockReturnValue(HistoryVisibility.Joined), getInvitedAndJoinedMemberCount: jest.fn().mockReturnValue(1), getJoinRule: jest.fn().mockReturnValue("invite"), getJoinedMemberCount: jest.fn().mockReturnValue(1), diff --git a/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx b/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx index 764376aba3..b1ab6ef7ff 100644 --- a/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx +++ b/test/unit-tests/components/viewmodels/composer/HistoryVisibleBannerViewModel-test.tsx @@ -54,7 +54,7 @@ describe("HistoryVisibleBannerViewModel", () => { }); it("should not show the banner in unencrypted rooms", () => { - const vm = new HistoryVisibleBannerViewModel({ room, threadId: null }); + const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null }); expect(vm.getSnapshot().visible).toBe(false); }); @@ -76,7 +76,7 @@ describe("HistoryVisibleBannerViewModel", () => { }), ]); - const vm = new HistoryVisibleBannerViewModel({ room, threadId: null }); + const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null }); expect(vm.getSnapshot().visible).toBe(false); }); @@ -99,7 +99,7 @@ describe("HistoryVisibleBannerViewModel", () => { }), ]); - const vm = new HistoryVisibleBannerViewModel({ room, threadId: null }); + const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null }); expect(vm.getSnapshot().visible).toBe(false); vm.dispose(); }); @@ -122,12 +122,12 @@ describe("HistoryVisibleBannerViewModel", () => { }), ]); - const vm = new HistoryVisibleBannerViewModel({ room, threadId: "some thread ID" }); + const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: "some thread ID" }); expect(vm.getSnapshot().visible).toBe(false); vm.dispose(); }); - it("should show the banner in encrypted rooms with non-joined history visibility", async () => { + it("should not show the banner if the user cannot send messages", () => { upsertRoomStateEvents(room, [ mkEvent({ event: true, @@ -145,7 +145,53 @@ describe("HistoryVisibleBannerViewModel", () => { }), ]); - const vm = new HistoryVisibleBannerViewModel({ room, threadId: null }); + const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: false, threadId: null }); + expect(vm.getSnapshot().visible).toBe(false); + vm.dispose(); + }); + + it("should not show the banner if history visibility is `invited`", () => { + upsertRoomStateEvents(room, [ + mkEvent({ + event: true, + type: "m.room.encryption", + user: "@user1:server", + content: {}, + }), + mkEvent({ + event: true, + type: "m.room.history_visibility", + user: "@user1:server", + content: { + history_visibility: "invited", + }, + }), + ]); + + const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null }); + expect(vm.getSnapshot().visible).toBe(false); + vm.dispose(); + }); + + it("should show the banner in encrypted rooms with shared history visibility", async () => { + upsertRoomStateEvents(room, [ + mkEvent({ + event: true, + type: "m.room.encryption", + user: "@user1:server", + content: {}, + }), + mkEvent({ + event: true, + type: "m.room.history_visibility", + user: "@user1:server", + content: { + history_visibility: "shared", + }, + }), + ]); + + const vm = new HistoryVisibleBannerViewModel({ room, canSendMessages: true, threadId: null }); expect(vm.getSnapshot().visible).toBe(true); await vm.onClose(); expect(vm.getSnapshot().visible).toBe(false); From ebd5df633ecedc4eef496d6df35fd180a4a8de46 Mon Sep 17 00:00:00 2001 From: Hubert Chathi Date: Fri, 19 Dec 2025 12:00:50 -0500 Subject: [PATCH 3/3] Handle cross-signing keys missing locally and/or from secret storage (#31367) * show correct toast when cross-signing keys missing If cross-signing keys are missing both locally and in 4S, show a new toast saying that identity needs resetting, rather than saying that the device needs to be verified. * refactor: make DeviceListener in charge of device state - move enum from SetupEncryptionToast to DeviceListener - DeviceListener has public method to get device state - DeviceListener emits events to update device state * reset key backup when needed in RecoveryPanelOutOfSync brings RecoveryPanelOutOfSync in line with SetupEncryptionToast behaviour * update strings to agree with designs from Figma * use DeviceListener to determine EncryptionUserSettingsTab display rather than using its own logic * prompt to reset identity in Encryption Settings when needed * fix type * calculate device state even if we aren't going to show a toast * update snapshot * make logs more accurate * add tests * make the bot use a different access token/device * only log in a new session when requested * Mark properties as read-only Co-authored-by: Skye Elliot * remove some duplicate strings * make accessToken optional instead of using empty string * switch from enum to string union as per review * apply other changes from review * handle errors in accessSecretStorage * remove incorrect testid --------- Co-authored-by: Skye Elliot --- .../encryption-tab.spec.ts | 4 +- .../encryption-user-tab/recovery.spec.ts | 4 +- playwright/pages/bot.ts | 43 +++- playwright/pages/client.ts | 6 +- src/DeviceListener.ts | 154 ++++++++++---- .../encryption/RecoveryPanelOutOfSync.tsx | 51 ++++- .../tabs/user/EncryptionUserSettingsTab.tsx | 201 ++++++++---------- src/i18n/strings/en_EN.json | 2 + src/toasts/SetupEncryptionToast.tsx | 161 +++++++------- test/unit-tests/DeviceListener-test.ts | 78 +++---- .../RecoveryPanelOutOfSync-test.tsx | 98 ++++++++- .../user/EncryptionUserSettingsTab-test.tsx | 55 +++-- .../EncryptionUserSettingsTab-test.tsx.snap | 58 +++++ .../toasts/SetupEncryptionToast-test.tsx | 96 +++++++-- 14 files changed, 668 insertions(+), 343 deletions(-) diff --git a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts index 291be4442a..c944e92307 100644 --- a/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/encryption-tab.spec.ts @@ -25,7 +25,9 @@ test.describe("Encryption tab", () => { test.beforeEach(async ({ page, homeserver, credentials }) => { // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key - const res = await createBot(page, homeserver, credentials); + const botCredentials = { ...credentials }; + delete botCredentials.accessToken; // use a new login for the bot + const res = await createBot(page, homeserver, botCredentials); recoveryKey = res.recoveryKey; expectedBackupVersion = res.expectedBackupVersion; }); diff --git a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts index 8895e4a7ee..db558a43da 100644 --- a/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts +++ b/playwright/e2e/settings/encryption-user-tab/recovery.spec.ts @@ -17,7 +17,9 @@ test.describe("Recovery section in Encryption tab", () => { let recoveryKey: GeneratedSecretStorageKey; test.beforeEach(async ({ page, homeserver, credentials }) => { // The bot bootstraps cross-signing, creates a key backup and sets up a recovery key - const res = await createBot(page, homeserver, credentials); + const botCredentials = { ...credentials }; + delete botCredentials.accessToken; // use a new login for the bot + const res = await createBot(page, homeserver, botCredentials); recoveryKey = res.recoveryKey; }); diff --git a/playwright/pages/bot.ts b/playwright/pages/bot.ts index 05a8948a65..c3168a89ac 100644 --- a/playwright/pages/bot.ts +++ b/playwright/pages/bot.ts @@ -16,6 +16,10 @@ import type { Credentials, HomeserverInstance } from "../plugins/homeserver"; import type { GeneratedSecretStorageKey } from "matrix-js-sdk/src/crypto-api"; import { bootstrapCrossSigningForClient, Client } from "./client"; +export interface CredentialsOptionalAccessToken extends Omit { + accessToken?: string; +} + export interface CreateBotOpts { /** * A prefix to use for the userid. If unspecified, "bot_" will be used. @@ -58,7 +62,7 @@ const defaultCreateBotOptions = { type ExtendedMatrixClient = MatrixClient & { __playwright_recovery_key: GeneratedSecretStorageKey }; export class Bot extends Client { - public credentials?: Credentials; + public credentials?: CredentialsOptionalAccessToken; private handlePromise: Promise>; constructor( @@ -70,7 +74,16 @@ export class Bot extends Client { this.opts = Object.assign({}, defaultCreateBotOptions, opts); } - public setCredentials(credentials: Credentials): void { + /** + * Set the credentials used by the bot. + * + * If `credentials.accessToken` is unset, then `buildClient` will log in a + * new session. Note that `getCredentials` will return the credentials + * passed to this function, rather than the updated credentials from the new + * login. In particular, the `accessToken` and `deviceId` will not be + * updated. + */ + public setCredentials(credentials: CredentialsOptionalAccessToken): void { if (this.credentials) throw new Error("Bot has already started"); this.credentials = credentials; } @@ -80,7 +93,7 @@ export class Bot extends Client { return client.evaluate((cli) => cli.__playwright_recovery_key); } - private async getCredentials(): Promise { + private async getCredentials(): Promise { if (this.credentials) return this.credentials; // We want to pad the uniqueId but not the prefix const username = @@ -161,6 +174,30 @@ export class Bot extends Client { getSecretStorageKey, }; + if (!("accessToken" in credentials)) { + const loginCli = new window.matrixcs.MatrixClient({ + baseUrl, + store: new window.matrixcs.MemoryStore(), + scheduler: new window.matrixcs.MatrixScheduler(), + cryptoStore: new window.matrixcs.MemoryCryptoStore(), + cryptoCallbacks, + logger, + }); + + const loginResponse = await loginCli.loginRequest({ + type: "m.login.password", + identifier: { + type: "m.id.user", + user: credentials.userId, + }, + password: credentials.password, + }); + + credentials.accessToken = loginResponse.access_token; + credentials.userId = loginResponse.user_id; + credentials.deviceId = loginResponse.device_id; + } + const cli = new window.matrixcs.MatrixClient({ baseUrl, userId: credentials.userId, diff --git a/playwright/pages/client.ts b/playwright/pages/client.ts index 86cb581397..76f2733820 100644 --- a/playwright/pages/client.ts +++ b/playwright/pages/client.ts @@ -28,7 +28,7 @@ import type { EmptyObject, } from "matrix-js-sdk/src/matrix"; import type { RoomMessageEventContent } from "matrix-js-sdk/src/types"; -import { type Credentials } from "../plugins/homeserver"; +import { type CredentialsOptionalAccessToken } from "./bot"; export class Client { public network: Network; @@ -424,7 +424,7 @@ export class Client { /** * Bootstraps cross-signing. */ - public async bootstrapCrossSigning(credentials: Credentials): Promise { + public async bootstrapCrossSigning(credentials: CredentialsOptionalAccessToken): Promise { const client = await this.prepareClient(); return bootstrapCrossSigningForClient(client, credentials); } @@ -522,7 +522,7 @@ export class Client { */ export function bootstrapCrossSigningForClient( client: JSHandle, - credentials: Credentials, + credentials: CredentialsOptionalAccessToken, resetKeys: boolean = false, ) { return client.evaluate( diff --git a/src/DeviceListener.ts b/src/DeviceListener.ts index 3591db8d82..f9904eaef1 100644 --- a/src/DeviceListener.ts +++ b/src/DeviceListener.ts @@ -15,6 +15,7 @@ import { RoomStateEvent, type SyncState, ClientStoppedError, + TypedEventEmitter, } from "matrix-js-sdk/src/matrix"; import { logger as baseLogger, type BaseLogger, LogSpan } from "matrix-js-sdk/src/logger"; import { CryptoEvent, type KeyBackupInfo } from "matrix-js-sdk/src/crypto-api"; @@ -29,7 +30,6 @@ import { } from "./toasts/BulkUnverifiedSessionsToast"; import { hideToast as hideSetupEncryptionToast, - Kind as SetupKind, showToast as showSetupEncryptionToast, } from "./toasts/SetupEncryptionToast"; import { @@ -65,7 +65,47 @@ export const RECOVERY_ACCOUNT_DATA_KEY = "io.element.recovery"; const logger = baseLogger.getChild("DeviceListener:"); -export default class DeviceListener { +/** + * The state of the device and the user's account. + */ +export type DeviceState = + /** + * The device is in a good state. + */ + | "ok" + /** + * The user needs to set up recovery. + */ + | "set_up_recovery" + /** + * The device is not verified. + */ + | "verify_this_session" + /** + * Key storage is out of sync (keys are missing locally, from recovery, or both). + */ + | "key_storage_out_of_sync" + /** + * Key storage is not enabled, and has not been marked as purposely disabled. + */ + | "turn_on_key_storage" + /** + * The user's identity needs resetting, due to missing keys. + */ + | "identity_needs_reset"; + +/** + * The events emitted by {@link DeviceListener} + */ +export enum DeviceListenerEvents { + DeviceState = "device_state", +} + +type EventHandlerMap = { + [DeviceListenerEvents.DeviceState]: (state: DeviceState) => void; +}; + +export default class DeviceListener extends TypedEventEmitter { private dispatcherRef?: string; // device IDs for which the user has dismissed the verify toast ('Later') private dismissed = new Set(); @@ -87,6 +127,7 @@ export default class DeviceListener { private shouldRecordClientInformation = false; private enableBulkUnverifiedSessionsReminder = true; private deviceClientInformationSettingWatcherRef: string | undefined; + private deviceState: DeviceState = "ok"; // Remember the current analytics state to avoid sending the same event multiple times. private analyticsVerificationState?: string; @@ -198,8 +239,8 @@ export default class DeviceListener { } /** - * If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck} - * requires a reset of cross-signing keys. + * If the device is in a `key_storage_out_of_sync` state, check if + * it requires a reset of cross-signing keys. * * We will reset cross-signing keys if both our local cache and 4S don't * have all cross-signing keys. @@ -227,16 +268,15 @@ export default class DeviceListener { } /** - * If a `Kind.KEY_STORAGE_OUT_OF_SYNC` condition from {@link doRecheck} - * requires a reset of key backup. + * If the device is in a `"key_storage_out_of_sync"` state, check if + * it requires a reset of key backup. * * If the user has their recovery key, we need to reset backup if: * - the user hasn't disabled backup, * - we don't have the backup key cached locally, *and* * - we don't have the backup key stored in 4S. - * (The user should already have a key backup created at this point, - * otherwise `doRecheck` would have triggered a `Kind.TURN_ON_KEY_STORAGE` - * condition.) + * (The user should already have a key backup created at this point, the + * device state would be `turn_on_key_storage`.) * * If the user has forgotten their recovery key, we need to reset backup if: * - the user hasn't disabled backup, and @@ -425,88 +465,93 @@ export default class DeviceListener { const recoveryIsOk = secretStorageStatus.ready || recoveryDisabled; - const isCurrentDeviceTrusted = - crossSigningReady && - Boolean( - (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, - ); + const isCurrentDeviceTrusted = Boolean( + (await crypto.getDeviceVerificationStatus(cli.getSafeUserId(), cli.deviceId!))?.crossSigningVerified, + ); const keyBackupUploadActive = await this.isKeyBackupUploadActive(logSpan); const backupDisabled = await this.recheckBackupDisabled(cli); // We warn if key backup upload is turned off and we have not explicitly // said we are OK with that. - const keyBackupIsOk = keyBackupUploadActive || backupDisabled; + const keyBackupUploadIsOk = keyBackupUploadActive || backupDisabled; - // If key backup is active and not disabled: do we have the backup key - // cached locally? - const backupKeyCached = + // We warn if key backup is set up, but we don't have the decryption + // key, so can't fetch keys from backup. + const keyBackupDownloadIsOk = !keyBackupUploadActive || backupDisabled || (await crypto.getSessionBackupPrivateKey()) !== null; const allSystemsReady = - isCurrentDeviceTrusted && allCrossSigningSecretsCached && keyBackupIsOk && recoveryIsOk && backupKeyCached; + isCurrentDeviceTrusted && + allCrossSigningSecretsCached && + keyBackupUploadIsOk && + recoveryIsOk && + keyBackupDownloadIsOk; await this.reportCryptoSessionStateToAnalytics(cli); - if (this.dismissedThisDeviceToast || allSystemsReady) { + if (allSystemsReady) { logSpan.info("No toast needed"); - hideSetupEncryptionToast(); + await this.setDeviceState("ok", logSpan); this.checkKeyBackupStatus(); - } else if (await this.shouldShowSetupEncryptionToast()) { + } else { // make sure our keys are finished downloading await crypto.getUserDeviceInfo([cli.getSafeUserId()]); if (!isCurrentDeviceTrusted) { // the current device is not trusted: prompt the user to verify - logSpan.info("Current device not verified: showing VERIFY_THIS_SESSION toast"); - showSetupEncryptionToast(SetupKind.VERIFY_THIS_SESSION); + logSpan.info("Current device not verified: setting state to VERIFY_THIS_SESSION"); + await this.setDeviceState("verify_this_session", logSpan); } else if (!allCrossSigningSecretsCached) { // cross signing ready & device trusted, but we are missing secrets from our local cache. // prompt the user to enter their recovery key. logSpan.info( - "Some secrets not cached: showing KEY_STORAGE_OUT_OF_SYNC toast", + "Some secrets not cached: setting state to KEY_STORAGE_OUT_OF_SYNC", crossSigningStatus.privateKeysCachedLocally, + crossSigningStatus.privateKeysInSecretStorage, ); - showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); - } else if (!keyBackupIsOk) { - logSpan.info("Key backup upload is unexpectedly turned off: showing TURN_ON_KEY_STORAGE toast"); - showSetupEncryptionToast(SetupKind.TURN_ON_KEY_STORAGE); + await this.setDeviceState( + crossSigningStatus.privateKeysInSecretStorage ? "key_storage_out_of_sync" : "identity_needs_reset", + logSpan, + ); + } else if (!keyBackupUploadIsOk) { + logSpan.info("Key backup upload is unexpectedly turned off: setting state to TURN_ON_KEY_STORAGE"); + await this.setDeviceState("turn_on_key_storage", logSpan); } else if (secretStorageStatus.defaultKeyId === null) { // The user just hasn't set up 4S yet: if they have key // backup, prompt them to turn on recovery too. (If not, they // have explicitly opted out, so don't hassle them.) if (recoveryDisabled) { logSpan.info("Recovery disabled: no toast needed"); - hideSetupEncryptionToast(); + await this.setDeviceState("ok", logSpan); } else if (keyBackupUploadActive) { - logSpan.info("No default 4S key: showing SET_UP_RECOVERY toast"); - showSetupEncryptionToast(SetupKind.SET_UP_RECOVERY); + logSpan.info("No default 4S key: setting state to SET_UP_RECOVERY"); + await this.setDeviceState("set_up_recovery", logSpan); } else { logSpan.info("No default 4S key but backup disabled: no toast needed"); - hideSetupEncryptionToast(); + await this.setDeviceState("ok", logSpan); } } else { // If we get here, then we are verified, have key backup, and // 4S, but allSystemsReady is false, which means that either // secretStorageStatus.ready is false (which means that 4S // doesn't have all the secrets), or we don't have the backup - // key cached locally. + // key cached locally. If any of the cross-signing keys are + // missing locally, that is handled by the + // `!allCrossSigningSecretsCached` branch above. logSpan.warn("4S is missing secrets or backup key not cached", { crossSigningReady, secretStorageStatus, allCrossSigningSecretsCached, isCurrentDeviceTrusted, - backupKeyCached, + keyBackupDownloadIsOk, }); - // We use the right toast variant based on whether the backup - // key is missing locally. If any of the cross-signing keys are - // missing locally, that is handled by the - // `!allCrossSigningSecretsCached` branch above. - showSetupEncryptionToast(SetupKind.KEY_STORAGE_OUT_OF_SYNC); + await this.setDeviceState("key_storage_out_of_sync", logSpan); + } + if (this.dismissedThisDeviceToast) { + this.checkKeyBackupStatus(); } - } else { - logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); } // This needs to be done after awaiting on getUserDeviceInfo() above, so @@ -598,6 +643,31 @@ export default class DeviceListener { return recoveryStatus?.enabled === false; } + /** + * Get the state of the device and the user's account. The device/account + * state indicates what action the user must take in order to get a + * self-verified device that is using key backup and recovery. + */ + public getDeviceState(): DeviceState { + return this.deviceState; + } + + /** + * Set the state of the device, and perform any actions necessary in + * response to the state changing. + */ + private async setDeviceState(newState: DeviceState, logSpan: LogSpan): Promise { + this.deviceState = newState; + this.emit(DeviceListenerEvents.DeviceState, newState); + if (newState === "ok" || this.dismissedThisDeviceToast) { + hideSetupEncryptionToast(); + } else if (await this.shouldShowSetupEncryptionToast()) { + showSetupEncryptionToast(newState); + } else { + logSpan.info("Not yet ready, but shouldShowSetupEncryptionToast==false"); + } + } + /** * Reports current recovery state to analytics. * Checks if the session is verified and if the recovery is correctly set up (i.e all secrets known locally and in 4S). diff --git a/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx b/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx index a5d47100d6..16ab517d32 100644 --- a/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx +++ b/src/components/views/settings/encryption/RecoveryPanelOutOfSync.tsx @@ -12,13 +12,20 @@ import KeyIcon from "@vector-im/compound-design-tokens/assets/web/icons/key"; import { SettingsSection } from "../shared/SettingsSection"; import { _t } from "../../../../languageHandler"; import { SettingsSubheader } from "../SettingsSubheader"; -import { accessSecretStorage } from "../../../../SecurityManager"; +import { AccessCancelledError, accessSecretStorage } from "../../../../SecurityManager"; +import DeviceListener from "../../../../DeviceListener"; +import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext"; +import { resetKeyBackupAndWait } from "../../../../utils/crypto/resetKeyBackup"; interface RecoveryPanelOutOfSyncProps { /** * Callback for when the user has finished entering their recovery key. */ onFinish: () => void; + /** + * Callback for when accessing secret storage fails. + */ + onAccessSecretStorageFailed: () => void; /** * Callback for when the user clicks on the "Forgot recovery key?" button. */ @@ -32,7 +39,13 @@ interface RecoveryPanelOutOfSyncProps { * It prompts the user to enter their recovery key so that the secrets can be loaded from 4S into * the client. */ -export function RecoveryPanelOutOfSync({ onForgotRecoveryKey, onFinish }: RecoveryPanelOutOfSyncProps): JSX.Element { +export function RecoveryPanelOutOfSync({ + onForgotRecoveryKey, + onAccessSecretStorageFailed, + onFinish, +}: RecoveryPanelOutOfSyncProps): JSX.Element { + const matrixClient = useMatrixClientContext(); + return ( { - await accessSecretStorage(); + const crypto = matrixClient.getCrypto()!; + + const deviceListener = DeviceListener.sharedInstance(); + + // we need to call keyStorageOutOfSyncNeedsBackupReset here because + // deviceListener.whilePaused() sets its client to undefined, so + // keyStorageOutOfSyncNeedsBackupReset won't be able to check + // the backup state. + const needsBackupReset = await deviceListener.keyStorageOutOfSyncNeedsBackupReset(false); + + try { + // pause the device listener because we could be making lots + // of changes, and don't want toasts to pop up and disappear + // while we're doing it + await deviceListener.whilePaused(async () => { + await accessSecretStorage(async () => { + // Reset backup if needed. + if (needsBackupReset) { + await resetKeyBackupAndWait(crypto); + } else if (await matrixClient.isKeyBackupKeyStored()) { + await crypto.loadSessionBackupPrivateKeyFromSecretStorage(); + } + }); + }); + } catch (error) { + if (error instanceof AccessCancelledError) { + // The user cancelled the dialog - just allow it to + // close, and return to this panel + } else { + onAccessSecretStorageFailed(); + } + return; + } onFinish(); }} > diff --git a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx index dea28628fb..ddc594a0b0 100644 --- a/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx +++ b/src/components/views/settings/tabs/user/EncryptionUserSettingsTab.tsx @@ -5,15 +5,13 @@ * Please see LICENSE files in the repository root for full details. */ -import React, { type JSX, useCallback, useEffect, useState } from "react"; -import { Button, InlineSpinner, Separator } from "@vector-im/compound-web"; +import React, { type JSX, useState } from "react"; +import { Button, Separator } from "@vector-im/compound-web"; import ComputerIcon from "@vector-im/compound-design-tokens/assets/web/icons/computer"; -import { CryptoEvent } from "matrix-js-sdk/src/crypto-api"; import SettingsTab from "../SettingsTab"; import { RecoveryPanel } from "../../encryption/RecoveryPanel"; import { ChangeRecoveryKey } from "../../encryption/ChangeRecoveryKey"; -import { useMatrixClientContext } from "../../../../../contexts/MatrixClientContext"; import { _t } from "../../../../../languageHandler"; import Modal from "../../../../../Modal"; import SetupEncryptionDialog from "../../../dialogs/security/SetupEncryptionDialog"; @@ -23,17 +21,15 @@ import { AdvancedPanel } from "../../encryption/AdvancedPanel"; import { ResetIdentityPanel } from "../../encryption/ResetIdentityPanel"; import { type ResetIdentityBodyVariant } from "../../encryption/ResetIdentityBody"; import { RecoveryPanelOutOfSync } from "../../encryption/RecoveryPanelOutOfSync"; -import { useTypedEventEmitter } from "../../../../../hooks/useEventEmitter"; +import { useTypedEventEmitterState } from "../../../../../hooks/useEventEmitter"; import { KeyStoragePanel } from "../../encryption/KeyStoragePanel"; import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel"; +import DeviceListener, { DeviceListenerEvents, type DeviceState } from "../../../../../DeviceListener"; +import { useKeyStoragePanelViewModel } from "../../../../viewmodels/settings/encryption/KeyStoragePanelViewModel"; /** * The state in the encryption settings tab. - * - "loading": We are checking if the device is verified. * - "main": The main panel with all the sections (Key storage, recovery, advanced). - * - "key_storage_disabled": The user has chosen to disable key storage and options are unavailable as a result. - * - "set_up_encryption": The panel to show when the user is setting up their encryption. - * This happens when the user doesn't have cross-signing enabled, or their current device is not verified. * - "change_recovery_key": The panel to show when the user is changing their recovery key. * This happens when the user has a recovery key and the user clicks on "Change recovery key" button of the RecoveryPanel. * - "set_recovery_key": The panel to show when the user is setting up their recovery key. @@ -41,21 +37,17 @@ import { DeleteKeyStoragePanel } from "../../encryption/DeleteKeyStoragePanel"; * - "reset_identity_compromised": The panel to show when the user is resetting their identity, in the case where their key is compromised. * - "reset_identity_forgot": The panel to show when the user is resetting their identity, in the case where they forgot their recovery key. * - "reset_identity_sync_failed": The panel to show when the user us resetting their identity, in the case where recovery failed. - * - "secrets_not_cached": The secrets are not cached locally. This can happen if we verified another device and secret-gossiping failed, or the other device itself lacked the secrets. - * If the "set_up_encryption" and "secrets_not_cached" conditions are both filled, "set_up_encryption" prevails. + * - "reset_identity_cant_recover": The panel to show when the user is resetting their identity, in the case where they can't use recovery. * - "key_storage_delete": The confirmation page asking if the user really wants to turn off key storage. */ export type State = - | "loading" | "main" - | "key_storage_disabled" - | "set_up_encryption" | "change_recovery_key" | "set_recovery_key" | "reset_identity_compromised" | "reset_identity_forgot" | "reset_identity_sync_failed" - | "secrets_not_cached" + | "reset_identity_cant_recover" | "key_storage_delete"; interface Props { @@ -68,48 +60,69 @@ interface Props { /** * The encryption settings tab. */ -export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): JSX.Element { +export function EncryptionUserSettingsTab({ initialState = "main" }: Readonly): JSX.Element { const [state, setState] = useState(initialState); - const checkEncryptionState = useCheckEncryptionState(state, setState); + const deviceState = useTypedEventEmitterState( + DeviceListener.sharedInstance(), + DeviceListenerEvents.DeviceState, + (state?: DeviceState): DeviceState => { + return state ?? DeviceListener.sharedInstance().getDeviceState(); + }, + ); + + const { isEnabled: isBackupEnabled } = useKeyStoragePanelViewModel(); let content: JSX.Element; switch (state) { - case "loading": - content = ; - break; - case "set_up_encryption": - content = ; - break; - case "secrets_not_cached": - content = ( - setState("reset_identity_forgot")} - /> - ); - break; - case "key_storage_disabled": case "main": - content = ( - <> - setState("key_storage_delete")} /> - - {/* We only show the "Recovery" panel if key storage is enabled.*/} - {state === "main" && ( + switch (deviceState) { + // some device states require action from the user rather than showing the main settings screen + case "verify_this_session": + content = setState("main")} />; + break; + case "key_storage_out_of_sync": + content = ( + setState("main")} + onForgotRecoveryKey={() => setState("reset_identity_forgot")} + onAccessSecretStorageFailed={async () => { + const needsCrossSigningReset = + await DeviceListener.sharedInstance().keyStorageOutOfSyncNeedsCrossSigningReset( + true, + ); + setState(needsCrossSigningReset ? "reset_identity_sync_failed" : "change_recovery_key"); + }} + /> + ); + break; + case "identity_needs_reset": + content = ( + setState("reset_identity_cant_recover")} /> + ); + break; + default: + content = ( <> - - setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") - } - /> + setState("key_storage_delete")} /> + {/* We only show the "Recovery" panel if key storage is enabled.*/} + {isBackupEnabled && ( + <> + + setupNewKey ? setState("set_recovery_key") : setState("change_recovery_key") + } + /> + + + )} + setState("reset_identity_compromised")} /> - )} - setState("reset_identity_compromised")} /> - - ); + ); + break; + } break; case "change_recovery_key": case "set_recovery_key": @@ -124,16 +137,17 @@ export function EncryptionUserSettingsTab({ initialState = "loading" }: Props): case "reset_identity_compromised": case "reset_identity_forgot": case "reset_identity_sync_failed": + case "reset_identity_cant_recover": content = ( setState("main")} + onReset={() => setState("main")} /> ); break; case "key_storage_delete": - content = ; + content = setState("main")} />; break; } @@ -154,6 +168,8 @@ function findResetVariant(state: State): ResetIdentityBodyVariant { return "compromised"; case "reset_identity_sync_failed": return "sync_failed"; + case "reset_identity_cant_recover": + return "no_verification_method"; default: case "reset_identity_forgot": @@ -161,63 +177,6 @@ function findResetVariant(state: State): ResetIdentityBodyVariant { } } -/** - * Hook to check if the user needs: - * - to go through the SetupEncryption flow. - * - to enter their recovery key, if the secrets are not cached locally. - * ...and also whether megolm key backup is enabled on this device (which we use to set the state of the 'allow key storage' toggle) - * - * If cross signing is set up, key backup is enabled and the secrets are cached, the state will be set to "main". - * If cross signing is not set up, the state will be set to "set_up_encryption". - * If key backup is not enabled, the state will be set to "key_storage_disabled". - * If secrets are missing, the state will be set to "secrets_not_cached". - * - * The state is set once when the component is first mounted. - * Also returns a callback function which can be called to re-run the logic. - * - * @param setState - callback passed from the EncryptionUserSettingsTab to set the current `State`. - * @returns a callback function, which will re-run the logic and update the state. - */ -function useCheckEncryptionState(state: State, setState: (state: State) => void): () => Promise { - const matrixClient = useMatrixClientContext(); - - const checkEncryptionState = useCallback(async () => { - const crypto = matrixClient.getCrypto()!; - const isCrossSigningReady = await crypto.isCrossSigningReady(); - - // Check if the secrets are cached - const cachedSecrets = (await crypto.getCrossSigningStatus()).privateKeysCachedLocally; - const secretsOk = cachedSecrets.masterKey && cachedSecrets.selfSigningKey && cachedSecrets.userSigningKey; - - // Also check the key backup status - const activeBackupVersion = await crypto.getActiveSessionBackupVersion(); - - const keyStorageEnabled = activeBackupVersion !== null; - - if (isCrossSigningReady && keyStorageEnabled && secretsOk) setState("main"); - else if (!isCrossSigningReady) setState("set_up_encryption"); - else if (!keyStorageEnabled) setState("key_storage_disabled"); - else setState("secrets_not_cached"); - }, [matrixClient, setState]); - - // Initialise the state when the component is mounted - useEffect(() => { - if (state === "loading") checkEncryptionState(); - }, [checkEncryptionState, state]); - - useTypedEventEmitter(matrixClient, CryptoEvent.KeyBackupStatus, (): void => { - // Recheck the status if the key backup status has changed so we can keep the page up to date. - // Note that this could potentially update the UI while the user is trying to do something, although - // if their key backup status is changing then they're changing encryption related things - // on another device. This code is written with the assumption that it's better for the UI to refresh - // and be up to date with whatever changes they've made. - checkEncryptionState(); - }); - - // Also return the callback so that the component can re-run the logic. - return checkEncryptionState; -} - interface SetUpEncryptionPanelProps { /** * Callback to call when the user has finished setting up encryption. @@ -257,3 +216,31 @@ function SetUpEncryptionPanel({ onFinish }: SetUpEncryptionPanelProps): JSX.Elem ); } + +interface IdentityNeedsResetNoticePanelProps { + /** + * Callback to call when the user has finished setting up encryption. + */ + onContinue: () => void; +} + +/** + * Panel to tell the user that they need to reset their identity. + */ +function IdentityNeedsResetNoticePanel({ onContinue }: Readonly): JSX.Element { + return ( + + } + > +
+ +
+
+ ); +} diff --git a/src/i18n/strings/en_EN.json b/src/i18n/strings/en_EN.json index 86b095b965..d80ea12d03 100644 --- a/src/i18n/strings/en_EN.json +++ b/src/i18n/strings/en_EN.json @@ -959,6 +959,7 @@ "bootstrap_title": "Setting up keys", "confirm_encryption_setup_body": "Click the button below to confirm setting up encryption.", "confirm_encryption_setup_title": "Confirm encryption setup", + "continue_with_reset": "Continue with reset", "cross_signing_room_normal": "This room is end-to-end encrypted", "cross_signing_room_verified": "Everyone in this room is verified", "cross_signing_room_warning": "Someone is using an unknown session", @@ -974,6 +975,7 @@ "event_shield_reason_unverified_identity": "Encrypted by an unverified user.", "export_unsupported": "Your browser does not support the required cryptography extensions", "forgot_recovery_key": "Forgot recovery key?", + "identity_needs_reset_description": "You have to reset your cryptographic identity in order to ensure access to your message history", "import_invalid_keyfile": "Not a valid %(brand)s keyfile", "import_invalid_passphrase": "Authentication check failed: incorrect password?", "key_storage_out_of_sync": "Your key storage is out of sync.", diff --git a/src/toasts/SetupEncryptionToast.tsx b/src/toasts/SetupEncryptionToast.tsx index ddf9dd69a7..1b5f8a4a49 100644 --- a/src/toasts/SetupEncryptionToast.tsx +++ b/src/toasts/SetupEncryptionToast.tsx @@ -14,7 +14,7 @@ import { type Interaction as InteractionEvent } from "@matrix-org/analytics-even import Modal from "../Modal"; import { _t } from "../languageHandler"; -import DeviceListener from "../DeviceListener"; +import DeviceListener, { type DeviceState } from "../DeviceListener"; import SetupEncryptionDialog from "../components/views/dialogs/security/SetupEncryptionDialog"; import { AccessCancelledError, accessSecretStorage } from "../SecurityManager"; import ToastStore, { type IToast } from "../stores/ToastStore"; @@ -33,114 +33,107 @@ import { PosthogAnalytics } from "../PosthogAnalytics"; const TOAST_KEY = "setupencryption"; -const getTitle = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +/** + * The device states that we show a toast for (everything except for "ok"). + */ +type DeviceStateForToast = Exclude; + +const getTitle = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("encryption|set_up_recovery"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("encryption|verify_toast_title"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": + case "identity_needs_reset": return _t("encryption|key_storage_out_of_sync"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("encryption|turn_on_key_storage"); } }; -const getIcon = (kind: Kind): IToast["icon"] => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getIcon = (state: DeviceStateForToast): IToast["icon"] => { + switch (state) { + case "set_up_recovery": return undefined; - case Kind.VERIFY_THIS_SESSION: - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "verify_this_session": + case "key_storage_out_of_sync": + case "identity_needs_reset": return ; - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return ; } }; -const getSetupCaption = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getSetupCaption = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("action|continue"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("action|verify"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": return _t("encryption|enter_recovery_key"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("action|continue"); + case "identity_needs_reset": + return _t("encryption|continue_with_reset"); } }; /** * Get the icon to show on the primary button. - * @param kind + * @param state */ -const getPrimaryButtonIcon = (kind: Kind): ComponentType> | undefined => { - switch (kind) { - case Kind.KEY_STORAGE_OUT_OF_SYNC: +const getPrimaryButtonIcon = ( + state: DeviceStateForToast, +): ComponentType> | undefined => { + switch (state) { + case "key_storage_out_of_sync": return KeyIcon; default: return; } }; -const getSecondaryButtonLabel = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getSecondaryButtonLabel = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("action|dismiss"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("encryption|verification|unverified_sessions_toast_reject"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": return _t("encryption|forgot_recovery_key"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("action|dismiss"); + case "identity_needs_reset": + return ""; } }; -const getDescription = (kind: Kind): string => { - switch (kind) { - case Kind.SET_UP_RECOVERY: +const getDescription = (state: DeviceStateForToast): string => { + switch (state) { + case "set_up_recovery": return _t("encryption|set_up_recovery_toast_description"); - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": return _t("encryption|verify_toast_description"); - case Kind.KEY_STORAGE_OUT_OF_SYNC: + case "key_storage_out_of_sync": return _t("encryption|key_storage_out_of_sync_description"); - case Kind.TURN_ON_KEY_STORAGE: + case "turn_on_key_storage": return _t("encryption|turn_on_key_storage_description"); + case "identity_needs_reset": + return _t("encryption|identity_needs_reset_description"); } }; -/** - * The kind of toast to show. - */ -export enum Kind { - /** - * Prompt the user to set up a recovery key - */ - SET_UP_RECOVERY = "set_up_recovery", - /** - * Prompt the user to verify this session - */ - VERIFY_THIS_SESSION = "verify_this_session", - /** - * Prompt the user to enter their recovery key - */ - KEY_STORAGE_OUT_OF_SYNC = "key_storage_out_of_sync", - /** - * Prompt the user to turn on key storage - */ - TURN_ON_KEY_STORAGE = "turn_on_key_storage", -} - /** * Show a toast prompting the user for some action related to setting up their encryption. * - * @param kind The kind of toast to show + * @param state The state of the device */ -export const showToast = (kind: Kind): void => { +export const showToast = (state: DeviceStateForToast): void => { if ( ModuleRunner.instance.extensions.cryptoSetup.setupEncryptionNeeded({ - kind: kind as any, + kind: state as any, storeProvider: { getInstance: () => SetupEncryptionStore.sharedInstance() }, }) ) { @@ -148,13 +141,13 @@ export const showToast = (kind: Kind): void => { } const onPrimaryClick = async (): Promise => { - switch (kind) { - case Kind.SET_UP_RECOVERY: - case Kind.TURN_ON_KEY_STORAGE: { + switch (state) { + case "set_up_recovery": + case "turn_on_key_storage": { PosthogAnalytics.instance.trackEvent({ eventName: "Interaction", interactionType: "Pointer", - name: kind === Kind.SET_UP_RECOVERY ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick", + name: state === "set_up_recovery" ? "ToastSetUpRecoveryClick" : "ToastTurnOnKeyStorageClick", }); // Open the user settings dialog to the encryption tab const payload: OpenToTabPayload = { @@ -164,10 +157,10 @@ export const showToast = (kind: Kind): void => { defaultDispatcher.dispatch(payload); break; } - case Kind.VERIFY_THIS_SESSION: + case "verify_this_session": Modal.createDialog(SetupEncryptionDialog, {}, undefined, /* priority = */ false, /* static = */ true); break; - case Kind.KEY_STORAGE_OUT_OF_SYNC: { + case "key_storage_out_of_sync": { const modal = Modal.createDialog( Spinner, undefined, @@ -208,12 +201,24 @@ export const showToast = (kind: Kind): void => { } break; } + case "identity_needs_reset": { + // Open the user settings dialog to reset identity + const payload: OpenToTabPayload = { + action: Action.ViewUserSettings, + initialTabId: UserTab.Encryption, + props: { + initialEncryptionState: "reset_identity_cant_recover", + }, + }; + defaultDispatcher.dispatch(payload); + break; + } } }; const onSecondaryClick = async (): Promise => { - switch (kind) { - case Kind.SET_UP_RECOVERY: { + switch (state) { + case "set_up_recovery": { PosthogAnalytics.instance.trackEvent({ eventName: "Interaction", interactionType: "Pointer", @@ -225,7 +230,7 @@ export const showToast = (kind: Kind): void => { deviceListener.dismissEncryptionSetup(); break; } - case Kind.KEY_STORAGE_OUT_OF_SYNC: { + case "key_storage_out_of_sync": { // Open the user settings dialog to the encryption tab and start the flow to reset encryption or change the recovery key const deviceListener = DeviceListener.sharedInstance(); const needsCrossSigningReset = await deviceListener.keyStorageOutOfSyncNeedsCrossSigningReset(true); @@ -241,7 +246,7 @@ export const showToast = (kind: Kind): void => { defaultDispatcher.dispatch(payload); break; } - case Kind.TURN_ON_KEY_STORAGE: { + case "turn_on_key_storage": { PosthogAnalytics.instance.trackEvent({ eventName: "Interaction", interactionType: "Pointer", @@ -296,19 +301,19 @@ export const showToast = (kind: Kind): void => { ToastStore.sharedInstance().addOrReplaceToast({ key: TOAST_KEY, - title: getTitle(kind), - icon: getIcon(kind), + title: getTitle(state), + icon: getIcon(state), props: { - description: getDescription(kind), - primaryLabel: getSetupCaption(kind), - PrimaryIcon: getPrimaryButtonIcon(kind), + description: getDescription(state), + primaryLabel: getSetupCaption(state), + PrimaryIcon: getPrimaryButtonIcon(state), onPrimaryClick, - secondaryLabel: getSecondaryButtonLabel(kind), + secondaryLabel: getSecondaryButtonLabel(state), onSecondaryClick, - overrideWidth: kind === Kind.KEY_STORAGE_OUT_OF_SYNC ? "366px" : undefined, + overrideWidth: state === "key_storage_out_of_sync" ? "366px" : undefined, }, component: GenericToast, - priority: kind === Kind.VERIFY_THIS_SESSION ? 95 : 40, + priority: state === "verify_this_session" ? 95 : 40, }); }; diff --git a/test/unit-tests/DeviceListener-test.ts b/test/unit-tests/DeviceListener-test.ts index ef3b01b68c..153739dea6 100644 --- a/test/unit-tests/DeviceListener-test.ts +++ b/test/unit-tests/DeviceListener-test.ts @@ -341,9 +341,7 @@ describe("DeviceListener", () => { await createAndStart(); expect(mockCrypto!.getUserDeviceInfo).toHaveBeenCalled(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.VERIFY_THIS_SESSION, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("verify_this_session"); }); describe("when current device is verified", () => { @@ -380,9 +378,23 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); + }); + + it("shows an identity reset toast when one of the cross-signing secrets is missing locally and in 4S", async () => { + mockCrypto!.getCrossSigningStatus.mockResolvedValue({ + publicKeysOnDevice: true, + privateKeysInSecretStorage: false, + privateKeysCachedLocally: { + masterKey: false, + selfSigningKey: true, + userSigningKey: true, + }, + }); + + await createAndStart(); + + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("identity_needs_reset"); }); it("shows an out-of-sync toast when the backup key is missing locally", async () => { @@ -392,9 +404,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); }); it("does not show an out-of-sync toast when the backup key is missing locally but backup is purposely disabled", async () => { @@ -426,9 +436,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); // Then, when we receive the secret, it should be hidden. mockCrypto!.getCrossSigningStatus.mockResolvedValue({ @@ -454,9 +462,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("set_up_recovery"); }); it("shows an out-of-sync toast when one of the secrets is missing from 4S", async () => { @@ -470,9 +476,7 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.KEY_STORAGE_OUT_OF_SYNC, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("key_storage_out_of_sync"); }); }); }); @@ -573,9 +577,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is displayed - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage"); }); it("shows the 'Turn on key storage' toast if we turned on key storage", async () => { @@ -591,9 +593,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is displayed - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("turn_on_key_storage"); }); it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => { @@ -606,9 +606,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); }); @@ -626,9 +624,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); it("does not show the 'Turn on key storage' toast if we turned on key storage", async () => { @@ -643,9 +639,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); it("does not show the 'Turn on key storage' toast if we turned off key storage", async () => { @@ -661,9 +655,7 @@ describe("DeviceListener", () => { await createAndStart(); // Then the toast is not displayed - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.TURN_ON_KEY_STORAGE, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("turn_on_key_storage"); }); }); }); @@ -1206,25 +1198,21 @@ describe("DeviceListener", () => { await createAndStart(); - expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith(SetupEncryptionToast.Kind.SET_UP_RECOVERY); + expect(SetupEncryptionToast.showToast).toHaveBeenCalledWith("set_up_recovery"); }); it("does not show the 'set up recovery' toast if secret storage is set up", async () => { mockCrypto!.getSecretStorageStatus.mockResolvedValue(readySecretStorageStatus); await createAndStart(); - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery"); }); it("does not show the 'set up recovery' toast if user has no encrypted rooms", async () => { jest.spyOn(mockClient.getCrypto()!, "isEncryptionEnabledInRoom").mockResolvedValue(false); await createAndStart(); - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery"); }); it("does not show the 'set up recovery' toast if the user has chosen to disable key storage", async () => { @@ -1236,9 +1224,7 @@ describe("DeviceListener", () => { }); await createAndStart(); - expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith( - SetupEncryptionToast.Kind.SET_UP_RECOVERY, - ); + expect(SetupEncryptionToast.showToast).not.toHaveBeenCalledWith("set_up_recovery"); }); }); }); diff --git a/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx b/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx index 36e35dbe83..7f1d37b329 100644 --- a/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx +++ b/test/unit-tests/components/views/settings/encryption/RecoveryPanelOutOfSync-test.tsx @@ -9,19 +9,45 @@ import React from "react"; import { render, screen } from "jest-matrix-react"; import userEvent from "@testing-library/user-event"; import { mocked } from "jest-mock"; +import { type MatrixClient } from "matrix-js-sdk/src/matrix"; import { RecoveryPanelOutOfSync } from "../../../../../../src/components/views/settings/encryption/RecoveryPanelOutOfSync"; -import { accessSecretStorage } from "../../../../../../src/SecurityManager"; +import { AccessCancelledError, accessSecretStorage } from "../../../../../../src/SecurityManager"; +import DeviceListener from "../../../../../../src/DeviceListener"; +import { createTestClient, withClientContextRenderOptions } from "../../../../../test-utils"; -jest.mock("../../../../../../src/SecurityManager", () => ({ - accessSecretStorage: jest.fn(), -})); +jest.mock("../../../../../../src/SecurityManager", () => { + const originalModule = jest.requireActual("../../../../../../src/SecurityManager"); + + return { + ...originalModule, + accessSecretStorage: jest.fn(), + }; +}); describe("", () => { - function renderComponent(onFinish = jest.fn(), onForgotRecoveryKey = jest.fn()) { - return render(); + let matrixClient: MatrixClient; + + function renderComponent( + onFinish = jest.fn(), + onForgotRecoveryKey = jest.fn(), + onAccessSecretStorageFailed = jest.fn(), + ) { + matrixClient = createTestClient(); + return render( + , + withClientContextRenderOptions(matrixClient), + ); } + afterEach(() => { + jest.clearAllMocks(); + }); + it("should render", () => { const { asFragment } = renderComponent(); expect(asFragment()).toMatchSnapshot(); @@ -38,8 +64,12 @@ describe("", () => { }); it("should access to 4S and call onFinish when 'Enter recovery key' is clicked", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(false); + const user = userEvent.setup(); - mocked(accessSecretStorage).mockClear().mockResolvedValue(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + return await func(); + }); const onFinish = jest.fn(); renderComponent(onFinish); @@ -47,5 +77,59 @@ describe("", () => { await user.click(screen.getByRole("button", { name: "Enter recovery key" })); expect(accessSecretStorage).toHaveBeenCalled(); expect(onFinish).toHaveBeenCalled(); + + expect(matrixClient.getCrypto()!.resetKeyBackup).not.toHaveBeenCalled(); + }); + + it("should reset key backup if needed", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true); + + const user = userEvent.setup(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + return await func(); + }); + + const onFinish = jest.fn(); + renderComponent(onFinish); + + await user.click(screen.getByRole("button", { name: "Enter recovery key" })); + expect(accessSecretStorage).toHaveBeenCalled(); + expect(onFinish).toHaveBeenCalled(); + + expect(matrixClient.getCrypto()!.resetKeyBackup).toHaveBeenCalled(); + }); + + it("should call onAccessSecretStorageFailed on failure", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true); + + const user = userEvent.setup(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + throw new Error("Error"); + }); + + const onAccessSecretStorageFailed = jest.fn(); + renderComponent(jest.fn(), jest.fn(), onAccessSecretStorageFailed); + + await user.click(screen.getByRole("button", { name: "Enter recovery key" })); + expect(accessSecretStorage).toHaveBeenCalled(); + expect(onAccessSecretStorageFailed).toHaveBeenCalled(); + }); + + it("should not call onAccessSecretStorageFailed when cancelled", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsBackupReset").mockResolvedValue(true); + + const user = userEvent.setup(); + mocked(accessSecretStorage).mockImplementation(async (func = async (): Promise => {}) => { + throw new AccessCancelledError(); + }); + + const onFinish = jest.fn(); + const onAccessSecretStorageFailed = jest.fn(); + renderComponent(onFinish, jest.fn(), onAccessSecretStorageFailed); + + await user.click(screen.getByRole("button", { name: "Enter recovery key" })); + expect(accessSecretStorage).toHaveBeenCalled(); + expect(onFinish).not.toHaveBeenCalled(); + expect(onAccessSecretStorageFailed).not.toHaveBeenCalled(); }); }); diff --git a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx index 018ec25ef3..d86f8fbdec 100644 --- a/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx +++ b/test/unit-tests/components/views/settings/tabs/user/EncryptionUserSettingsTab-test.tsx @@ -5,6 +5,7 @@ * Please see LICENSE files in the repository root for full details. */ +import { mocked } from "jest-mock"; import React from "react"; import { act, render, screen } from "jest-matrix-react"; import { type MatrixClient } from "matrix-js-sdk/src/matrix"; @@ -18,6 +19,7 @@ import { } from "../../../../../../../src/components/views/settings/tabs/user/EncryptionUserSettingsTab"; import { createTestClient, withClientContextRenderOptions } from "../../../../../../test-utils"; import Modal from "../../../../../../../src/Modal"; +import DeviceListener from "../../../../../../../src/DeviceListener"; describe("", () => { let matrixClient: MatrixClient; @@ -37,22 +39,21 @@ describe("", () => { userSigningKey: true, }, }); + + jest.spyOn(DeviceListener.sharedInstance(), "getDeviceState").mockReturnValue("ok"); + }); + + afterEach(() => { + jest.resetAllMocks(); }); function renderComponent(props: { initialState?: State } = {}) { return render(, withClientContextRenderOptions(matrixClient)); } - it("should display a loading state when the encryption state is computed", () => { - jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockImplementation(() => new Promise(() => {})); - - renderComponent(); - expect(screen.getByLabelText("Loading…")).toBeInTheDocument(); - }); - it("should display a verify button when the encryption is not set up", async () => { const user = userEvent.setup(); - jest.spyOn(matrixClient.getCrypto()!, "isCrossSigningReady").mockResolvedValue(false); + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("verify_this_session"); const { asFragment } = renderComponent(); await waitFor(() => @@ -81,17 +82,7 @@ describe("", () => { }); it("should display the recovery out of sync panel when secrets are not cached", async () => { - jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); - // Secrets are not cached - jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ - privateKeysInSecretStorage: true, - publicKeysOnDevice: true, - privateKeysCachedLocally: { - masterKey: false, - selfSigningKey: true, - userSigningKey: true, - }, - }); + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync"); const user = userEvent.setup(); const { asFragment } = renderComponent(); @@ -196,18 +187,7 @@ describe("", () => { it("should re-check the encryption state and displays the correct panel when the user clicks cancel the reset identity flow", async () => { const user = userEvent.setup(); - jest.spyOn(matrixClient.getCrypto()!, "getActiveSessionBackupVersion").mockResolvedValue("1"); - - // Secrets are not cached - jest.spyOn(matrixClient.getCrypto()!, "getCrossSigningStatus").mockResolvedValue({ - privateKeysInSecretStorage: true, - publicKeysOnDevice: true, - privateKeysCachedLocally: { - masterKey: false, - selfSigningKey: true, - userSigningKey: true, - }, - }); + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("key_storage_out_of_sync"); renderComponent({ initialState: "reset_identity_forgot" }); @@ -220,4 +200,17 @@ describe("", () => { screen.getByText("Your key storage is out of sync. Click one of the buttons below to fix the problem."), ); }); + + it("should display the identity needs reset panel when the user's identity needs resetting", async () => { + mocked(DeviceListener.sharedInstance().getDeviceState).mockReturnValue("identity_needs_reset"); + + const user = userEvent.setup(); + const { asFragment } = renderComponent(); + + await waitFor(() => screen.getByRole("button", { name: "Continue with reset" })); + expect(asFragment()).toMatchSnapshot(); + + await user.click(screen.getByRole("button", { name: "Continue with reset" })); + expect(screen.getByRole("heading", { name: "You need to reset your identity" })).toBeVisible(); + }); }); diff --git a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap index a3dc5dfecf..d82653a180 100644 --- a/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap +++ b/test/unit-tests/components/views/settings/tabs/user/__snapshots__/EncryptionUserSettingsTab-test.tsx.snap @@ -81,6 +81,64 @@ exports[` should display the change recovery key pa `; +exports[` should display the identity needs reset panel when the user's identity needs resetting 1`] = ` + +
+
+
+
+

+ Your key storage is out of sync. +

+
+ + + + + You have to reset your cryptographic identity in order to ensure access to your message history + +
+
+
+ +
+
+
+
+
+`; + exports[` should display the recovery out of sync panel when secrets are not cached 1`] = `
({ @@ -36,7 +37,7 @@ describe("SetupEncryptionToast", () => { describe("Set up recovery", () => { it("should render the toast", async () => { - act(() => showToast(Kind.SET_UP_RECOVERY)); + act(() => showToast("set_up_recovery")); expect(await screen.findByRole("heading", { name: "Set up recovery" })).toBeInTheDocument(); }); @@ -45,7 +46,7 @@ describe("SetupEncryptionToast", () => { jest.spyOn(DeviceListener.sharedInstance(), "recordRecoveryDisabled"); jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup"); - act(() => showToast(Kind.SET_UP_RECOVERY)); + act(() => showToast("set_up_recovery")); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Dismiss" })); @@ -55,14 +56,6 @@ describe("SetupEncryptionToast", () => { }); }); - describe("Verify this session", () => { - it("should render the toast", async () => { - act(() => showToast(Kind.VERIFY_THIS_SESSION)); - - expect(await screen.findByRole("heading", { name: "Verify this session" })).toBeInTheDocument(); - }); - }); - describe("Key storage out of sync", () => { let client: Mocked; @@ -77,13 +70,13 @@ describe("SetupEncryptionToast", () => { }); it("should render the toast", async () => { - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument(); }); it("should reset key backup if needed", async () => { - showToast(Kind.KEY_STORAGE_OUT_OF_SYNC); + showToast("key_storage_out_of_sync"); jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation( async (func = async (): Promise => {}) => { @@ -100,7 +93,7 @@ describe("SetupEncryptionToast", () => { }); it("should not reset key backup if not needed", async () => { - showToast(Kind.KEY_STORAGE_OUT_OF_SYNC); + showToast("key_storage_out_of_sync"); jest.spyOn(SecurityManager, "accessSecretStorage").mockImplementation( async (func = async (): Promise => {}) => { @@ -122,7 +115,7 @@ describe("SetupEncryptionToast", () => { }); it("should open settings to the reset flow when 'forgot recovery key' clicked and identity reset needed", async () => { - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue( true, @@ -139,7 +132,7 @@ describe("SetupEncryptionToast", () => { }); it("should open settings to the change recovery key flow when 'forgot recovery key' clicked and identity reset not needed", async () => { - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); jest.spyOn(DeviceListener.sharedInstance(), "keyStorageOutOfSyncNeedsCrossSigningReset").mockResolvedValue( false, @@ -164,7 +157,7 @@ describe("SetupEncryptionToast", () => { true, ); - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); const user = userEvent.setup(); await user.click(await screen.findByText("Enter recovery key")); @@ -185,7 +178,7 @@ describe("SetupEncryptionToast", () => { false, ); - act(() => showToast(Kind.KEY_STORAGE_OUT_OF_SYNC)); + act(() => showToast("key_storage_out_of_sync")); const user = userEvent.setup(); await user.click(await screen.findByText("Enter recovery key")); @@ -200,7 +193,7 @@ describe("SetupEncryptionToast", () => { describe("Turn on key storage", () => { it("should render the toast", async () => { - act(() => showToast(Kind.TURN_ON_KEY_STORAGE)); + act(() => showToast("turn_on_key_storage")); await expect(screen.findByText("Turn on key storage")).resolves.toBeInTheDocument(); await expect(screen.findByRole("button", { name: "Dismiss" })).resolves.toBeInTheDocument(); @@ -210,7 +203,7 @@ describe("SetupEncryptionToast", () => { it("should open settings to the Encryption tab when 'Continue' clicked", async () => { jest.spyOn(DeviceListener.sharedInstance(), "recordKeyBackupDisabled"); - act(() => showToast(Kind.TURN_ON_KEY_STORAGE)); + act(() => showToast("turn_on_key_storage")); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Continue" })); @@ -232,7 +225,7 @@ describe("SetupEncryptionToast", () => { }); // When we show the toast, and click Dismiss - act(() => showToast(Kind.TURN_ON_KEY_STORAGE)); + act(() => showToast("turn_on_key_storage")); const user = userEvent.setup(); await user.click(await screen.findByRole("button", { name: "Dismiss" })); @@ -248,4 +241,65 @@ describe("SetupEncryptionToast", () => { expect(DeviceListener.sharedInstance().recordKeyBackupDisabled).toHaveBeenCalledTimes(1); }); }); + + describe("Verify this session", () => { + it("should render the toast", async () => { + act(() => showToast("verify_this_session")); + + await expect(screen.findByText("Verify this session")).resolves.toBeInTheDocument(); + await expect(screen.findByRole("button", { name: "Later" })).resolves.toBeInTheDocument(); + await expect(screen.findByRole("button", { name: "Verify" })).resolves.toBeInTheDocument(); + }); + + it("should dismiss the toast when 'Later' button clicked, and remember it", async () => { + jest.spyOn(DeviceListener.sharedInstance(), "dismissEncryptionSetup"); + + act(() => showToast("verify_this_session")); + + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Later" })); + + expect(DeviceListener.sharedInstance().dismissEncryptionSetup).toHaveBeenCalled(); + }); + + it("should open the verification dialog when 'Verify' clicked", async () => { + jest.spyOn(Modal, "createDialog"); + + // When we show the toast, and click Verify + act(() => showToast("verify_this_session")); + + const user = userEvent.setup(); + await user.click(await screen.findByRole("button", { name: "Verify" })); + + // Then the dialog was opened + expect(Modal.createDialog).toHaveBeenCalledWith(SetupEncryptionDialog, {}, undefined, false, true); + }); + }); + + describe("Identity needs reset", () => { + it("should render the toast", async () => { + act(() => showToast("identity_needs_reset")); + + await expect(screen.findByText("Your key storage is out of sync.")).resolves.toBeInTheDocument(); + await expect( + screen.findByText( + "You have to reset your cryptographic identity in order to ensure access to your message history", + ), + ).resolves.toBeInTheDocument(); + await expect(screen.findByRole("button", { name: "Continue with reset" })).resolves.toBeInTheDocument(); + }); + + it("should open settings to the reset flow when 'Continue with reset' clicked", async () => { + act(() => showToast("identity_needs_reset")); + + const user = userEvent.setup(); + await user.click(await screen.findByText("Continue with reset")); + + expect(dis.dispatch).toHaveBeenCalledWith({ + action: "view_user_settings", + initialTabId: "USER_ENCRYPTION_TAB", + props: { initialEncryptionState: "reset_identity_cant_recover" }, + }); + }); + }); });