From 8bb1cb5e63e62ed52952b38e9b76cbc3e2fcbf5d Mon Sep 17 00:00:00 2001 From: Marc Date: Thu, 29 Jan 2026 11:22:47 +0100 Subject: [PATCH] MVVM WidgetContextMenu component to shared component (#31190) * Create WidgetContextMenu component in shared-components * Modify WidgetMenuContext call (apptile, extensioncard, widgetcard), test and stories * Correctly use new widgetcontextmenu component * WidgetContextMenuViewModel unit test * Lint and add comments * Finalize widgetcontextmenuviewmodel test * fix lint errors * Fix test error * Update playwright screenshots * add userWidget in widgetcontexstmenu props * Fix some a11y issues on playwright * fix linter error widget card * Use new i18n way for share component widget context menu * Add i18n context provider for widget context menu * chore: lint and update snapshot widgetcontextmenu --- .../default-auto.png | Bin 0 -> 14109 bytes .../only-basic-modification-auto.png | Bin 0 -> 8791 bytes .../event-textualevent--default-linux.png | Bin 0 -> 7075 bytes ...l-widgetcontextmenuview--default-linux.png | Bin 0 -> 16966 bytes ...enuview--only-basic-modification-linux.png | Bin 0 -> 10516 bytes .../src/i18n/strings/en_EN.json | 12 + packages/shared-components/src/index.ts | 1 + .../WidgetContextMenuView.stories.tsx | 84 +++++ .../WidgetContextMenuView.test.tsx | 116 +++++++ .../WidgetContextMenuView.tsx | 197 ++++++++++++ .../WidgetContextMenuView.test.tsx.snap | 83 +++++ .../right-panel/WidgetContextMenu/index.ts | 9 + .../views/context_menus/WidgetContextMenu.tsx | 20 -- src/components/views/elements/AppTile.tsx | 64 ++-- .../views/right_panel/ExtensionsCard.tsx | 47 ++- .../views/right_panel/WidgetCard.tsx | 46 ++- .../WidgetContextMenuViewModel.tsx | 300 ++++++++++++++++++ .../__snapshots__/AppTile-test.tsx.snap | 32 +- .../ExtensionsCard-test.tsx.snap | 24 +- .../WidgetContextMenuViewModel-test.tsx | 296 +++++++++++++++++ 20 files changed, 1176 insertions(+), 155 deletions(-) create mode 100644 packages/shared-components/__vis__/linux/__baselines__/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx/default-auto.png create mode 100644 packages/shared-components/__vis__/linux/__baselines__/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx/only-basic-modification-auto.png create mode 100644 packages/shared-components/playwright/snapshots/event-textualevent--default-linux.png create mode 100644 packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--default-linux.png create mode 100644 packages/shared-components/playwright/snapshots/rightpanel-widgetcontextmenuview--only-basic-modification-linux.png create mode 100644 packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx create mode 100644 packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.test.tsx create mode 100644 packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx create mode 100644 packages/shared-components/src/right-panel/WidgetContextMenu/__snapshots__/WidgetContextMenuView.test.tsx.snap create mode 100644 packages/shared-components/src/right-panel/WidgetContextMenu/index.ts create mode 100644 src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx create mode 100644 test/viewmodels/right-panel/WidgetContextMenuViewModel-test.tsx diff --git a/packages/shared-components/__vis__/linux/__baselines__/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx/default-auto.png b/packages/shared-components/__vis__/linux/__baselines__/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx/default-auto.png new file mode 100644 index 0000000000000000000000000000000000000000..01b549ca724538b38e69278547c9107e2286bc3b GIT binary patch literal 14109 zcmeHuXH=8v+V1NxBeO+hMn$AX$ATh7r37hlR76AwNRt{H(uqh3Ed+-Fqz|G-YDAjU zNbf{JKzgqMA~l2#NeJoZ)o;(<-}=tk=j{Ehb=Ep(t^N1O^X7fZo%_D7>%J0cY@{c= zPhuYc0K)n=uH6Ozfe!%iUEh!2fg^pQadQCh6QFFCIq5&{IKmvi`{H9bMPfQp78i~ac8j)dJ>wPvkJu=WrP^f}T-lNS8 zXxs^0LQ-)&nl=ObY8rzG;-j+%mJ?^mnJt*jnA2h;w*E|0Gfs)X7{tXD;~j;Cfi4Hs zu{TH_w-do#E_zB}rgb4Piyup$!3%M`Rk8qvyMtkFB1vK-NyCx8?<9cNp*f;`_vjpO zehWgFTNb7C;>SFz489Vd3jn;XUA|13)E{Lz2W?I( zVVFENcD;sd`g56)a}e^$7PkNQs=$1LAP_RX)*8a2Qu!sjrc$=mn}hs5E`~EjB1g|X zh4AQ-jsdNxYF(|^(;M7YZuZY*{bo;@e1`*a-kw%vt$PgF8-Q;q&?a`t9|kfK7-ww~WM|L5K|VMu%J02!#1)3>|tE z;Wv*uy&lvY(Hb?a1g#?KeUYy)>l>Zub@b zTz`%>vEWHWCBbirmJ&A9#2O)2Rg8Sn)*M+4uDduzVkYL`gDT6oZjIzf$HX+H!aI*u zgCcsTX@8I{*W*xx((GD!iL-HxY+np}MKsK`j<*}u6XX)Sli5iQb1Ze)FdZ|nN(zBA^(hZ z03TRO&ghsgn&#P>^_P}3_Du(S9K+?K1A2>Jie~=|0H{3iq^CyaD#I_+&?RFpkTjY| z?6qu%r0r6(g$Hm&ymj>x^2as9ymSF2DZ^wD4u#o+UEXf9ciS*{tA6tdH;jeVjOP+z zEXb(2rHUK7H5xMHW%hdhc;aW&dT>eAeK=WCnr$<9HFVVQzDUK!*aWlB3p3!u64V>H z-UeOM(GFT`OyFe-_GCk){J9mCnJM{uCwk`#M%_ zvvIwuW7#IZBlDe#DIv%tHIN!g7x%P1vQP^zr|%cNDOT=A8Y%r!oBMNasc>h(#q1MY z*6nPx z9#Bs!N`{DD|4r{G`TY3+$S5!pD0yI z@Sich-Mb@Z`EJW$9f`2{y<$p-pZf(%Auic!%{&%UTBPdR209p|-qh8DHTEJKu3t<|xc6$m)CW$M-R&>#`WDRFK$741G^3=ukTYJsUoIG?ItjW-{xJ1rNg%-cK*;_96 zM_ySLO$2Sy`P8lwVcgm{<373XA^e0F1HeFVs%$;PApmTbtYqK`w(6vIt36&a9POhp9+RuYLUV`{}oZ_ni%gyjHvVmc|^m zjn)HrPDmYGpyo|WhtBB4d9o(4mwd=T1pstDK05hE`CMgVy$<@(3RcV!(qqBlnWp9C z9;hF>NYFA(GmJxL*U*Px_7z$ld>5~|s9|D&+=yCEEox(zf#Dap#w!^rbzQfUBn*`j z7%qFTqlFEW<7p<{9Z|e)0%Ek<7tZJCCaIq_k{hj3yGCM%E@Mop79X40B`Tg~SNQZ$ zvB>dh+D?zs=rE_V)xDFxG`}(h4s+Ao*0#cgi6<0BcXJ&PRXKGwWXN=XDzI0l<09 zuCa(8_W)lKZ(J4tE(3eO3b*Hb@Zi4xsSkvpm;af;Gm-153=#%*f2Y=)a{GJWJUQv< zULXVjkr(jYWP3@a)?+m;5dOe(<9c0Q`9D%^u)bNXXx2|JT7&{rk_* zJX}9(rKasB+W9=Z>)oW-oSSZ6%Q8k}Y+N>&Q_c;~bn^Z}Yo8AK9srbG!XLevtDY@E z$`FZng6p_)(DF11$&=_@f|tC1Wuu0j#2(;7OikUZ$NL$p{);tn8S2_Fw(;|mFIzlx z-m@rA@o2Aw6ykwYO5_vCOzA-A&qZ--sh88<99sK{lmZeZ<-Psf z(+M9*2}qth+Mf`x7r1-9+xW7qKA*vR#|l;L6B?D0loHn(O_8^?e;N7Ktg@s=LmFJb z)S{xbfcAjtjaf5iEj7hh(>(R>fRMUwyYk;p#F=_Nk2(Edu0PB8#xG#9B~I$@u}iip zKjvS?4N{ES7n-ZVDz1ApHRerQdX@veGQ7L*VBD5!A1USisj2sT!N8GXeLQpdd4AW4 zm$5z-<5j08rF-!`^Ss@X98Ra~jv8rw76kyCD;GBgvJDcaJ>A)#KJM-g9G+I-6lP`3 zXY?{@BzfBU=P*t@W}CS!NH)xzrV6?xBg%6@$ZAi2d6G=5ct8L zR(&V~*&w9uQT~dm{v+n96ZdE%r|X}Mcd~_mpZ@YSLhk=X@LvqZN0&2DHdhyCR)=w6 zss-s<6N&LUza^wgcUr72iAO$aby^>$3m3^g85~E2zo$KK6i_f2(W$xT-dU(W-svC3iaz{z4kt4apOtTq&PjnIAn0~iMqTs znxs2X+0oa1BPw4)>?FCB7~uWBK-Ox^Othh7VE4838?k;|4UYX%k#<&JB=sbd;lkCg?O zn`=Z3n7dkc2*T;tqp_BUYvCes*W160d+Fdd`N@8yJ zzR(o{y}Ik5sz)}G=Z!~2yc? ztHo#=GF@tAR{f>>9s8GOr30^d)@MBlwoSIblcsMuV(|joN^u+JygUEI-B&YiuBqk$ zNmKRw!CvG#hfW&k>Q5w4J0MAqV8Lr4hst2q`hL1 zq=i;~An>m+z7T7!D+`NlI-5xhHPp?me&u$Oxx=_^3W%R;?#G*xHR>vyX z-a1BhjgA9XPmOAaddtad%vSG)G{KeT!`XEs{LW58J{+R?SPk1LrilB};el-vvAwk7 z03{R-{7}FMo?96fAh~e4x&{{7{^eE1)3n){BH^$@a8)ie8tbk#=A|&JWW+?N-ow>yrk6V8@vppO0gfs4)x*w4@lXX7zQ(o`1rf=3MB5!^nXY!+6?vqSY zDVHXH5S*RNoXgCrW;^O6p2f2^;(5$rE{3)^vygPg7Gzef4eq)8!UtYE5o|_C z{#ER7 z@@-_g`Y^0=bd$m*p6)sVC*J6dQY8aR&dcC#$mq ze?7=$+tM@h;pT8`L6vX3u9ubXy{%_?L$cH>;0DTCrZ#ou@|H(rT?w2fo@uw>tE$zB zWKw-Y_JzoOVb#Hdos^P=LrKdyZoEh;9c~$Gx1)FyR_mhkb-Ay;@doX zUB01SOqrpiHS2(zvvw+^W1&+5wTiz1B8RS2f2^WiGA-_Zfyt)Uo;NnHq_4A~mWtQ9 zRsJDB+m%E9iIhG}?q8}drSInb2mrtRCy@1z%k4LS^&xHlk^xpxx66JS0fc-5dOsO_ z6Cl73{HcpyAx^0(g8 zN|bIA$@S6@_8I^@Hc&p;XL7hrG#*{X&0%hQ9k<$mwNTg`iA*1F@<(CFsmZnz@*{?uUFp~cU7 zdl6H%TFzS`3UJ&KByyiNF25A`kP;PK*>BClc zug^nhMOtvCd*EVM@tR$8%lLBEd9!yDoST+x1L0;oVbYYziQGV~=65s%%yMqLz&s$0 zinJg!IibaanC*uwnh?>bg-*|V+5?Sfi%Z>Dlaj~dJLgYdTCE;n#->0k%*-oRc{@UM z{kFv^hM) zQN!rByyDIaDf2zP!VW0~Lhgz6uL|_oi;zk)Sx5U72pjrg=}XOeQ&UdBiC=SY@_IQ%Um;j9(4N108O6p@r`3(#UeqkUns7iI^x z&@#`WO-cGMqUK7?f|`SwlQwIJVhvxPtBWPJ8HJCW&CAOIAFFdMP>MveM7esMaaZOS zC(80d^xG+E?p4au^eYP;Eeq{6DTvON&gRlcGC_TOJTq$GM<67=IR??HXh&r2{)U&) z@~-pBgJsQVgLj2=gm84tL-8tPqj`&AMCS-?c^f%xm0R~-?WDP|*%oo}n(KJuiDpNQzm zU3M+a?pG0!baateBu{9xjTjzg8C8fn;tK{DM!o}4*{UYJCCU#3#k74#fJ;eg2 zcei?^G!jw}1ixU4ijlQ_#ap@MNz~(GL|Z!@rfX7Gf@va78a%qTsml(5>ov#iaa@Rkd(%3!lJ1sNCx6rV$V%xx{g1|R}x0p)0w(2ZfCcD3j zB1zmD9d>N9fZd|Le^BRcJ;ZJ6HfXH&V?K2n9#jxI4GYZoZBDP)N*Ga6RSC{>7`>bG zM9aIg^_42F*D<*j*SrQ#&cD;BZuhJpCBGv#SYc3G6T~^u|F}S1(9T5bjlf1ptTi!+ zLj3xZjX2xS{9Rd`66W#r;V8DPWK2BrC|KkUpqls7Y*hzH!C!W7m>baT5WN6kd*Y2_ zs}t#?1OLf*Wzdweq)P|LXLT;f&)l_^2j%jk&^c_001yH{_|Iq#UySzB%t-BEcdRTt zQt+E_I`T2Yk7!cS$>0=Eu{b4oPtk?J;$b}6b-Q8PTxYJSw3n&@O6KET$@KuAb zcb1vi{cZR{-_8}hp>La0BYUihH8M2R6AxyA>goMpN5~~>V|TD%S+4Q@dlyP=8dw7U zf{Hy*?tW*Yv_cc28#WCp$bsb^?U}-^l|K7p?J>`0$|BS%hdB0?v(ep>2XGA@%K^JX zg9a&3E$VKEsIWof*!kcMLFK*^h?d9c-LS!;yBz}@-j-6YA>Kl;M{ zXW_d}$5ANss!*fvQbq9&vV2~I@c3=jV|pA%l3ghkYXRX6mbIyA^);|ln(2R2(D}<% zvVp~(TgGV5&KZc5ReHJme_@&|x`H~WUnzQ`f}XuEzt1gj96`wB6db`qg3HvCGzoqu z{Oi0(RRJzI65*na#6q1WNzSG*H`vWVcX(S>6kmbsguQC0<(x3DE1IM2o-Z!A;Uj$b zdC6GA=^issPK*kT3eUM^gDn0jTM4PO-xn9}0#&u_h3irg>K2ExXFpEcz=-O0b@dsg z@)8=us()BD)VQo({#vha>cg6o_2pV1Njg$zP~4QT--l4|&e-bV2|cJ95O!5Wc7YP> zxyXX5oYpITDre`tv`K+(AJ+>+^8L;rR#yvFa`pCV?0As8W@$t$`4y~gFFNm^;OcralTL#=!Ck3W$EbU=wW-01+Y)% z=4&ZGb5weHtdiZmoaXohCLy)^v8*ER9C6Sb0poPcwaFN&-i{2jc#8C#A0-adm(2|aV#4}@Y98xLvvrZ4gD3VRam8H#o({X?E4Z1hL4qv&P%y|Q$1iM~FQ~d=+tv{7 z9=0U`Ht?2-Rx`%BG~TfOm6x22I7*AU5WWB}&mPsP;e_VR(lX&2cV2Spa!5t*c*`{% z!th~7<>B%9G0S9|_=R(pYS>_ghzrDhT=NX>;h$Q7U!k!{LL6RcY2XKiy2=r{2lA{A zE=P9a`7Jf=L!)y7U`T3eK*ag|I~p)YJToph-Dk8T?ggEgnylW`~#K&ihg#tZy&r6*qs7Z`VM$a?fPd# z=fBI-A>z#S4_7uS!@Ih&dz+P-bz$m(fG&(m6mJU-&joxdRbz0!~cQ0xU@!vYao$H zl}@q7O>Vcx zf{ih+Te3%V8|n~F1Y>htH!8h5R(Jab#t~yXWtQNrL!jKL-+Ea*NFtdM36508UZl}d z|I@h?+{$MU7ftSU;!anR9kFg7RT7GH$;)>cNAHrjL_S5N;G9GydagxOXclCiIgYK5 z-WZq)rMSFJU!Rro&fu@6n3#L1sU9iSU$;fwo7p}!4npt(7yE>E#sQeI4NiPLSQ%!$ zN-l16+8T(;s-hN^E)=Yg+gj3I;q#8J>tG3aZ>Z$Dy%!5C&9gJl*j40Y2W^^dJO(9w zNK2PpSf;$WRav!npdR0tb{%C#$a`Dp<>qz!bL^+Q(sPMTd1m?5r$EsD0>xpcAxwIk@Ydd4a zjq)NQ-t=ph^NCZIooCI6&A0pHq7^t_ta9CNHOK*0ZW!0_@Um~lQRqN0#*pw30 z?h|C9eW&Bfx!WerIh8t7sMQ;&awq4lM=y#!tknnn>mA+nuMeQ^xAh$2Lh(NrnLN6{na!PeS^3#WvE%g@Axm(KJkI^X%DV@N6Ke1fgTa4j`&D6%c3 zvHBhBob*BwT&+94t+qC&Sc_p{JN$=&B(b30xpQZM*?@3%vYqG)*j;0aCZ*)QHTWAe zt%m;xGEIMP>Kt%SZFyoh`xUoA(LoUS;N4y4O&Q2S*%Ea;!A6ow7)l6s?(A9rB`?Y| zEAmu=5EH_mr!I)}q+IdDxrT0S34=&){G+A+f49fpS`dpqPOM(xIC<;E{?%*&9Y@!Us;L!TQuE`AC(fjsYG2g{Lo_idAtdgtx={;r zeUNK8rz)-$cp4XQtak3Ea=`%~Y5Sz4bzUZO{Q*fM=@7WATOJ_Wjk+j0p0HlbOX!4N z7p$uj5-O$maYinK7*g0k#{EvIH+^J>H}iC^D85)nV2mfh%#S_L+KDQ~}%8vjB1?HZEi1u?H5rnriI z%3d%SK{{1ix8<#Nv{_o@8gweZq`WgeGdUwV1`-_&ERwSZ##u#55Eto`cj&nS>4}mo=PMQ*T zL_uLXW?V0XdRqQ{4D$a6HTeW7&K8ww4pS#H)()`(4k<30shM zC8qR;#Lccg5C0+W7WesI4z)X&7ir>druo%VqNDQ(118g~hl3oy!UEUj4liVjx$temt%hqIUmf1T)D$lw2XPsZM2<#^6#J&v zO6+I^*f_pGRkW*Bp-7Kb!lh!sUj58eQ}4~2H)U9pQ;iz;v)Ig+f*0BL{`F6iS76WH zqVyE!F`UF+EKD7_uLWH!+}Z}+4*-uMlG2{?c4NMhu#$CS=E_;86Kk{N!JE44<@pyu$ZucS=-gt*SK>MO^WXcOf9-HD z_E10m`jrN7ecrVE1hV5pJGyfBlk;|opmFTkSpkjm2-@H`o7k7k;-`%kH%!sTh03~w z5$WFn=SPEfLu58`Bc}>89IH}Dh_4pNU+=lT!J8PxP`~-m6d!mE zZ{F0If1!q|nY9Vt$c3pg?M`QaX14hr+WM|u$2ymmtHfC)gBC$Jh8GP-FPm2JR^L3| z3oQN?5+c)^Dfx@@eUlbkBbiJ%8v8mnSSR^KDzh?{mY;Zj6I!#{EHXvSI~N&Ll!82}m*`NQwHJ7lt@%?Q zRHU^H!{~v3^V4W>1lgG`)3Jo=*d1V#Q0xWWy! zJ<^6wOOks{hpC@_oympqnM%*^N5{t5*W+o^(S-8iISs859KGQ&7q$T=uNSudw+t-C!ohu-VZyi6Kkx3l(ItiZO zrDJZ)Pc%tZCBw+)mX1u~t92De>@fHg_6Izyu|*F+ru8RK$aP&RVo$CO+7xA^gU`%1 z2ZbOtgryDVTqKwMTc>I>GM!J-&D!<=uZwi%MbCRkw+%28Po2p;K?ft)9l|^Kq^kS= z6eseK^*{n>+*nMH_ZD7s4nDJ4nfucSq>N~>p>owq8{B7~9`|5ko=k9bbH_qmpTh0k zBczZ+dw?!&)S?Zru)Rt>{v6z}Vs#=S(>6a zfS44C^QzxTP>DJ;t~l#JoLihx@AD z%mcvg_espJ?ilz_|BOiXPq}sePMrQ*W}kmQ{;#pq|67OLN?QUs;ojn}{}K3?9b5mt z#Q!%g@!uEsFW3zK9kl-r+P^sk{~Zkf{{;gN1OB&-&mXe63yL9^K7-&yNh>wM?@IcuG@`se-ey!ZVK_j9f5zVB;2 zan~(P_U)G44FJGC(?2d-1Auq~0Bq~}Wt(WE>u};E02}~JFaOOpBx_*|<^7V2nOKrA zK5G2Rx%d7D4;O!8I@D)j9KJASuz=lOs5mkg2kId@E9}oVYbo}4b4a1Y_JG1RY3Y;2 zJE!s2!*&#SzWyWn&hUZ!o%UxN##%4m$rzpU;|4R|#VHO@lXaG!gEXH%k#qU3zZDHJ zy7v~Qb~~?GlM(@fWNb`!=+Z*ns6$om$#KVOXC~I(CODHxV+aO@yPGW&&nCmN3=o|e zn(l}2-tORab!~p8-ZYNVagMx&?kNa&YfnGF*wxVV78VQNJF*i1O^}57Bp84&C^>8rM%!%E!wt1O*mo{REXw1-C9xAFGm_xV8Q9up z=xX-MUAVKk99U$K3%!;?!6ZL;07RWWYo^+2zv@_vWG2v5-@B5+uo_#X3_6UoHUH3Q zX*+ONpXg_V))!o4fD1-w^(n&9QN3*8Xx0(bJN)D2j~q^@rwtX(QJ8vxA3WHiMM z8QZ6Z0>&ds*k-5*<4=A5f*{T~lI+K8&vHr5XLxy3S{5;_d zWs5DK@tRO0tCQS4(SbZp5Nc`KnF7i}tLPzl?bX?@Ua22OM#33fUySUad`KhIQyfUg zf)93O$dML(=eJ&$dN;}7`FX<-;c^L?;jxYgD&UiA*Okc;WiwXrde_;`&tR=v%Qxn* zXz;c4r=3{LkCVn01k4RMA2VE*2cpi>w+%d!7Zy{D(>|w8MbdNw=CjG#}5;o&sU~u+s;O@2Bd^PK1Lis}Z zas!~DG;TqQY8M(`G}msjODt+`E(tG=`8w<2F%=(49XPNJ=xw^9`t7J&&KvWqmOcdo zh)tB5sk`Wo->;W1$B2zf0uAppa<#hvpdqR)`0)NhMYae5wZ!#f4?v(Sx?}W@IH(5g zYVKm6y03pUqO3p@P&Z5DaREQ;$49aIF=5t0}4>6Q326;D$5h*^E(_+zei`5^VYI&1{- zed(};?c>z*E~KTW+Cg$vI_4R5fEaGk%fKk|uI|;oU-Z2NQx-okieP6_ zUCoZDI&FOAKn3U{Vf%d?ZDa$wzaQoo3%_Nux6^LF#1m3$oV7fRQ zu_nZ!(jUn`Jc2w?5X%}`pD1=ks1%c3`kNjP^<=CDod}5-D>kruH%_Eu6Zi%lj^cnh zUisIYp|{ zjev4TFkXd6My}6VvD=Q1RnA4D2P~xn)O=7Y8?CW~$UGhGdvC^bo6Qx7tlo3zn)*c! zVP8r!b~N&RO&4*-wD{)=ov zUm&C=uVk(zTYkJ|i<;;@qR*KzITr{Ttbp7W2naH3;F@X$?)O|)r!iy{;}y27=)+$F zSD(=%2)|5ROBsFRexp4#vBHNIAImMXI58BEQ}=v+u&Shr^}-$NJHi$Cs?J{M>P`j# z^|&)j zH{%%^f)X`Gs+zg00ZagEPMj*SQ!Bxw<~ zVu@vV4V&KU&>PrR+x||gW#r?k5uB}(aJdR%(AS@qNn4(!_xJ1OyUQ4iVD6_S%Zpz6NVp)aIufSz2Q`L>rXd9|Dr@ubBmFVs4#LTpy-PVAJ0e25sSQ}+K zh>tCWA=XwWDVsA6nklOZ{RNdO^Z+6&53RH1fwEUyXQZAQ^S#fRt8E(L4Z}LufPRLA5JWEOCeI3z=pG6hjTh^+v1B9T|b1Iq^0Hic>Vb z6ejax1Q&+!;-NUJnC7Y>$E{JZPIfSyKg=;Pbf5A_QL|s2;*xC!RzFw6>XA#WeC*s; zyLS-?N>kw`iSMu__~betTrGKZbFQ26`kfwIO1wyp${r?N)LZxS`fQhZOVXiy8_?hm z?@9B!FH0S-$_W@?O%k_4+HG&CjZnuy)S1Yw0w!w*?&&an$A*hZBIttk2cJ-nt9n7k zXKpSS!ag@f_?}N8IR^{>h}RXY;8GXxWwDjf=VN63NAI17sT|o!cv>)4P9{~pUV|8N z&>*&uryQPYcjh6-GZi0R$()58AH2TGNM!mzqx=4Bs>6oR{rHp`?I~L~6*rSod4p&BV-ieF+KORAe(SAgP0^kk;B;g!=SqgCc*d17=ZxbpdZ&sTIr4U;{a z&124ogiR8y#uPZdzPNVQSit-8T3q4lmv3$+^qcaxChMc~y8<>hCL1%GiUUzdnijb@ zbc}b!(4c04eKrx?cYrA6BT>gO{cVIKOv(5BW>Is_)0AAvzV0q`sy9J+%W61WbVf4K zWlr3{xywArvDWFdJIqIr(Di0oE?a z!Q)FHkMZI9E}+5op+ba|b1j^S+=r@nZO$z)NUMR5rH{NNRuj{wIJl-&D_35cl`XZ} zcAA8Iz4T~!t8H8%Lt!4&1|RpI86Zqm5mzeNV25l|%PdgI>u*?zdG_$imq#(|1pU4o zTi4Yx=m^`B)pN3?eAFV6M#kb zoj5KA#l5=#Albdi9ww_BBJ4_d#H?}&Q5M2)-yX8ibxTr)E=^(Ua+2cp3PqEvg}*B`bM~8bb`vF#I8lypH-iWxyeW*8@mLU&2cj6yqh-NLY`W+5B?${i1Tf{$2;geExCMbG5Ir}JkZDgWW&@(FYxQc z9lWZ-xtLiqe>ERrC#z4FyScg8ojsnWSL@oFYwO}tJ3paZFE}-$zdzVcOSQN?QDY%$ zd80#)T0^B0z)N}O5*o$juptHCmU`e4gqjZ(E*!k|L}Aiq3#+|0`z5O-VlzL2zs5&Q z*)caaDjq{TJJ=B`Ly7sd(`)&hMPm9nH#CTayK^VaS8m!7EYFmnJt$L_4<}U#X+Sh9 z4L#-Sg|mpsZrd{y@Ht4vfxrx3S|nBDPKECel({NzC@S|CZ)fC3sU9&vt(DbsymCq$ z2r_n&z9X4CaHDabp(!P|sHxW4Hg&btuld(aP(g(=x+@LuOqJ@YiI83RK52YZ;%Ryfd?@jw;fYJL4yZTHmLPmsy;Z& zwEeexNADyH8(T)_dV*bDye9u#0R=D2M^prM^;_&|OheE0bez-}Y=( z!{?USC~6c3eZ&mnOMi;XTxl@G1zr)Q9%`e zOOEX`W-j@y5^F@Ao0&Y-Gjq0{PWsy3WXClbzOZ&}2+e-88Kwg%-%1>wS~@jWp8-4G za;v(ph@(FY$qv@vZN+oGy)$S(qn~;U)$jB`BBKX(*r}YJ$7S;mi5eM@E z-#=39w6zMIE{2rWuX|pZG`u;D8~y!rqK-YTH@@+nhklK1UZYJxNN2tYP0c2hUk?f6 zeXF&)nB_?{9E!}T|K5)+F4$M#?lkMDRGdata9EQsYoCGFS83fa945BQhpr6efldHX zl%}cjN2;){cX+)ZKlhQAOG!ANOxjo<;>M2u&;kg;(bwao#3EhRg7DX|5c*B+TZ&9R zntLSa5@l%yE#oCyLRY{sc+u0%+2hEQWA?C{SaZKYTUTW7ruWFltstq5MwJD5AzV9*W;^F$)QFRILmG>^j_WydjOjg^8@5n0 zpT@x%jqW^gU@;^8;o8E`4bagKsM)55wW0GLQcyLbe!WmmbC;COg^iUlNd#WdjYzXS zZTG&0XE`0oT}xBKzd9e&1AZ(gKJr;dlyPE|op*-SGUi9g93p9$TB}ULiUCJY=O~(d zo5H)Is=aOC-H*9ciWvaB_q6}?@rnH>F+lxpkD7}F0N6?WBaQtt@BH#W6#_cG9VmRe z2i9b{R}5TSv4)N)xG7p)a{UR;0JnCJ129`b#kcGu%*EvihVdgEUHvNRAiNB=G! zmXjS5Tor>IzDgp=$^z*z_q=i>&MNK<5dph5p>0qMFluOQH$rRA+27L1D_uBQ^p5x` z#c6qHp#8Yux8xuCiGOGqV&ar*RXLH~SI1T`zry~&-D=O!Y!)u$B<%zW9~&7-PIbTi zL-vj>&daTy8~K~FQds$Q6{`;ULHUgCo?}2d_>6Q{9=-eP(@ec8kqq!m6BO}WeOfxc z$eqIbawxIFEw51vP_Kp(O8j23&MGVOKY^h9dD@n$vu7xW>q|M;0ilrG`UVWefON~I zJ+r$A-CY@9=XWF1xZGq3LYcIfo#QMU@-YYU(3go`89l+MOf{X_Bh35qk9K;8w>gUrMHS zfTfZ)0=_Y-H|^@rP+A)2MdjsB9fZUvbTw0_Ik`Z%enM( zx)L@G*rM1~^xcd8G^aORWrey6ZBAjeXGqfX-HNQM+H3|QoX4wMp7+a6`tH4moZ1OA zNK5@Pd;gi~?9`?6hJFSip>@tCA0|KzMb3SD^wQw&7(U@;F83LzqcB?}YtZj)(?3>+ zRa^HunNz;kWyi`yxr=L{##LiQ5!}+{-JpJG>s|YFSY6B){JrC6W-3Ew%N;$g?h~yL zI$cqi(Jb2)zaKDnmf1ghW1lkpMa?cd!gV9q;t}93Fa7+-!_rc>zn<^(xwotNu-j&o z<)5qGydO8O>c2DWOOlk3vrVQ(I0nmbtSPT33iJp|`P$;T#%0~1Ob8$US1?%=;L$N#Xj z{Wm4`f90Z|GyY4*@iRO>LGcq5Ke^}MYZE`2;wMx5WQw0m@qf=0VbFFfkvO{Y<6Xf2 yLQMThSpV+{O9%o0p>XNlI!p9CWap3j7YD?s6*|=sJ!k8pdgwYSuXBtS&m`g)^6GCQZ*&Uec6G&6AZ@npe-=Sol z$1Cr9f#aR+rOzJY<7>C<-XyQzb}#VR@oy7%b=>HD9_Unm_t>`2d%t_MOZtX7sn8@ zK!@Q&VEuKCsJ*uWx-o{BYK?l7jZK>CoU3)Vmc-B{sgaG!VU>K&JJZql)$UCIz;Bl? ztpk^J;%mIU*=!zg1~}X_JD4!f4tYy1^*Mze5>)>FJ8sq@iQ+h{ds)?oE}(5;F7}t+c>_ z4Hr(2>qb+?$MX~gl|K7b@-bg+7`Nqp?>;@~aI7t8^?j$FjK`GMk7gvv`^br}v<QZ%6aGmO9)rB+QBS zMn_DuOYz~BR#*9?X+<~bO%xj-b8O1V`RvPD8IDWCo|yG@0ULg^poELKnhJ|9-umT* zvHpAsyr?;fjcaFlckH>8=9%4P@xrC4o9xM~T5W5C5Mr)~LZWf%0V>FN(n4glZ^tNR z@wFzeQeTIkcaChXajoi_n0Qkp(T|Ulf&(feo0s!yL9IxN#xocKKg{$vl)z|P4**EO zu|WW)liWcz>y$DH6i7kT(3GGm?3`cnq)YeBRdmPm8@Nc*%MsD!$x4%2c#A1;zMXYZ zJ5D=*z|==O7FON!$VyZ)suO^AQL&yhX~6o~%>!YE)%VN#29-^%R^jCn85(Y>DcfCK zGC-xq4)HC6w?Xn9>tH5ro`o*PQ~Bz9{35$WTy@uTf6v*2uW-$AqC8SNv8335R4q*V zm+IY8ccf%ul9ko`w0WwPy@w|u=fGCDgNG1o9HjoiPa*x4fFFl#Y@YB5h?uq*>)-F3v6cXT~S(U$p^NUyGVc9BSktt{K z*x5PAVC1O^J1#nfMhPLNAl0A*utW_^0*K1i^B=1*#UniF2#nboMD z-oup)iW@W3tSMZ`!4aT@aUYegpWx8*MKvP>dy2^OXChZrEY8gHcAj3KZgM9Ux+@~M zO-~*}+jYjUl&Y4VA2U^7smf~$0vfqT?GS~%S_or5)a z-pOBUHcXu(<({npvsNFE>S_ji2v&|Fc2@GmwyE$kZpdC{cmpRz@iZ|(!5S>Sn9t*t z?&${a9n)n&4NOcXyyhUkfPbo_STL`E4CZFqH?he~8dxt|^nVV6tjD;8byP_nE00j% z(qdQpnhE4vw}%0n)8=qvi_8lsE8`5R=y)z?``D|F??bBSQB2lY=OHG_nY^t(pOe(jnsEM818;7T=!2cj4vA2Y7cFW&A?tw-@7Q4LnE*OyHyilx%ITeqpZUu! zCCpIH)+=T^+KAsT z5eZ-&-PpU}M?)+HyHjU417k&n>f;FU0XVchh4jcr^SrXnUvL3K0>f(3NPRRGc^H(# z7>f2h6q5nxHql$MOO?29mx^nj8veXH8(01?7b=75jWCws7Q}my5mt@o`7hBxO|fjv z3G(MQHnByFnL1-!9)5 z71$a;IB3WcK9k?7TC7v#=HkEuu$t!WLpop(H9hXh(2sk=CG3bZoGt&M*>u}^>63pp zrKD!}_H{x^+eHwzclbu1Xzm#oy30LGWc0;I+$H76>pSDqe!b1rOm;@8QnG4W3r`d) zM|1wXB5jI=onwjBZdi?cIAZpa>P3Ry-A6h3qQ7uxPTVUv`xfF66*(Zj{S6u(pU6nZ zzV8v|Z;hRrOAo|Pt%s=leJ`ENHHa*zn3tn9?etq)YddyB+b6c$0%5>+%*m;7QlSlW za?fU%v27+(AH)|8B_+noR;r7pqM54mp5<^6r!_rZa=-D8Bm7L2r|aSU>DY%dSqePJ z4kt~`MLu+MQ$6QZ6RcZ_2=%Zq<|YsWz3>EymCGtG6h>epk7lL*~s7no73G<-?VILraLOXBONK#`HPB=vo!Io~~I4Abv3;EqvRSr9d zEDy|N?Z0eGq~*J%#4MX6vuolhoURi6=OieDtRO$Q35#u=dUhv%#iGN$y^KtP2_(n{v=J@~7;l8aW*(fo3lUIM68?kYg85JwLW1!%256UH<|-`nroPz!uNqq))p*g>oORZj^uZ z9z%8BZ{xlb_jA>+yp7?`brtC3{*Z>^L83dh?rB?ATKbjIP2ZT+b6QvU(Pl1}6TNX6 z{af`!Vu^!r_ygy#S$)6)f_7g|Y=O`sBw?%cdO^$%Xsltndd9{~*H#y{=`upcwx8AJ zg6n*qXp8KvRs!SAtJ38BIE#bkWORUu`CszgC$y}e(jKqvwMJl#z+aa@(@&!%bUjf0(&L) zLLd-<>(?&dhCp_LA9=cW^MHRY$QbN^K=wnfU;g7xVDid%P`pV&8;mh*T7f18P|SWy|5k~enweH zcOQ7&uOwAp8K3($7=Owh0y$D_ckXdnWynsS;L+Z{%o@R1oy4o|oS$Pld{w%x8EGR9 z{w8YFj7LK*{%CM3-1vaSE?&rmul_F~U2KW3=MvmQW?k+=Ab*-F@p-8`6DrLvpGx{` zywVIj$hxf)GSQz|=Bp#;dR@o{T2hrh{p;5ykcow$vPW-syPtO`d$b<{xn@`)T(9Xi zlKkdA%y_$%?(1QE^?<}72;^fF|Bh;r4@r~*UG!ZmqUtsoNx5UWg7?6{J6Bd96TO4u zn+GOdDYhz(lGAJP0VMM~=OB=`PW~D zP~K=&U=Q!+s2u(0b&t1{LlgbG!K`+9AARg|yRNla&2`W?Nv-NZRAa2*>Db4~3G~k7 z^!7N5T+5TV?TF5njrT29y!>ESt{H0UJTA*aUwV=QuW=&JtGbwL-p~K}{Xl^%yEw2_ ztglwD$b1$_C#QdY`fI34Lp*`SK6NpANrRIMO$Q1}ejv9NZ^9sT;wVJqf>siiT`v$Q3B%bOGT!E3)msum0`%lyYL@ZmmW>v z>O>W952xVV(omZt#cR@`a~!g3oS5aD`*S6*`1j^_u6>>xE2>rqq?2P`$1_(=^M{q> z72Zuytf&705E0i@wa-3=#tKVHnFuJ^)HaSP<0BfgAL3;cV%qU_o8B6N z5Xj1B?|IRMZ@bELGo-;zRa-_i?in_2Y+s}~Vos>DqIn^ZV}(*YTamL0dUR&?tb+Lo zY}dAb7ql4DQY4=wB-5rNEgi4K;O^&vf|P>_)`5#fvv@I*69NkYSfV9<*vUjtx?*yO zgFH_!&YLgecyH(Yo?k7#3+EQ>QqneSnq8SZq(o7|cQh)}Q3>LAOVd7H*&8NiHWxJm z<#k4n_1EI_0YLaGkJ|6r!tks2#dk7kc#c&G{`d(Qi#%()zb>$UbCcW2KZB@nF>7?k z0r;pPp5;YOSSe<7GAmuu$@Udj2VRBl71_r>+a70-n{|8r?Mz%PGIlK5Q7ND)bG-jg zD+E|Ci_xQx`AN6F80Ri!W{9-K8Z)7eG1*1*LBjJOP5+N)%5>*vlpcWXa$k9C@?@ei zaiD(XCOi&&UquQ{?HYcJv!WV^@9`!(P{_QH#~Y=T&219D-;Tk$be;%M7*sc*{y5J(G4eDb-Vy&TyAJn4Zu z2Qsm8CrSGjUc_y~@4-i~yS>9o=XjI553L-8JAn}tr%2S}=fgDY!^qt4ZXtqza=Y_I z&^y%PBsf?PEGl+tUH2<^7WfdrYgIQ5($qC2%LjoxYDF$WJtyK0pIM*Mk2z_S zm^^HsWs$A!+cN3!{grH~(W#~1M(c^)U>vc&?{^4F1x|O?m}t^J9mVNVwq2Fg9(r%9 zKEu{z&6m8ShWd;WQFEP1byxV1E|Y(?ntndv|NiH9n$G6?G|D^mV4P3i{>g-eAOTJ zW}Cq51}KR!CYm)rKKLnBZbc`ka#D0I(qEbJ2}mGarcV-aT5>mz)NL<)(PoVKSf->E zB|k8&DsvmyjL;?-yNgD=MOOL~M;L50r>A)k_H{FAisyDrZ-VC&_}2%wH0I7xCGKS3 zsxn*Nv?m8OA-u>lr>A`N2Xn>LmnWuQDc8Q&LNmXaO0xXumhTE` zG{r1?NRUscs+!UjGF6s$JJ*~gY%EBbQn1LoA?>ozJ>{H&@}MS$g^n|X0?%u1wPtYk zHYc22f5xx86ukAjei&lD3SS&z=YuLw6_!ZVa+rC&2khcFLhCoL^j9Kq3@iNHz2?x# z8xe;eT~1N8KHna%OlXz~7cxPhA8j?EWOnlpAuyCIv+Pvl&mIf7bQIJxm7-LxQDOLm zXD2pjldc!AfnIom5tLHVc)sB`1ZOS2i~O}do^35r%9}E4E!3e+3Fur(aI-Y!TMRFB z>~p(peCPIdQnL&ZhF+=CmqFcO2+lLtnj)=AE4CAt70l0YMzWP_HyYRWipcxcdLKBA7m)FS7`pt0{|9vK##L zuwAw5fgW<$XXiQg!8+CoEFX)@fRPg37lX2yVvjI#(sBBg<*aL$KP1eRZPB zwewa|BLlkRKhH?sz@9taF$?co&RQDCCKluLEZ{xU^}IrdwA?PobvEV;TL*AhG=A8) za@Boid4Q^lN|Bpz7BvxWL8_rme19bp0?x)o*cUmumW7Q01XTR2 zM%je-bPbJlZ>)TefJ8qmXY+$Th{uMfJB!>X@hqB&02&%%{Qfs-dZS?i^u6jlgSDx! z-jN1$~QzNdyc~-^Sud;(nmb)J{MJjhCDSOUkh}i{B z2>H}@F-^mLPc+gg;n<7pB5 zA`Rme+jTVjmMq%i6#2!)ovbd!qnYb*sGeKuRbpo>0fL!jW@HMbC|Qx~=@g7XxJi<_ zZu`0Rcc-PK!I4NnR)TfNM4+wmZ1EkVId)b4a~+uZ&LrAelhASxtJgUdt&*(OPD;9h zS4pp9dRs3oe*FF#Jo%7l<5#cOb4Dgwi{Errx@ zX*=uAjJ2GjjgsOK=sjW9HNk!^XOqUW@8q3-*gNy~oqf<+F)qX7tp{@HbeewM`cih< z#ix3Vv9}NVobPMiifxOH)LtE~TCITQSeJWE*#!`243eStSI_vMB)reud7X+!Tg2$% zT2iFA#h05O-VRP3v&gj;X;WvqSP6!BWJCzVwhB0p^~IFgL>d7@8d$EZWo_qkL^cI} zmqusaBts{BUAKf;MGduJ&WkQ!Yu>zh17IQG=N^FIOAH|Ep`SRsO=AEA}C`ucd7 zD4f*mVHaYH9x&6*&orH113)O~js&Ta23za4LwS&VyYN!lDVzz$y`sYuCPlkOZ@Y7P z3H#OII|uXHQ@x6|@W|@Ds0TjFF6OiI^G3Hohi5Yxm&#lQ$RA&K)e$4~)Lf+56CwAM zH0t_Bs8PvQ9JEl}Y16qeic$`yu@!M6MaylW_~@dl=gmAfe6utvupgj{w4bd8b(%x@ z2EhB&?N8UnEA^9u+geM6^0nOp2ZcKK`h4fbB9R-Co>G1t>qPBj5TT39`O6|d7-M+t z8|D_7!3$CP1KQhPDu>`cR2&Eqr)+<2(%609ZOfzA{T=;r=IqSHrE?F{+5tE2DS)T^ z<_04fzrS5(KKq7xV42f*-?Tlmxr5&-ZU;y;ceIdu&x1vEi-Q1GbYw3Npf-CSNZelKjn?La=*u1k2xu0Tgl9?V@t#}HM*u-y`^O9Bvr0ikH;5Q+?F)wDj zvYsLwi8CUq0nXDFEB_)?29wO_`4nlB+M@$zd|}%l#^x{XLF*Djymu_P7S)Jf@m@Kn zFDEvETqe?*--y(Mr!FcQ@dVZ#kiGp!()TYzgmMm>&lyLr4W?$fkA5`{=MLjI@3v8r zI*ZLGZJ>u@B?vsTZ` zWpEzjl{0<$wTY?VV@C`gO4o-=+)FR{6VSk4UOqCIxK6EbbwO7hMM`6wLfP~l^h!17I(TYpTtTd1Qt8x9`_)kLdrbs8h^MB=5$tj z0we=zGRr)lHDJ-lCB2_A-}Dn3Mh)k9KgY%JXEuKdS2EdlyV?}&cc6e(t!-~GZXK&S z$Zw*`c5RyNzc&|prX2#Q_mJ3iT>>uJKkRpe!lz%1jwfSJO5_U|CBdEb@q7A zeW+zgrVO4h%ud{D2;+-a;w9_oD1Vyn;>XsH|Ot=G+$xxK!+6`u4J?R!YFshmuI0pDp*4 zm)T`l3|n?zsYl<`5_~9qGf_KWwIRJdQU>-2aCafS{`{%da0RnW{KD7ICPEU`Q>Q`h ztOUgUg@9yX<}>F~5M{R=<#k~9Eg8jK4+kn)FXhzswzrxABY`;TS${nvS+vV9I7T#c z{p@hwrRIsUajTMG;@OIEOnsXBXaEx{m%3Lj%WC=0Ro%Szx*=UV#rz*4gNsEKN0IB_ z`@eB&5;Scshiig6n;Oq~)(|5&%z2nSm(q*bAzP#TmZePd|IlcB5u#}Qbs;Nt=y}P2 zOTXX{9Vnq_G2uMoY&=4M@xoQ3qz7RtR?))?sL4uE@1|}&VDz?CPDYlNMfa~SmrAwC zc(*L)*KEuPM=Bv|e?H~c4qFF_y;^;Vsa>omL<+ViXZr>@v-j|JIiJWOd4s+#(2ql)#Is1CwZk(Z3;lUY;W3Q;o5 zc|f>H<&@R4XsX(fMUExA!~%g_44o2@zq1g0opy6BAz;P3)v>siJ-4pUh?HTh_UOwD z)HZ1X&U){Vkh2gbW}<9DW2WMdGJ?Nr`BpbmQ}YNoq)M{7yjvCXVaAE)>0kRKWC!Ej z*-Sd16M@4Am)4)} zqSD5SE&rV9Gx5{`d~HVEo>=7A-la)<8Y7V!_^?a~e;Cj(Fn@6CK@AOANHwY9{?RHH(2+()=FT@(b-)(%$wx7wVTDj)7Tplid~JjQ$dea z+0**rea;=biwQ#;m07`57sg64F4kg013>JHJnT=wE{!z$6F%ufq7gbp##^WiBWxl5 zSB$yz&gjGY3Uop?lR<8>$+{8RF&d`X@isyZ+rLH~B4~i+ zAfRdEb$Sfu6k13L&Kw0P0|e>2k)qq*_X?jxzO{uH2m1|qjDwAQWJ5}dIgUT9(=H2>iLa2w)yshelxd8Hzol#;31nb-T@>1A7K^P9Eed@-K)F7wV(&NfT zxdN(runxKw1u*H{#y}ugq67Y@Be%KwElb}5^j#pb+Migd6T~jlY3gupjh4N1RER<9 zf1u8Xa~Nndv${&2p;IC`!)Zd!6VQ&dlS6Zz@z6pu@?7UAV=zfCg>28*bm!~(G^W0*b9UGocve>gXk6Rm!yWtjz=h7xggrMP+>6u| z)R}y&T$datfz=N1n7ibjBBEgaVsWf7lpovu7y8sxl4m9Q+n-{Cd3M@9b9I*_!|;=9 z2wt2HYo=MoBsF$1x=}g0dTXX{4a;L%$5f%d_Iyyxoz3vTr>T&~k$PDkLrP)@c3CP| zXMPi#^Pbk9u1%%ZnhH-BW(SI)Vcs`Gs$pW$YqWOhfYBz9xl;UkSn`JQfeb&yvgQz8C1a!dq zPlEI+r)5K2H8GD9@zV!K+}il70T2h(JIj%QMZbd+BV-5Yx*=&nn?e5^tkN4tDEHKgeb4J;6;l2J>* zAVxl*s*F+`|2^yZu)B8Slvn%jeR$Gz*%9cbFd>u&zR1xivBb@*@id1`tCJD$R;GFk z0DOO5@$*NxL}&$JrUlpt{wu=(NrPo>l}!kn==kAuQ4&xswf1$-@~IIZ-)+a2D_4Y4 zt;!55bv9up(x&-r0N|u5TAqYjR#?OSB{76=1By0|SS~o8zk<&;oPTYM;568c{={jJ zkW=mfV`jNmiDZ#%U{^4@w1Yu%-YnFYk*01hRO(9w04Wp6!3hh|P=8&EItHMbKx$BN zY)cNA2&I~6&|hboh6$kjfiE%IZ{_X%>y#P%@W&9G{uhR|n7(o}mO({L=4U{S-%3uT zoK5%0Z4+OX9jr?aGHri(!!xjp+2t(b1&q;$M~w7#N&rSuB=;|QFd<&caJtI1A8lp- zS&HFrfpanSH|_C`1kS3Le}>AeJc>SR0mw1L{}r%5J^D6tfXDhixDs$Ki+nhBMUa?u zUQ@TwFxY9$#}I9!LZzGr4iDFoDKP!iy3ebsG#B%p;zypJjejA7v90llw_^j5d7$dk zPJXet{F`tVmsH$%7|8EiQRG-<*E{?EUPSQhz^&FOpjtln1&K*pGg^*E=xi3PVi@#! zMD%oQR{vq(t)x`>YA?R)Ys3u~PRoR>yfmJNA5yOZbRv2b)J(P;!nW0b&>k-Dd1!={ zKCiJaLJ~SP**lA2KJr-lq?=!>e&e#UPu{z@+>u%)IlCz=a-aSdxjkP!m^qnjEVfax zJW<@GS7u9>w~8nLhG{Kp*K>Zss^p1J-+>+HJ}w)ZZ|T?lS3`!nM=v|a$GU9M>$_Ra zZ3Y|A7{KY?h--V8#t;3?(+)FNC{sAUi%q6Y1TT;}9XUflT>{8tNKeLsG-X$O^2`~l zqECLKg0n)JGt?n?F!`l^yfWfZLug9O=nODKGBZ+Z=U4SSBhb{fCYOMUnO<0&vdmWh zbbhr88L)|J%4&#x(*K}Wg7$|K>E+WAx|7NnaWxlTvIcOrB*2*gwqa^##qFztGQBxg zG3L3}FItxXhj!oCvM*-RDMB*mR1Q;`UfwG{eDvEqmuV@| zf8yD>SC6tnL9H&Zx_LQn<5`X^WCGeI+Lqeeo`2Z)X6tsdOj%|-Kc*6=$3OyuYzjwZ zS9vPgRpyvrLy9=AK$pzqZS0$zPnFRE&Pt!L>$|PuS-8NtplN}j7ay~&imzg|JsBGa ztWg5_x_7&%Vxow0t#eDoNQrCct7YVRs6F5kf~U-~`a!@7wgYO^znENX^57;0r~5k8 zOP96r>>I&B}c zxuOYUYn0td*qczs0n*~)R%qw}#%j{x({EuMJ;sV1aHu#a>%Zz>i71FFv)vn>hJs)* zAX;WRgzgd6+s&_-2o+E*oND>70hgo(PhZVAc>4AUhL8KHq-j3j4OUDk8OAH6Kr!fi z*E}IuW#c8dl;u1WJv+i)LBmo9DX3a`n~9ustwt zbXEQ$`D8%Y_1oE6;$M8B= zzGvR%3xjmcksJ4|U5%`e;*0=98NHP_##4iMd7xR^$9HSb>}EX+gJG`A5l|%7OqPnH zL6~^U*++>Zfj{0PfO>j+tNm*+Lh3+mZVZ3!Dp!m@*jVD9d#R~^Jv%?##m}idx2oEm z?#kONRrBNDUI4(an-907C(|OcMoZei$8&PI(&50e=RT#|pS*DJAryxt1a$r)P;?>| z@ETBfJ}gcAe&}RR=5pKFd1 zy|25EmZCQ+J`gY*)RMxxvJGndy^7CxEvl%6T<|Fy z@5#7c9A^*zYMjy}%}wWULTBYyn<3yLCV4FC0`qk5tWd>#s$RHcN}{cZlS3q|7HI6q z3i@XbN_*8jSs`iK)1ZZMHa!?n*f0dZ$4gbdwF?`467KPaE)zM6xizE*eskiXdqu}8 z+ed%YsRQ8?5mPwsY0`b1*dW=Q!bniP2=bQCCuMlPZ{-X~4ktsSB)v3XNGC#Fqn)Tgw!b~`nBIrN z)N$UKnh=1m>sd#P18nYsmI00iDNfI;B$L--Kp%Lc^!*}(P20$NK4|tJ!adMlNC+|(tj>4T%du~y)6 z8vnQBY|#a-uq@aI3zIU}*2%q{?oD3&pyoQ}N2}??J+Mo+&Q-N337Jo!sgDH==H$B` zwssya_}xAT(A!`;AJ)I>jP6y%F!}~U(u3=`bxKp7@G(kV#@U6z$}dnx-NC^hbr(<*rs!!-bp(^xFFq-Ukt;j?b4s2^_%NKye4DOyfRrOvY0 zO(OKj$}po;JKH>8O0XQ{@Uz~%r!&m5Zc{c+Dn^HK3oU6}H^W3n^X$e9aKSlu8yVDP znSh2Xc5D{ODgnCNZlth}Zl{IlucRhSEy|8p;*y}8o*k?Sw9lOsEr5E2^?57?-+OOY z5P62>uoUkz--%-5{~*;doF2?A=hdC2?5bmxeqvdrWSQrxcywr6FmpplqpQ28f(K&b zu_$v!jIq{wm?pv^(>BPo?#}DXpe@*Ynz1S}aT5@S5{*uiH(+%EUq0LbQBf$M7R4 z!57t;sAdp%CJ2<54CCe1+_wHqmQgJR4Jyx%A3SyQi04GLyET@+y#NJu#zbodSt0+0 zKpt_x2ON0!v4ws<5=S4Psjz0L?_!Z_u$`3BUzUXqCc0{;enURnZVtNQmAPTU?GHR`+rV0@GYr~V@OjTrn ze5>uGpHo$^tL&E!S&6dMn|#a*l;Wg(iF$IjTSD*A$QTn7y$X8=L#88##V^YAA?zQZ z%;89WozyA0l2W$>6)tsEKk3f9#pKr}nN@G`a;o!#sY@RYxyrzyB`zZy;`)b!zGYz1 z>Tbh-s=HgK|Hwe=Ip)`Fj#S=rDs`F7x?DP#YZE_wAfXHgie=tMO;TabB$<$nsXhs) z0eEf;f{hT$D4+E7Z(AQ;d6xYQkoe*%~O7^t}wi)``Yl@H(~2C7yN8=J9}$7^6fXELnk-2N!x&Bg)R;Pc0XL{9(D$w zz&fYO-&{wJo8W5S2Ik}?R^vYRInsTX!LQfZ%$*)}H6{!5WfQ^kiOU|A)q#%=Dc}0| z?I}X^45Aj<^Yc3}_Y==QBqyr5Hf~KiW3ik*6G@gDPPc5j@7bRUJAdCgLHwCetm{ChQulay$=uiHdmy_fhs7WnDX+C`;Z8#p8ib&Lvw&e0LL|S zwpM5yQ8oA>9_~*Et}OEB%O^s5YmvR_hVI|bJ=_1I#6@c4^Ee(Dtq4#!(k3mW1QAn1 zL_<8*>a7qM6!KZjYVAB}K4o=p)0tTuAh1dVwaIO%HI=_o-pl{<*+%zww*Aj6=TIi%^a6o$ z@0ZN4i@qDbzWV&MpQ~AyrE($rUOluVIk=eGD4W1k+! zcz-_*$bI!nEq}MZ{KL%smYBP)eme6^8;=@YhP(9AXkAxSv+ruJB6p^F24N7cwgd?c zd)D3|Zk;RUel$m7-?zx}i=L@wq%r{ZP@;CQ8vCYxb?m^o0Wp_ z^e=5)d4A8N_iUSa&Yks8F@&+6Kwoati|_}dpUoB~X08#?EIvI}hY=JG4eI^oVW&+% zH`^UY1TF$$Ct%u~b~W7CqZpL5ZskYnO~0A5r$>8?H@Okhc(-dpE0Bje~KD zGK(?5pqfVvS4>+GVei-2#2U{%=a4jA4D7=ntTKXx$Y6vdqw8MeCTA;c-JU)x76u!m zJ>f2!wjF4~xq9_#pc^5t6Er*-M%+l=YUp(|G;s-Za7oJ8CAb7i$VUyhrgX8Bkuj`h z9jL~ph@%1e;ppQPogt)fs}dK#;?I~AwdG~tUyURCs36;%vMQPx(9J(%m(^b|bzI$V z$J8Y7(H@R(cYs`0g=S4Fksdu<;sWjPl~1^#BP9SF24_U;2RMLH*z3k1N37fPcnVz+D zf71*$aL&%|IsEyZs!&$~1;vdnyQ`oI@w53zpOMDANi8gW{hH{Bd@&A;4b>pK+$vhAg~hDBAUUnyJd(*O-p z<~lT2aw|>0cJ1fy`iIeL4SeZ8{@;leYl|+kXPxX#brUDrWzc7~EjTEdYcb9ygH%Ub^0NaG zTI^<89k>pxP38s#hey5_00$|#*DMGf?`8a1Nlgv> znHjRW*jL_@*=okkW$VpDAtcs0AgB{YYDt%9LwSA+>G5{x2gKvpK!?d^Ld-PZ#oI|* zi&J4T>YyA9nlA=#H6JE`s!O_Id@Z{YZdH03od5C{((wpCGn^~8mb&6AZvR5H(9Tis zu8U^H+u$1`3*=8Yr}H@$ayv$GRl=Y9bImmG)qLBI6d?3_FP)=YjB^Ck5E`q852JlDnA~2m~kY5l@ zF_IQpw~PPFtUG$+cMjkO{cx^V*C7`c`u#O#84$bXe%G782`(jev1j@)?t#5NT6W#@ zc_BD0#+dhP_6e179@eO*VI{5nXZMIcS4`xVO)QS&GQy$biDT&(*vx0U1oS>kWrA+S z)m~~Z=xPE*(!U{j7$CQy5-|#_%-!Z|F%Tt-Jh$%#B8b5C*_pnx8CrlNf`b%VeIsIn zIE0z(!;Se)`|YMnI{e!Uh>jYC7jiIsvWn)0$rqmgpz=;2eM^(EQso6ViTxlmafvj5?_X>NJow(3!~g z12V4pjZ^@|XlN+MD(!x2v<0oY$T#ygNWa5t{ju_9Zb0*6=g8HKL_Fq_W zaB)qyetD4JvE`=D9zKq+asuxSB05q$pw zptzNzo=vaBc?RvMCS4Y=~Zte{OdTw*|pH$=u33Wi)fT{>if+;YkHx z+kQWeIIO!Jem2^6pgO3pH74VE=CpZkS4$ENCqA-QqzwytB1TEo%Y7J>=v7`t1<-ZLkzsVhZhcQTy@MZdzI^r^k3_V{A(K-ws zjGqk~)FZ!?+ocCOY7OF*Vp~A#KzXTcMUnexiXCW$0ETLn_pCtz>FLDHsm9QzS!y{m zD?1p@b$RYKx9nC;eo2(4>@aO_4eh_zdo^ATrJiPAqxI7FcXZm@?s(sta@Y9R!ZXnU zayK$!P$NDqt>224&)tb`9WA4=~>h0yE++2qBg^yS`CBoHJG2Zi}kF8t}wc7~QZY z6K+)4y@32zpV`bv$YWHFQbpJ6{h)QpVb^~(pA{G)1)@R2{ln^EU@BWXab1{y_o7w! z@P8e>*?J`bfrh^VNEA7S5h~yTAGj+wT;lOWv}xrC+?s2^rR43*mG%iY{|rrt}dn7zIi?-mFL z6@n`QU%6KV)CSCtVAvuG7P5D>_b6xZ>?9|RaIZ&vAnh<#I;dFGOfroB`>l_mv}n6$=*bq0@XP#Pg7dWNy^4kb~C6PVPLVG2nEHoW64^iQ{v<2Q&%2 zo$$W~@wB^kg4PzVjMEDdNdmWRese1pN8kjNGuA#DxQ%kp{59T+HR;LliJd9*hc9br zOcb8XIRlS=?7p`Akr2l!JXW1t6jUgCLW$c23oZn8d1@Ky*@5P*SaP}^R$XDo=p-fI z$?P;jt2+6BW?+0i?$2G2$6w>4I-U`g+U-qEV)*eoY)%nAxFR`zsv)`qBxD(b&ua!bt-<#!sCf<=l_6eU9ajHGnxtX4ou4st_M$X zQt|hdo%1@NQCLDv8n#?8AoxZ{5r_0Wukgy<;P5~$yaPchY%^THhbmc^>pJ-@Zj5pU zoSh>?$Cc-{V@tb?-RxNggpD|(w7G&O!b>3fkHzutNCwxiH6z-#_~S4Zi$jg=qcD!^jK1g7L*L{>7tmgniZc+0MB<00xiG#}%r+ zJ~uCrJlq(P>gTDWZj)=Hi_hl%U5(?)jPKY-FzBHTk=Cl(WVr=VN3VcX0M2?xm42_^6WhL7Ky>G>R-v8j;}~S7vW<&PtriH9 zKil#|>aW|Nu4ie~xcfC2aJReAb(hzxzZ`8A;NV3u@}-@n{M~nFH8Vm$S8n)7I7RLPCw9B!m_q?OUAp`^I?3xZjWa@18sE`LnZk#?IPnt!F;ZoX^~Odez)W zYP0NS2!f=HFJHJ0LE_+4^v6a~@L>ou5rLp>knx4HR`-%;C?VmmmQw23iu+N9TNJF7 z{9#d&z1**9o3E>Ezi_qNChw@n^!))F?;bmcAhf5FN8a>fo3o`$P!XkPvq#TpaGBkP zvkhVn9Ul9KTnMLw;8IPuen0p7Bo|AXe?yL=wg#Nk6^74;r6#rY<~!oOJm3wkZez!e zA2$J0$tkfdK6bTs{OZW!k1t?m8L~mpw3Ekyuz{m45cIb8+n)L|rqd^fJ`dSalRXf0 zv>@x+MOT4jT2Rw>1E1R`A!q~nhNvdt%QoV8g2iaFGX!nSy|%%R>H6c_tBR5M-1{QX z{tpscY5T$oD3%*u9lYbo*$F{cpUa+($Q=kWYg`CP_CpmC1pmlmx^$p9 zFKEyDARp?)DnI1aj2w1|Ms!|$5+(BwMeZL(NHulWK8>2dSHY(;ZU3 zK@hte3DJ?HvUb{qO)~l6KGZxoMjbrnLa(z(vc*L2nWq6Wv1V4O$Z>mbW^bt7`e1vO zcGj!sQ8#Z#U+I>eg z6FNbxzFUrsyW_*I%zHYEfOU$yr%C$FPH8AV@5v>%VsA6^iPhn^qsupSe?<`zdAHji z5YeovemDH>%m?f1-#~U2ahM%qMXPo&YN!gEx_i4igdtRY9S!%FWZgmttmOguQ2*cS`JhfTw$b_Kmhk>w4z z;Z0HTN@UV%?SL^r+uM4tuTcJR#pCiFTN6}}Bg>{!S3}|PD)vdX?HJ~S)>a7edM`Wq z`>$KXmei7GriseF_CwnDwO^*1o0=pZwkG%k)Nc#gR^O6Pu);zJs>2(iS_>Aaq4c)L zEbDv68zZITVHU8&1Qk!qq2u?pJIl?cL6ieze9P#H0LT#B6y7J#(O+a|5{M_c6KW*A zsWLL%VC5I9^OLKjd|5z8et+gS?DWcf?)dA+O19TMBwlNGf2a%!y&ey13Rm-6ZOrLy zT#)z&koEr%bNb}C3Rr7SyTHX z>?=H~=uBb>DR{cpb^dmxn3leI3*6z6pXp z^-#(CCn^Q~sJY^ziGX4)KG^y~??tGczDB`c_HhX*bC%t+A%^xd0)Wbil zPi?xw_xm6M8Tv`>u8+U;$7z>?ck4j$lQIxX{2t6s~0ieLqrdUG>?Z3mV4Vvd+z#k@z9CCifqK5&EvoS8Z(Nm z6jXbjZt*4Zmlqjo-%=Kcqwt1E`2mfmF44+x17{)srECrbtO3Hm7p>h;9gt~0)-GEk z6ma#|`W9QiKM?m#<>hq+PnU3rM zf3w@gXwfuL%_2TNo}$T4I2fy|O(93zKVLG&ps~{$9YT66&{Gt}l%cxS1bd@c*#%ey zHHmqLOyOvAYUrvCEA82tF|!8Fb3a@f`4g%IM}*<00z#b`Q$#f7AZ2nF!ja#PB4j)) zL6HL2(Cq_Z1Tl_5p>Or$CGy%JRS-O~V%mv94`*0}CU=nLZcaLPx`vIvXzUE?a@EYC zYLrOYG9&jdceD*n!E{U{@|~BXJ7RPe{vg9eZHO$i&=-z~|k5e#f{N7b!Oc`_{B`^{754XX=e z1ey!oz65{9Z36+HB~+649j@&ks#@sHR}Nz)Wd>6+(A8@#s-(`^kcAp%S-pF!jdTQqhNGg(dX z2nYCTov}gHRMaA^YqA6N5a2Av#6@Bb!?6fqH}o69D5|!I^0qhXH8Y(o+^pvyt9n|+ zT|=;nbHXw*gbKQMX!8>-sP@n!CC#Po&tPhSmt}L&!|KQK8qaeTidQGSyDlr7d@CG3 zW~o+SOi)%<)?>V~e5T1OENP5sD`GE6yMLW?4O{$`sxnhWCwC#-+%j3U0oP?aG16L< zjy1uJTjjK;R>qiBoVwQOL=|P?_-J$|yH%zc0g-Ztt^padLF7)Xl!!D=9x-kQ~--+tC_Y%$qGJ=t#l0 zW0C&s$HXw!P#uQ5rCrDj9`x3^P1%W_dhPnetAY~D_&glON)m+8YxOGzq<~;M0~hy~ zp5w>CzB|3ao9yu6J*F?|Onnaw8TpJJ#^J7k6lx~vr+m56#g;SLCs-M?*cW_*ZAL_48(KUU}<@ ziaW!)-t+T11KwoEI(}Z9h5ke-2ut{n`;zHrxl0AC7qLk8SWAA5fa=y3TJB3dzx=CY z&?#)z8p~xE9KsA`BMMpz7kOxakh zd@t4CwtwJ=nc!L~qm>SxK-fJmhBS|v?7JtPYm=XIvNOGjO64Ibp_Z#bT_N4AC1x6) zZ@NvoKA08Mj?Np97I|6M4pLu}Xq96Y$$o7?jCRvd*6KIT8#(#2MyP*_REk2YeVfRI=2&Qf~K`c zrj&5YEVAQrno?KH`0zb(_h`37Duzxh?X)lRD5I1Q)-4Yt&#b(^7~Slf6KwPGMv^(b z+=sIEqb@T2FfXkIw?cbJI;NzA*I>nz#&q@57A?d$LzqT_;7k~{>b!~YK+?kw-W$O+ zS`XQ&Mev1;nEk8#;YDjD?~yPZt$-PH9eQ}BAzCJrv{n9eR%PYVN;GbcS;NY3M2~&7 zZzs{-$4^hX;x&BrZ_T~cVODbUDyXt&=I}{97Fr>#j%c$t>eKx%U%r%ttmZ}NP3XItIeXF_!#qjoL@y;XQgm@O?h zvV)Gh7it5Bub^UUX6%Te%U6WKer;vsjFKTj_GX83GNrjoL{!yGLhSlC6?F*w?~vEhQmSs^CN3#&ngB#q4C0^eBI>7hQ?>n@Ts!k-%AA9s9R>3P5=;E8p}siKb&&s# zdT@NSKVtgJpl`bwIKOjM6f`pt+cp5T4;|R(436iv)q^9r2~h3Evwtb32SDjV`!}q& z5QZE7yMOS`_#AOPWqvBA4iyDFBRg?DL6tTn9(}ZzHg+j>ZU{!`5Dery)(jKT+IJVT z?u$ttis{tj3?t?*Hsu!7c9(k(f`S$idTqa2edDuzY*4sP8$8q1U7|hQx)0sXNnQKW zWt_IiRwM+@_FD77;N6-3bw!rMRv-prerZS(P6ZEbH&RFuv?i~%7GQ(8O_|9Fbu)?x zWZZh~&&3I%pKgUiKwkgL3y8#};nqg6M$*+`okRxhqiqKh0ERmFyB&xmWwA^Sk(*VMlj5;I){AhH^GyQ7hJSQRSL`+kQ3Lk41Si`Jtc|>qpq6~%qMfC-5(`YMtNE>#dZB?Z?93@7e+s?}Q5DeHr)JIy`~GQm zpiC#Y@5i-97`lVV>0v&X*hlg0&?D9L*w}B~ds-9I?+e2oQWNx*Hb%m9PEg!pznBvA z!iJKPCNLZds(m^j7gMpWv#;I{m+D*>J`S6be*(G0mPsYEvJQ4lJl;YL5%Mp}YX>U{ z*TlCBU-_cy*yqurtah*sukJ~$bBx~ICS-dqOe)!YT=_TkgP8eA94zS&oO0Zwee0_B z4!n<+GSR&Y#-g&mkB*KGaqWI}0S0WVxop5^K(B5cScZ%S&z|{Z&<&s0Ww7rS0%=qIaR4m`+EkHUmm%2Q zuP#YVgT?B%W&R{-reyJ$RVWm}Eb~8Lgk`_$GQeMfyI+5$vdAPZwCgOr(Z_rPTmdMJ ziyZcMO_@=u;di^?t>;NyK&2?o;y_6^;;;}S*>?M`clp<7V#oieVb;-AaUn~Lq>g=o zKSboUXP!&=fqg|qC{f(x>jptZ4azc9xj!75o42(OM_N==uK3SNMD01_tNU_80KMJ5 zCfHjaPq`W0Rx#kw60$IfGIGLUeCX{dr(3=}kxJ1YdrIXq4F=2I1Ln?3_TEHRRUor% z^6UVn$_t2aMV*)CeQun}1Cg(ucoHaR)67^H9*$Wl`r?7UCpW(iVJ|E3d(v}QE;C@V zj}^|UWk<`Xn)f>Hac~$4$e?@%ahp$&P%kV@;k*WVY2$X*R2ipWqpX)SwvOyF;`}4pzlBx075uGOye{CV<{zh zapz^V{J$2k@bf^cxRK6PeTb~M&w@ix{Wr_~iG~QjpDPRNt%f&^i^ZVvaPJT1Rjiy` z#ozYno=B0Q1a#>AMe@YD9&G^Iix)2@3RjvhM(;*X4@?YrDlW3nR+HI*N0w<1rNgJB zErh@3aYM28(U^%otaWFtMW$5&Nq}M)6j~4{-%{_cMWk_inhr?q)%_KvRG+c|6@enmE`P#GpHx?;FICa89NvM4lDluL)61=m&I0Bdj^F>nNUG1jMH%Pc z^K1uTsB>1YIgq8LjME%(Q4u8MQHOu$-ZOoA_4+o667)kGpopuj|3=i0F0s`w=U>Pe z*6atgeM0Tu`23xV%KhNI$EC7(47wHgou;fn!C8ErKlSc7BpOD$z<i6keT&--~Y8>+A|Gz{Lw59MSP#aN>p}!%UTRTWA>;@9ThJ8Xaa=x z_}z$sxc2rv-3Jvx3O?!qE@F?B{eoMY%}0lLLwRG1%*i%<(f}~UWq)!Y)NbIC+7*|c z`OBB{wyQA7shk8Z*B@!u97G+R?oKC|Ca9RcjeT|En3*u=B}k0_TU(XFb=vH)brm^{UnOq>KW${-hDb6mKn$^wmOjPg(?8uBwr7?3^b`Y+ zbjSCOA@MDyiFv<%hD4rmRFa|$QS9sP^{4_1_GRd1TKbW=`dtbJtM*MOoYFCm>)qv* zL4)wT!JQR8KWlA8k#e7kcqj}NXX*aHM%&olop~h0Vk@Fr=P#rC!yQ+| z&D6MhWAAB=>=S_*z6XvquP*&8yF>fBKFFhPVC;7n-run#cVkvoGrLK>V?DJtJ6+Qo zznLeeneDXQ7s+#aTQJ z%+-@oQ<-uLNgK@J178c^!=+Y(1i90@lEbvFfjzrg88xN6)ss?hd>yYU*hPGNz$kY2 z1ka8ZF0o^23k1D5a|2Qg2{#N(ZK>j%bNNok-_Lc-hoi-TEZ+HdCFV0~AGV2@;qX69)xnE?0TDm^`10|38 zmme8{KFss+U$>7ldw7bG0nY$+h-YWH)D#6H$8&>@sR~(}+ZT=jqTRns{mJX0xok({ zeMb5tb~R6LuA9W_QINTnN|4K!!S$CW&M}80)WWk>)yobb77jexsc@+w$ZG5oB-({&36+h#f)|X}Vu7pH^34U+>qN`H@T_n$cYZ;68_b`01O z8;jQe7w}Ki|F=Z_pGg1BB7GS&9{+kz{I_yF|G~-M3@3jdL;N3T`~!{uw%0c!LeOOi kh2iQXUvMI^E?&eS(ivGT1Fg+^;2X&JqWOjVbIuR`54X>ju>b%7 literal 0 HcmV?d00001 diff --git a/packages/shared-components/src/i18n/strings/en_EN.json b/packages/shared-components/src/i18n/strings/en_EN.json index b174a5c8d6..bf5e266af9 100644 --- a/packages/shared-components/src/i18n/strings/en_EN.json +++ b/packages/shared-components/src/i18n/strings/en_EN.json @@ -5,6 +5,7 @@ "action": { "delete": "Delete", "dismiss": "Dismiss", + "edit": "Edit", "explore_rooms": "Explore rooms", "invite": "Invite", "new_conversation": "New conversation", @@ -13,6 +14,7 @@ "open_menu": "Open menu", "pause": "Pause", "play": "Play", + "remove": "Remove", "retry": "Retry", "search": "Search", "start_chat": "Start chat" @@ -83,5 +85,15 @@ "error_downloading_audio": "Error downloading audio", "unnamed_audio": "Unnamed audio" } + }, + "widget": { + "context_menu": { + "move_left": "Move left", + "move_right": "Move right", + "remove": "Remove for everyone", + "revoke": "Revoke permissions", + "screenshot": "Take a picture", + "start_audio_stream": "Start audio stream" + } } } diff --git a/packages/shared-components/src/index.ts b/packages/shared-components/src/index.ts index 83f074fa66..245c553e92 100644 --- a/packages/shared-components/src/index.ts +++ b/packages/shared-components/src/index.ts @@ -23,6 +23,7 @@ export * from "./room-list/RoomListHeaderView"; export * from "./room-list/RoomListSearchView"; export * from "./utils/Box"; export * from "./utils/Flex"; +export * from "./right-panel/WidgetContextMenu"; export * from "./utils/VirtualizedList"; // Utils diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx new file mode 100644 index 0000000000..87d3515d0c --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.stories.tsx @@ -0,0 +1,84 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type JSX } from "react"; +import { fn } from "storybook/test"; +import { IconButton } from "@vector-im/compound-web"; +import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; + +import type { Meta, StoryFn } from "@storybook/react-vite"; +import { + type WidgetContextMenuAction, + type WidgetContextMenuSnapshot, + WidgetContextMenuView, +} from "./WidgetContextMenuView"; +import { useMockedViewModel } from "../../viewmodel/useMockedViewModel"; + +type WidgetContextMenuViewModelProps = WidgetContextMenuSnapshot & WidgetContextMenuAction; + +const WidgetContextMenuViewWrapper = ({ + onStreamAudioClick, + onEditClick, + onSnapshotClick, + onDeleteClick, + onRevokeClick, + onFinished, + onMoveButton, + ...rest +}: WidgetContextMenuViewModelProps): JSX.Element => { + const vm = useMockedViewModel(rest, { + onStreamAudioClick, + onEditClick, + onSnapshotClick, + onDeleteClick, + onRevokeClick, + onFinished, + onMoveButton, + }); + return ; +}; + +export default { + title: "RightPanel/WidgetContextMenuView", + component: WidgetContextMenuViewWrapper, + tags: ["autodocs"], + args: { + showStreamAudioStreamButton: true, + showEditButton: true, + showRevokeButton: true, + showDeleteButton: true, + showSnapshotButton: true, + showMoveButtons: [true, true], + canModify: true, + widgetMessaging: undefined, + isMenuOpened: true, + trigger: ( + + + + ), + onStreamAudioClick: fn(), + onEditClick: fn(), + onSnapshotClick: fn(), + onDeleteClick: fn(), + onRevokeClick: fn(), + onFinished: fn(), + onMoveButton: fn(), + }, +} as Meta; + +const Template: StoryFn = (args) => ; + +export const Default = Template.bind({}); + +export const OnlyBasicModification = Template.bind({}); +OnlyBasicModification.args = { + showSnapshotButton: false, + showMoveButtons: [false, false], + showStreamAudioStreamButton: false, + showEditButton: false, +}; diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.test.tsx b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.test.tsx new file mode 100644 index 0000000000..e590e8d8d3 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.test.tsx @@ -0,0 +1,116 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React from "react"; +import { screen, render } from "@test-utils"; +import userEvent from "@testing-library/user-event"; +import { IconButton } from "@vector-im/compound-web"; +import { composeStories } from "@storybook/react-vite"; +import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; +import { describe, vi, expect, it, afterEach } from "vitest"; + +import { + type WidgetContextMenuAction, + type WidgetContextMenuSnapshot, + WidgetContextMenuView, +} from "./WidgetContextMenuView"; +import * as stories from "./WidgetContextMenuView.stories.tsx"; +import { MockViewModel } from "../../viewmodel/MockViewModel.ts"; +import { I18nApi } from "../../utils/I18nApi.ts"; +import { I18nContext } from "../../utils/i18nContext.ts"; + +const { Default, OnlyBasicModification } = composeStories(stories); + +describe("", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("renders widget contextmenu with all options", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + it("renders widget contextmenu without only basic modification", () => { + const { container } = render(); + expect(container).toMatchSnapshot(); + }); + + const onKeyDown = vi.fn(); + const togglePlay = vi.fn(); + const onSeekbarChange = vi.fn(); + + const onStreamAudioClick = vi.fn(); + const onEditClick = vi.fn(); + const onSnapshotClick = vi.fn(); + const onDeleteClick = vi.fn(); + const onRevokeClick = vi.fn(); + const onFinished = vi.fn(); + const onMoveButton = vi.fn(); + class WidgetContextMenuViewModel + extends MockViewModel + implements WidgetContextMenuAction + { + public onKeyDown = onKeyDown; + public togglePlay = togglePlay; + public onSeekbarChange = onSeekbarChange; + + public onStreamAudioClick = onStreamAudioClick; + public onEditClick = onEditClick; + public onSnapshotClick = onSnapshotClick; + public onDeleteClick = onDeleteClick; + public onRevokeClick = onRevokeClick; + public onFinished = onFinished; + public onMoveButton = onMoveButton; + } + + const defaultValue: WidgetContextMenuSnapshot = { + showStreamAudioStreamButton: true, + showEditButton: true, + showRevokeButton: true, + showDeleteButton: true, + showSnapshotButton: true, + showMoveButtons: [true, true], + canModify: true, + isMenuOpened: true, + userWidget: false, + trigger: ( + + + + ), + }; + + it("should attach vm methods", async () => { + const vm = new WidgetContextMenuViewModel(defaultValue); + + render(, { + wrapper: ({ children }) => {children}, + }); + + await userEvent.click(screen.getByRole("menuitem", { name: "Start audio stream" })); + expect(onStreamAudioClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Edit" })); + expect(onEditClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Take a picture" })); + expect(onSnapshotClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Revoke permissions" })); + expect(onRevokeClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Remove for everyone" })); + expect(onDeleteClick).toHaveBeenCalled(); + + await userEvent.click(screen.getByRole("menuitem", { name: "Move left" })); + expect(onMoveButton).toHaveBeenCalledWith(-1); + + await userEvent.click(screen.getByRole("menuitem", { name: "Move right" })); + expect(onMoveButton).toHaveBeenCalledWith(1); + }); +}); diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx new file mode 100644 index 0000000000..c8a150e041 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/WidgetContextMenuView.tsx @@ -0,0 +1,197 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { type ReactNode, type JSX } from "react"; +import { IconButton, Menu, MenuItem } from "@vector-im/compound-web"; +import TriggerIcon from "@vector-im/compound-design-tokens/assets/web/icons/overflow-horizontal"; + +import { type ViewModel } from "../../viewmodel/ViewModel.ts"; +import { useI18n } from "../../utils/i18nContext.ts"; +import { useViewModel } from "../../viewmodel/useViewModel.ts"; + +export interface WidgetContextMenuSnapshot { + /** + * Indicates if the audio stream button needs to be shown or not + * depending on the config value audio_stream_url and widget type jitsi + */ + showStreamAudioStreamButton: boolean; + /** + * Indicates if the edit button is shown depending the user permission to modify + */ + showEditButton: boolean; + /** + * Indicates if revoke widget button needs to be shown or not + */ + showRevokeButton: boolean; + /** + * Indicates if delete widget button needs to be shown or not + */ + showDeleteButton: boolean; + /** + * Show take screenshot button or not dependning on config value enableWidgetScreenshots + */ + showSnapshotButton: boolean; + /** + * show move widget position button + */ + showMoveButtons: [boolean, boolean]; + /** + * Indicates if user can modify the widget settings + */ + canModify: boolean; + /** + * Indicates if the widget menu is opened or not + */ + isMenuOpened: boolean; + /** + * A component that is displayed which trigger the menu to open or close + */ + trigger: ReactNode; + /** + * If it's an instance of a user widget + */ + userWidget: boolean; +} + +export interface WidgetContextMenuAction { + /** + * Function triggered when stream audio is clicked + */ + onStreamAudioClick: () => Promise; + /** + * Function triggered when edit button is clicked + */ + onEditClick: () => void; + /** + * Function triggered when snapshot button is clicked + */ + onSnapshotClick: () => void; + /** + * Function triggered when delete button is clicked + */ + onDeleteClick: () => void; + /** + * Function triggered when revoke button is clicked + */ + onRevokeClick: () => void; + /** + * Called when the action is finished, to close the menu + */ + onFinished: () => void; + /** + * Button used to move up or down in the list the widget position + * @param direction 1 or -1 + */ + onMoveButton: (direction: number) => void; +} + +export type WidgetContextMenuViewModel = ViewModel & WidgetContextMenuAction; + +interface WidgetContextMenuViewProps { + vm: WidgetContextMenuViewModel; +} + +/** + * A context menu component used to display the correct items that needs to be displayed for a widget item menu + */ +export const WidgetContextMenuView: React.FC = ({ vm }) => { + const { translate: _t } = useI18n(); + + const { + showStreamAudioStreamButton, + showEditButton, + showSnapshotButton, + showDeleteButton, + showRevokeButton, + showMoveButtons, + isMenuOpened, + userWidget, + trigger, + } = useViewModel(vm); + + let streamAudioStreamButton: JSX.Element | undefined; + if (showStreamAudioStreamButton) { + streamAudioStreamButton = ( + + ); + } + + let editButton: JSX.Element | undefined; + if (showEditButton) { + editButton = ; + } + + let snapshotButton: JSX.Element | undefined; + if (showSnapshotButton) { + snapshotButton = ; + } + + let deleteButton: JSX.Element | undefined; + if (showDeleteButton) { + deleteButton = ( + + ); + } + + let revokeButton: JSX.Element | undefined; + if (showRevokeButton) { + revokeButton = ; + } + + const [showMoveLeftButton, showMoveRightButton] = showMoveButtons; + let moveLeftButton: JSX.Element | undefined; + if (showMoveLeftButton) { + moveLeftButton = vm.onMoveButton(-1)} label={_t("widget|context_menu|move_left")} />; + } + + let moveRightButton: JSX.Element | undefined; + if (showMoveRightButton) { + moveRightButton = vm.onMoveButton(1)} label={_t("widget|context_menu|move_right")} />; + } + + // Only render menu items when the menu is open to prevent focusable elements in aria-hidden container + const renderMenuItems = (): React.ReactNode => { + if (!isMenuOpened) return null; + return ( + <> + {streamAudioStreamButton} + {editButton} + {revokeButton} + {deleteButton} + {snapshotButton} + {moveLeftButton} + {moveRightButton} + + ); + }; + + // Default trigger icon if no valid trigger element was passed + const wrappedTrigger = React.isValidElement(trigger) ? ( + trigger + ) : ( + + + + ); + + return ( + + {renderMenuItems()} + + ); +}; diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/__snapshots__/WidgetContextMenuView.test.tsx.snap b/packages/shared-components/src/right-panel/WidgetContextMenu/__snapshots__/WidgetContextMenuView.test.tsx.snap new file mode 100644 index 0000000000..f60036c561 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/__snapshots__/WidgetContextMenuView.test.tsx.snap @@ -0,0 +1,83 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[` > renders widget contextmenu with all options 1`] = ` + +`; + +exports[` > renders widget contextmenu without only basic modification 1`] = ` + +`; diff --git a/packages/shared-components/src/right-panel/WidgetContextMenu/index.ts b/packages/shared-components/src/right-panel/WidgetContextMenu/index.ts new file mode 100644 index 0000000000..fadbd317e5 --- /dev/null +++ b/packages/shared-components/src/right-panel/WidgetContextMenu/index.ts @@ -0,0 +1,9 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +export type { WidgetContextMenuSnapshot, WidgetContextMenuViewModel } from "./WidgetContextMenuView"; +export { WidgetContextMenuView } from "./WidgetContextMenuView"; diff --git a/src/components/views/context_menus/WidgetContextMenu.tsx b/src/components/views/context_menus/WidgetContextMenu.tsx index ff74e25d38..4ba300370f 100644 --- a/src/components/views/context_menus/WidgetContextMenu.tsx +++ b/src/components/views/context_menus/WidgetContextMenu.tsx @@ -84,26 +84,6 @@ const showMoveButtons = (app: IWidget, room: Room | undefined, showUnpin: boolea return [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1]; }; -export const showContextMenu = ( - cli: MatrixClient, - room: Room | undefined, - app: IWidget, - userWidget: boolean, - showUnpin: boolean, - onDeleteClick: (() => void) | undefined, -): boolean => { - const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, room?.roomId); - const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app)); - return ( - showStreamAudioStreamButton(app) || - showEditButton(app, canModify) || - showRevokeButton(cli, room?.roomId, app, userWidget) || - showDeleteButton(canModify, onDeleteClick) || - showSnapshotButton(widgetMessaging) || - showMoveButtons(app, room, showUnpin).some(Boolean) - ); -}; - export const WidgetContextMenu: React.FC = ({ onFinished, app, diff --git a/src/components/views/elements/AppTile.tsx b/src/components/views/elements/AppTile.tsx index c56a23e43d..46de406d84 100644 --- a/src/components/views/elements/AppTile.tsx +++ b/src/components/views/elements/AppTile.tsx @@ -30,6 +30,7 @@ import { CollapseIcon, PopOutIcon, } from "@vector-im/compound-design-tokens/assets/web/icons"; +import { I18nContext } from "@element-hq/web-shared-components"; import AccessibleButton from "./AccessibleButton"; import { _t } from "../../../languageHandler"; @@ -39,11 +40,10 @@ import Spinner from "./Spinner"; import dis from "../../../dispatcher/dispatcher"; import ActiveWidgetStore from "../../../stores/ActiveWidgetStore"; import SettingsStore from "../../../settings/SettingsStore"; -import { aboveLeftOf, ContextMenuButton } from "../../structures/ContextMenu"; +import { ContextMenuButton } from "../../structures/ContextMenu"; import PersistedElement, { getPersistKey } from "./PersistedElement"; import { WidgetType } from "../../../widgets/WidgetType"; import { ElementWidget, WidgetMessaging, WidgetMessagingEvent } from "../../../stores/widgets/WidgetMessaging"; -import { showContextMenu, WidgetContextMenu } from "../context_menus/WidgetContextMenu"; import WidgetAvatar from "../avatars/WidgetAvatar"; import LegacyCallHandler from "../../../LegacyCallHandler"; import { type IApp, isAppWidget } from "../../../stores/WidgetStore"; @@ -61,6 +61,7 @@ import { ModuleRunner } from "../../../modules/ModuleRunner"; import { parseUrl } from "../../../utils/UrlUtils"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore.ts"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases.ts"; +import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx"; // Note that there is advice saying allow-scripts shouldn't be used with allow-same-origin // because that would allow the iframe to programmatically remove the sandbox attribute, but @@ -132,7 +133,6 @@ interface IState { error: Error | null; menuDisplayed: boolean; requiresClient: boolean; - hasContextMenuOptions: boolean; } export default class AppTile extends React.Component { @@ -276,14 +276,6 @@ export default class AppTile extends React.Component { error: null, menuDisplayed: false, requiresClient: this.determineInitialRequiresClientState(), - hasContextMenuOptions: showContextMenu( - this.context, - this.props.room, - newProps.app, - newProps.userWidget, - !newProps.userWidget, - newProps.onDeleteClick, - ), }; } @@ -768,21 +760,6 @@ export default class AppTile extends React.Component { } appTileClasses = classNames(appTileClasses); - let contextMenu; - if (this.state.menuDisplayed) { - contextMenu = ( - - ); - } - const layoutButtons: ReactNode[] = []; if (this.props.showLayoutButtons) { const isMaximised = @@ -838,24 +815,33 @@ export default class AppTile extends React.Component { )} - {this.state.hasContextMenuOptions && ( - - - - )} + + + + + } + app={this.props.app} + onFinished={this.closeContextMenu} + showUnpin={!this.props.userWidget} + userWidget={this.props.userWidget} + onEditClick={this.props.onEditClick} + onDeleteClick={this.props.onDeleteClick} + menuDisplayed={this.state.menuDisplayed} + /> + )} {appTileBody} - - {contextMenu} ); } diff --git a/src/components/views/right_panel/ExtensionsCard.tsx b/src/components/views/right_panel/ExtensionsCard.tsx index 5fb5a033ec..9f1d1d0e02 100644 --- a/src/components/views/right_panel/ExtensionsCard.tsx +++ b/src/components/views/right_panel/ExtensionsCard.tsx @@ -20,9 +20,7 @@ import { import BaseCard from "./BaseCard"; import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; import { _t } from "../../../languageHandler"; -import { ChevronFace, ContextMenuTooltipButton, useContextMenu } from "../../structures/ContextMenu"; -import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; -import UIStore from "../../../stores/UIStore"; +import { useContextMenu } from "../../structures/ContextMenu"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import { type IApp } from "../../../stores/WidgetStore"; import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases"; @@ -33,6 +31,7 @@ import { IntegrationManagers } from "../../../integrations/IntegrationManagers"; import EmptyState from "./EmptyState"; import { shouldShowComponent } from "../../../customisations/helpers/UIComponents.ts"; import { UIComponent } from "../../../settings/UIFeature.ts"; +import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel.tsx"; interface Props { room: Room; @@ -69,21 +68,6 @@ const AppRow: React.FC = ({ app, room }) => { }; const [menuDisplayed, handle, openMenu, closeMenu] = useContextMenu(); - let contextMenu; - if (menuDisplayed) { - const rect = handle.current?.getBoundingClientRect(); - const rightMargin = rect?.right ?? 0; - const topMargin = rect?.top ?? 0; - contextMenu = ( - - ); - } const cannotPin = !isPinned && !WidgetLayoutStore.instance.canAddToContainer(room, Container.Top); @@ -108,7 +92,7 @@ const AppRow: React.FC = ({ app, room }) => { }); return ( -
+
= ({ app, room }) => { {canModifyWidget && ( - - - + + + + } + /> )} = ({ app, room }) => { > - - {contextMenu}
); }; diff --git a/src/components/views/right_panel/WidgetCard.tsx b/src/components/views/right_panel/WidgetCard.tsx index 87f334c1aa..7ae9ae1b21 100644 --- a/src/components/views/right_panel/WidgetCard.tsx +++ b/src/components/views/right_panel/WidgetCard.tsx @@ -8,19 +8,17 @@ Please see LICENSE files in the repository root for full details. import React, { type JSX, useContext, useEffect } from "react"; import { type Room } from "matrix-js-sdk/src/matrix"; -import { OverflowHorizontalIcon } from "@vector-im/compound-design-tokens/assets/web/icons"; import MatrixClientContext from "../../../contexts/MatrixClientContext"; import BaseCard from "./BaseCard"; import WidgetUtils, { useWidgets } from "../../../utils/WidgetUtils"; import AppTile from "../elements/AppTile"; import { _t } from "../../../languageHandler"; -import { ChevronFace, ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; -import { WidgetContextMenu } from "../context_menus/WidgetContextMenu"; +import { ContextMenuButton, useContextMenu } from "../../structures/ContextMenu"; import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore"; -import UIStore from "../../../stores/UIStore"; import RightPanelStore from "../../../stores/right-panel/RightPanelStore"; import Heading from "../typography/Heading"; +import { WidgetContextMenu } from "../../../viewmodels/right-panel/WidgetContextMenuViewModel"; interface IProps { room: Room; @@ -47,36 +45,28 @@ const WidgetCard: React.FC = ({ room, widgetId, onClose }) => { // Don't render anything as we are about to transition if (!app || !isRight) return null; - let contextMenu: JSX.Element | undefined; - if (menuDisplayed) { - const rect = handle.current?.getBoundingClientRect(); - const rightMargin = rect ? rect.right : 0; - const bottomMargin = rect ? rect.bottom : 0; - contextMenu = ( - - ); - } + const contextMenu: JSX.Element = ( + + } + onFinished={closeMenu} + app={app} + menuDisplayed={menuDisplayed} + /> + ); const header = (
{WidgetUtils.getWidgetName(app)} - - - {contextMenu}
); diff --git a/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx b/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx new file mode 100644 index 0000000000..0589e07efd --- /dev/null +++ b/src/viewmodels/right-panel/WidgetContextMenuViewModel.tsx @@ -0,0 +1,300 @@ +/* + * Copyright 2025 New Vector Ltd. + * + * SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial + * Please see LICENSE files in the repository root for full details. + */ + +import React, { useContext, useMemo, useEffect, type ReactElement, type ReactNode } from "react"; +import { logger } from "@sentry/browser"; +import { type Room, type MatrixClient } from "matrix-js-sdk/src/matrix"; +import { type IWidget, MatrixCapabilities } from "matrix-widget-api"; +import { + BaseViewModel, + type WidgetContextMenuSnapshot, + WidgetContextMenuView, + type WidgetContextMenuViewModel as WidgetContextMenuViewModelInterface, +} from "@element-hq/web-shared-components"; +import { type ApprovalOpts, WidgetLifecycle } from "@matrix-org/react-sdk-module-api/lib/lifecycles/WidgetLifecycle"; + +import ErrorDialog from "../../components/views/dialogs/ErrorDialog"; +import QuestionDialog from "../../components/views/dialogs/QuestionDialog"; +import MatrixClientContext from "../../contexts/MatrixClientContext"; +import { useScopedRoomContext } from "../../contexts/ScopedRoomContext"; +import { _t } from "../../languageHandler"; +import { getConfigLivestreamUrl, startJitsiAudioLivestream } from "../../Livestream"; +import Modal from "../../Modal"; +import SettingsStore from "../../settings/SettingsStore"; +import { Container } from "../../stores/widgets/types"; +import { WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore"; +import { WidgetMessagingStore } from "../../stores/widgets/WidgetMessagingStore"; +import { isAppWidget } from "../../stores/WidgetStore"; +import WidgetUtils from "../../utils/WidgetUtils"; +import { WidgetType } from "../../widgets/WidgetType"; +import { ModuleRunner } from "../../modules/ModuleRunner"; +import { ElementWidget, type WidgetMessaging } from "../../stores/widgets/WidgetMessaging"; +import dis from "../../dispatcher/dispatcher"; + +const checkRevokeButtonState = ( + cli: MatrixClient, + roomId: string | undefined, + app: IWidget, + userWidget: boolean | undefined, +): boolean => { + const opts: ApprovalOpts = { approved: undefined }; + ModuleRunner.instance.invoke(WidgetLifecycle.PreLoadRequest, opts, new ElementWidget(app)); + if (!opts.approved) { + const isAllowedWidget = + (isAppWidget(app) && + app.eventId !== undefined && + (SettingsStore.getValue("allowedWidgets", roomId)[app.eventId] ?? false)) || + app.creatorUserId === cli?.getUserId(); + + const isLocalWidget = WidgetType.JITSI.matches(app.type); + return !userWidget && !isLocalWidget && isAllowedWidget; + } + return false; +}; + +export class WidgetContextMenuViewModel + extends BaseViewModel + implements WidgetContextMenuViewModelInterface +{ + private _app: IWidget; + private _roomId: string | undefined; + private _room: Room | undefined; + private _cli: MatrixClient; + private _widgetMessaging: WidgetMessaging | undefined; + + public constructor(props: WidgetContextMenuViewModelProps) { + const { app, cli, room, roomId, userWidget, showUnpin, menuDisplayed, trigger, onDeleteClick } = props; + super( + props, + WidgetContextMenuViewModel.computeSnapshot( + app, + cli, + room, + userWidget, + showUnpin, + menuDisplayed, + trigger, + onDeleteClick, + ), + ); + this._app = app; + this._roomId = roomId; + this._room = room; + this._cli = cli; + this._widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(props.app)); + } + + private static readonly computeSnapshot = ( + app: IWidget, + cli: MatrixClient, + room: Room | undefined, + userWidget: boolean | undefined, + showUnpin: boolean | undefined, + menuDisplayed: boolean, + trigger: ReactNode, + onDeleteClick?: () => void, + ): WidgetContextMenuSnapshot => { + const showStreamAudioStreamButton = !!getConfigLivestreamUrl() && WidgetType.JITSI.matches(app.type); + const canModify = userWidget || WidgetUtils.canUserModifyWidgets(cli, room?.roomId); + const widgetMessaging = WidgetMessagingStore.instance.getMessagingForUid(WidgetUtils.getWidgetUid(app)); + const showDeleteButton = !!onDeleteClick || canModify; + + const showSnapshotButton = + SettingsStore.getValue("enableWidgetScreenshots") && + !!widgetMessaging?.widgetApi?.hasCapability(MatrixCapabilities.Screenshots); + + let showMoveButtons: [boolean, boolean] = [false, false]; + if (showUnpin) { + const pinnedWidgets = room ? WidgetLayoutStore.instance.getContainerWidgets(room, Container.Top) : []; + const widgetIndex = pinnedWidgets.findIndex((widget) => widget.id === app.id); + showMoveButtons = [widgetIndex > 0, widgetIndex < pinnedWidgets.length - 1]; + } + + const showEditButton = canModify && WidgetUtils.isManagedByManager(app); + + const showRevokeButton = checkRevokeButtonState(cli, room?.roomId, app, userWidget); + + return { + showStreamAudioStreamButton, + showEditButton, + showRevokeButton, + showDeleteButton, + showSnapshotButton, + showMoveButtons, + canModify, + userWidget: !!userWidget, + isMenuOpened: menuDisplayed, + trigger, + }; + }; + + public get onFinished(): () => void { + return () => this.props.onFinished!(); + } + + public get onRevokeClick(): () => void { + return () => { + const eventId = isAppWidget(this._app) ? this._app.eventId : undefined; + logger.info("Revoking permission for widget to load: " + eventId); + const current = SettingsStore.getValue("allowedWidgets", this._roomId); + if (eventId !== undefined) current[eventId] = false; + const level = SettingsStore.firstSupportedLevel("allowedWidgets"); + if (!level) throw new Error("level must be defined"); + SettingsStore.setValue("allowedWidgets", this._roomId ?? null, level, current).catch((err) => { + logger.error(err); + // We don't really need to do anything about this - the user will just hit the button again. + }); + this.props.onFinished!(); + }; + } + + public get onDeleteClick(): () => void { + return () => { + if (this.props.onDeleteClick) { + this.props.onDeleteClick(); + } else if (this._roomId) { + // Show delete confirmation dialog + const { finished } = Modal.createDialog(QuestionDialog, { + title: _t("widget|context_menu|delete"), + description: _t("widget|context_menu|delete_warning"), + button: _t("widget|context_menu|delete"), + }); + + finished.then(([confirmed]) => { + if (!confirmed) return; + WidgetUtils.setRoomWidget(this._cli, this._roomId!, this._app.id); + }); + } + + this.props.onFinished!(); + }; + } + + public get onSnapshotClick(): () => void { + return () => { + this._widgetMessaging?.widgetApi + ?.takeScreenshot() + .then((data) => { + dis.dispatch({ + action: "picture_snapshot", + file: data.screenshot, + }); + }) + .catch((err) => { + logger.error("Failed to take screenshot: ", err); + }); + this.props.onFinished!(); + }; + } + + public get onStreamAudioClick(): () => Promise { + return async () => { + try { + if (this._roomId) { + await startJitsiAudioLivestream(this._cli, this._widgetMessaging!.widgetApi!, this._roomId!); + } + } catch (err: any) { + logger.error("Failed to start livestream", err); + // XXX: won't i18n well, but looks like widget api only support 'message'? + const message = + err instanceof Error ? err.message : _t("widget|error_unable_start_audio_stream_description"); + Modal.createDialog(ErrorDialog, { + title: _t("widget|error_unable_start_audio_stream_title"), + description: message, + }); + } + this.props.onFinished!(); + }; + } + + public get onEditClick(): () => void { + return () => { + if (this.props.onEditClick) { + this.props.onEditClick(); + } else if (this._room) { + WidgetUtils.editWidget(this._room, this._app); + } + this.props.onFinished!(); + }; + } + + public get onMoveButton(): (direction: number) => void { + return (direction: number) => { + if (!this._room) throw new Error("room must be defined"); + WidgetLayoutStore.instance.moveWithinContainer(this._room, Container.Top, this._app, direction); + this.props.onFinished!(); + }; + } +} + +interface WidgetContextMenuProps { + app: IWidget; + userWidget?: boolean; + showUnpin?: boolean; + menuDisplayed: boolean; + trigger: ReactNode; + // override delete handler + onDeleteClick?(): void; + // override edit handler + onEditClick?(): void; + onFinished(): void; +} + +export type WidgetContextMenuViewModelProps = WidgetContextMenuProps & { + cli: MatrixClient; + room: Room | undefined; + roomId: string | undefined; +}; + +export function WidgetContextMenu(props: WidgetContextMenuProps): ReactElement { + const { app, userWidget, showUnpin, menuDisplayed, trigger, onEditClick, onDeleteClick, onFinished } = props; + const cli = useContext(MatrixClientContext); + const { room, roomId } = useScopedRoomContext("room", "roomId"); + + const vm = useMemo( + () => + new WidgetContextMenuViewModel({ + menuDisplayed, + room, + roomId, + cli, + app, + showUnpin, + userWidget, + trigger, + onEditClick, + onDeleteClick, + onFinished, + }), + [app, room, roomId, userWidget, showUnpin, menuDisplayed, cli, trigger, onEditClick, onDeleteClick, onFinished], + ); + + useEffect(() => { + return () => { + vm.dispose(); + }; + }, [vm]); + + const { + showStreamAudioStreamButton, + showEditButton, + showRevokeButton, + showDeleteButton, + showSnapshotButton, + showMoveButtons, + } = vm.getSnapshot(); + + const hasContextMenuOptions = + showStreamAudioStreamButton || + showEditButton || + showRevokeButton || + showDeleteButton || + showSnapshotButton || + showMoveButtons.some(Boolean); + + return hasContextMenuOptions ? : <>; +} diff --git a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap index 7638b5a55d..d3dd88572f 100644 --- a/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap +++ b/test/unit-tests/components/views/elements/__snapshots__/AppTile-test.tsx.snap @@ -26,24 +26,15 @@ exports[`AppTile destroys non-persisted right panel widget on room change 1`] = aria-haspopup="true" aria-label="Options" class="mx_AccessibleButton mx_BaseCard_header_title_button--option" + data-state="closed" + id="radix-_r_0_" role="button" tabindex="0" - > - - - -
+ type="button" + />