From 64a2656ad7a337fa17c4cc61f52536ea1a25afac Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 19:58:40 +0000 Subject: [PATCH 01/49] pwa: installable manifest fixes and Web Push stage 0-1 (worker handlers, session in IndexedDB, settings toggle, dark config block) Stage 0: manifest gains id "/" (what Chrome already derives from start_url), scope, a 512 maskable icon, and drops the portrait lock. The service worker keeps the session ({baseUrl, accessToken}) in IndexedDB `ratatoskr-sw` so a push can wake a worker with no tab; the page's existing logout message deletes it. Stage 1: `push` and `notificationclick` handlers (src/app/features/push), a pusher toggle in notification settings, `push` block in config.json shipped empty (off). Tested against a mock homeserver and Matrix gateway body. Co-Authored-By: Claude Sonnet 5.5 --- config.json | 5 + public/manifest.json | 9 +- .../android-chrome-maskable-512x512.png | Bin 0 -> 20178 bytes src/app/features/push/PushNotification.tsx | 69 +++++++++++ src/app/features/push/config.test.ts | 24 ++++ src/app/features/push/config.ts | 12 ++ src/app/features/push/manifest.test.ts | 29 +++++ src/app/features/push/payload.test.ts | 108 ++++++++++++++++++ src/app/features/push/payload.ts | 95 +++++++++++++++ src/app/features/push/pusher.test.ts | 94 +++++++++++++++ src/app/features/push/pusher.ts | 86 ++++++++++++++ src/app/features/push/sessionStore.test.ts | 96 ++++++++++++++++ src/app/features/push/sessionStore.ts | 78 +++++++++++++ src/app/features/push/testkit.ts | 41 +++++++ src/app/features/push/usePushOpen.ts | 33 ++++++ src/app/features/push/worker.test.ts | 103 +++++++++++++++++ src/app/features/push/worker.ts | 61 ++++++++++ .../notifications/SystemNotification.tsx | 14 +++ src/app/hooks/useClientConfig.ts | 4 + src/app/pages/client/ClientNonUIFeatures.tsx | 2 + src/sw.ts | 33 ++++++ 21 files changed, 995 insertions(+), 1 deletion(-) create mode 100644 public/res/android/android-chrome-maskable-512x512.png create mode 100644 src/app/features/push/PushNotification.tsx create mode 100644 src/app/features/push/config.test.ts create mode 100644 src/app/features/push/config.ts create mode 100644 src/app/features/push/manifest.test.ts create mode 100644 src/app/features/push/payload.test.ts create mode 100644 src/app/features/push/payload.ts create mode 100644 src/app/features/push/pusher.test.ts create mode 100644 src/app/features/push/pusher.ts create mode 100644 src/app/features/push/sessionStore.test.ts create mode 100644 src/app/features/push/sessionStore.ts create mode 100644 src/app/features/push/testkit.ts create mode 100644 src/app/features/push/usePushOpen.ts create mode 100644 src/app/features/push/worker.test.ts create mode 100644 src/app/features/push/worker.ts diff --git a/config.json b/config.json index f98575d9..fea9a59e 100644 --- a/config.json +++ b/config.json @@ -28,6 +28,11 @@ "fileZoneShare": { "enabled": false }, + "push": { + "vapidPublicKey": "", + "gatewayUrl": "", + "appId": "" + }, "registration": { "enabled": false, "url": "" diff --git a/public/manifest.json b/public/manifest.json index ef0a3510..c28e7b41 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,12 +1,13 @@ { "name": "Ratatoskr", "short_name": "Ratatoskr", + "id": "/", "description": "GameCult's Matrix client", "dir": "auto", "lang": "en-US", "display": "standalone", - "orientation": "portrait", "start_url": "./", + "scope": "./", "background_color": "#07111a", "theme_color": "#07111a", "icons": [ @@ -54,6 +55,12 @@ "src": "./public/android/android-chrome-512x512.png", "sizes": "512x512", "type": "image/png" + }, + { + "src": "./public/android/android-chrome-maskable-512x512.png", + "sizes": "512x512", + "type": "image/png", + "purpose": "maskable" } ] } diff --git a/public/res/android/android-chrome-maskable-512x512.png b/public/res/android/android-chrome-maskable-512x512.png new file mode 100644 index 0000000000000000000000000000000000000000..211a06505160a3fa1f6b165146d779db27c6714a GIT binary patch literal 20178 zcmZ_0XIN7~v@X1W|dr>tSgl(gz8xM+9rehAo zss>`!)tFhSDQKuKJ$ph;U8s(2Ln!n2g$%bH55B_2DPKO;^$i&DX%l1BcNodW*YuZu zT_Oj-S^)q$n(!_W9FqdPn88aqjuv_Wq(z1P-;;kt(R~4SAJKr{@&tcF@VR0C+Nglf zq2LA3ffv6Cm9px;_u>DZ{8vZ+{f1f>=rwr`cK@bt7j@t0GB}vozqTJ<0w>t3BIm)L zM5)2=|DHj?>;JoNc}%u^V?8$ zsJ#G3{E&Q+ypsR(zs;Ec9|8ZS{@-uN3;-FcHubL&`@gsU=w;@lSX^=XAFZJ_3_uRJ zxc{$7{hKDg4P^(JaANSk$t4st|G&wFg1`R{mUQG$DKq?Q@jrP@tp)TNKK@S@!|qRk ztoq+9wg#(*O37DSQ)$kflC0%Z*Ani zP5nP!{Nei; ze@msz^WT{A;R$d;u7O|ruO9wOZd`o+_Y}gkkxz)mzAyAh`JR)BxX))1B#6o}tj&RJ zc^n8we9u_LEzVQXRRX(3T%3#KdHNcIb!1dM>C`s+X~GX>gTCjbUs-R?J&H2gywO$S zoqT_)Me}r|Su5O3p+Kc8XR|6^hu2I>sy4q3kI`?lRdQsxryIzWCXz2Kn$i>vOZkd& zNnubT2+cuc_*-zi@hH-X+A*K30E8G~#z^++miAsv;+7BAKjx7ZgYDqee2Km$^&~=Z zDKRwV4nD9%JEy@YBDhNZ8h#|{scWQG*Q|n|`$m1jJ-x@mSEg-OmEa>?&a_YfD5ws1 z_ZyN^RWi2>5O)aavo30zbeA-Y2(2!xM8H5^%EqLq0lCK-{`Z z-NGVs{Sp_Ka8*@RA3Hgq>M{>eKU?uj0-lL?pbq1=Iv?)5kE`<9UpZFNmZrlfYG@i` zodUV%Ax3yK`iL4__=V2TUFcHmo}cLj$&_Z%h~Ceq7;P=$UgAv+|2J*$mpm*h-)O9( z8!dYcg4WSJ-CcqwvsD!#nGp;Ow4CbckzbOxRLuhkjIs^TYzlxH=m;Uqe=chgT6FXa z&D*yvl7oz{hgUmdu%3TsT@Cz^&P|p{+bTKm$j|}l*qmPyALko=^d7p;35P@uOB`I> zPj(XFTXlOAIzoP#`cJh9C}g`6e^{bl|MW{dMhf*0(qLYVU?BrpElgSLjiPN)AyI+! z^MSluIWMRNW0i;k*Sp2Pn(5GLu7wR*JmxxG(-3gYseNQX??cuP} zU6=!#G!gslYv=2&q>9z)PY3(!dj`epEkY=bQQn=$w6QtziTOPv1C>=;+Obe1SMDPy zxWG(Q(E0}es)~L#NQNu*TM^5t(}OK>5jf$-NVWp*=0}fT7Dp&%>Maz0>$GItVcnw7 zw6(4^XTUWZKwJXE2W-qY(fz!#yLG8@ zV-#BMJX(gjcyMzG;yH=CND1sR(gC}sdx-K-%Um_%*`ULdw4)QN%xAd7OVsn79c}p7 zSC|;3zct-d7P0DUE*(DqB$#aL_X3tv^R9OL{=%IIyI6N;DJ8W{MmcF@^MNvLtmzFt zwDa<%S^x?_y_cQ7I@z9;_9>r|dQgLQZpZsgwI^*2<&7$i1076m-N%i!JGoMhaLy+0zwCAd$GdmlZVtZQka<(TdV5Uh$r|k+Yv&z*CQ53c z;ISWUA*qJLRZxL;C|QXyJ1#}sT1H|x`#5ObEGnE)((4!b`1rZ63U|~rvrnuMxsgUiJt)`phyp({30Kh-9 zMR{Pm&^=y2RBw8_5m9rMN$pJW;r9yFugxK=@8`dS-KS3ND{SKI&s60aOdbq;0t*)G z(X&bl(0Z8u_F1Jx>Z{w+gBCehw9C3h#aY1ZPz4%LxC<6(=Q~(AEK{H2+G1luyQE<` z+{Y@K#^npizLvu3MU+Ef52zo+1;3W~>TFE{fKQ@AbdM){L?egj*yMg5@n~ z*=J+cFCsWW=|lyJ{3dR2T|X2B8Jv;01~)VB9Q;x_{C-$%Oy)8?eBGH<)DjOxOW@3ddHwa zRN$bb<%*S}oK^SR(d}nrwcky;#NP%|W-A7X9houKTrmEfi#b2)Y!E~lieLF&v+%@| zDj~F#kuTMjI=V(&)grSs>eIbPOQiQb)j`-x8%i(VvED(AQQJCPzVqmxCUQuDP))G? zl$!oxe%wD`d~Z#t?~=NJ@}L#vm)V%O&&Pi{xPQS}MxTMI+J23C{X<9Uv`NKb?sOZi z@6AyqZ)w}9S)TFl+v%9A1n4F{@7g> zz+IwFxOl9?S6LTx#htg}Win(j65web^P%)kFt4<#;G|R>Mi|N-WnPPn$xKd7y^>?m zvUhM{dDV21{`8#3P@v~G&eL^cr0jOO_3Q2ZB<8F+)T{a852pgAa35*kO*mBa#)-2J2-BorqErTy zZN|Rk)@0!sGsKyA6>VFU)FY_^8ME!83@Rf4yct|6!;jmJmtyi%JOmvZMa^$ z22FRpE466L^(ViKvU>W88O}87Xo-W(G=U3VDK5f%f1Nk?f`sKc<}*OrnYvK^-8$xf zYh$EU>=Zi&#S!KrG!L~?oUy-KbqdtG-b&c>S|4(n{btE7d+lz4E*B{8*8i>|>^L5p zG-%zdp_|qAGW+eGPApV}&Z5Nwap%7Geywv}9*m^unq6uNJ2gI-?Cd>1FckKf%?Xz@ z7;e_;D@I2JDq)AR3IXdo_e#FqTjP_BWZcVy`9J2YziNBI_3WaYWP%&fzW?2C;n#cp zqL<-zQLN=x+(duRjb4V7ZT2|peHE<|JNhu+vT)?(ZDD1;z-I#4%%~x;dkF3*EX?p1qwcYEWb=1OwsgSrhi*iqyct5kv)Zr( zr59zm>2l$lZ-SRrxZ~QG;yuM(1)_a^Vvd&A%YX?V|H+$mG z%Ja|jG|#(7_G;`OWWxqGA?>02f}vG;2oCOQCI)IHHzI&lR$Q59yO`W{uy2T1UOE z{2sl(Tj9>0Cpi-KqErl`T1Kv4)9B#?jXz2T(Tuz`(_034i6%#SL`c?&R50QGRjF;~ zw0mT^31oTfqjF+s1ECc!E@2LCpYb-)+G!{8RK*|BZikzpsQk%#ALi894QmLfTl zS6`_N1{sa?Ks-H_ez#l)K4|VWG+)NlU%BltZ0Y0H$ekxA@GROXkd2jvvea20F$)Vl zD6bKM#oH|LWZ+>NJ(x8^*^S{Mas?8zgTV_EI3rjd{=ptM>`o8%p3V4_y#wu)##CU) z(A|g-Qe(qA0Q#zqpk*BUpe3iOlP@xj`3h0rEIih$dvJo(>>Xkqa`A=328Pn`6NAQ# zs_c>=#_FbZ$|2$|U-S1R8eOmI;(>znW565I@UFmmSQuT497y%k~Td^7iXbGTv9O_iQ)9}C4ebn)~6 zH71XR-Y0BjqIjfCBjP2)>w?hrUm;_88wYR=(1Z(m{bJt_E$=pp1#AIoXk4)r^|sZv zT<|-Iv8eagZN;I((j#V#UvF_^3(fUjTo?Xuk+)5~9M#J43{e6~zHTL9rQ^IQ5h;G( zXV^^Q)^lND-L!+U(z(W-+65mb3Y^S^A6de=kA<37_qM{h3Us6M)^2?|>A7*9(q9Yk zpQx#&vpCjSoS-ERX*)$Jsp*&XX&Jj|v>E-@v8jEmeD>C|22>_OZJ?)3zi|?zr%ZU# zDcuxEjJZP$%m5v?NBxuQZoG1YyY2?)%uNU;KZQ*RE80do@--`n5eBcznH8XApmZ3v zmUKQ3h9?&H*IZwQpq|b%#owS*e&+NFsm(P&dQ85*l5>3nVCHa$i11cbM{Mms-3Z;@ z^eO48M(4`dZ&*PVa#T=8XLNbPqvFr&Nh6ELr;CiNX4mg}JDjYx!3=dL9!b zD+t`2$=;+7hW6titvcMFY+MeF`taQ|4GuMv&`UWT3uxi2T=;9w%zffRFh+-)+eXc|I&8d z>AAq4-Y368qyhDh0JC=&%7-OtUE+88Vb4n?Zugvv-=mk;?Li#VcC;;U%o3hV!S{7E&lqYKL$>Ui^-uo7%b<#0P+_F36k6D|m z7gGj8^9X|C^$VNZB7$-u)1&nYq_L{bqUF5FoE%p75x&J5NR_1!u5t=IW z$Se#1a@Zk=#&3%IID+bfuAJ+qD?J__)xImdUc@&glNIHz&Q-ayLlM30RKkOq074T& z8e_5PUa;=!{bG)Lg)e_RUXHmT5kHoH(t>kZb-H1Cp&{WoKJhh{WVcqO=H70+Z3UBw ze%O7EaVM4^pvE=A7ECtlx$TV%^H01AZUE~?-{#7a zCEdTVs5oE5#l>*7=1G4D+@PA>@pYh)XNrq^cK=nSkh)C8ps7=TcczIMED@g7!vflD zy1*ujGBUW?`$vKpYbM3jeyTv-bV|xxT1sa59(Um3+%%T5CpjzVp{I)H{id`=1R*Pz zx9;WQS=KGy4H&x*5YNTxV8e;$RLV?>J=E^qbL)Gwzi%>O!HfJ5968NnUB;r8e5 zfg5M@LQ1L;CdkNn&N`?$N!MG{vaRLw71;bC-MYO@^K3SrR(9j`q$G&W@A!`TDk}IW z1U7eA62OBR(b?rKW7 zeWaoQuz?2y$S0h2T!Ia5D0CVm#!rz~Aynvez}Gv-LKM2d)MWW+@v$>)XrXM#An%pD z`!|YU#ltdJ6y4G`t_py08}lGVNBT*F)Aq>Pmyv0uNU0dmT{3;Fr9=6U6|)%#;gjRg zXL;!pw)E?Iae>PALb;kulUf+S{HKg8uT$U{dCy*gqnRYrIuHCKpwdBS#<;HM_Nj>`zT4w^sI*~s?h8fMz==My#9%OyU>9BKgh?DjLtj-Jzuf$AwG84UT{it}YV zSKq@so8g3g%%#)XUqH#d#ZBVk!pF&T(D}WU{$2ModvbxYmT66BigOK%8JSy=4w&=h zJtX5)vWpUaJQLYac-5Xp!MAQZZPjf&UoQh!$^pZv;t|WQ3`$#H;g?tho#WwaiZ()& zUV4Azt^w3>p99i0KuP?2(ihDNVa(B?B!E~!R>wv~7x{PVP=V2efc<3){(8bn14E3~ zF8L+o*XA#Isa>)I_eN9KWM`=>Ul=*%VV^S7k@96vcWs@gj9js{vHD5pyITjgVen5#ar6n>zpab?)neYQZBe{fs35xPsuC%f=b(Bs<(aaDKLS7Q+({cq!a>r;Pp3#9B_%74xs3e z+$c_~y~rnyBDZMP}qunusrVw17MU$`juyw0|!-%y6_$g*w zj=Y!H!K)Nt&;G8e#RLh{CE&jG3Oii3DH&!VhyjgJ5a&^ad{ahH87mJiFz0=_{MEk?787~mmDI$>29;PiGv4fVa8ttjont_bzB1>&WS{Y(2 zg&L9K+R;UB1DEV6Sc%vop7ywhmO>g&a?*GhTgNxHRtP(V3i-T85gIVyyeHbvL!GO3-=lLGQkwuKz&niR$x5ycBqx zR4lOO$cft2uD~p$kHTA5OeB)G#VcYJPM;b7oiOtE$6hUJ=x^IC-t_ZbJJ;AA!yb|q zhiF}qHf{ARsoVAo7SJ8>T55k5&nt`KFYS3Sq28A|i{je|0~XO+8U52gtnoha)j>kF z;cs!I>ou8M-!()EA7SF{2S9PbY=RoSsji$I55`K8W)4IM|wFk3RKjI$_ zWC&u+cE~FBz5u|Tlk)w8HY8tVpN|VQAe7~-bQW^mcRO>sWhHr^bb4mUu824DE+1+g z`a3~O^?Gz&uo`rBJ;}dTsO@m1tK4;JI>L@># zz3NKmvF{sy`J!3y4YEZ8d*Fn6TPAQW%hIZ5&&Kp{%IIM7o#Wqd6S`4rV~29gsCHnR z?r~kYs1rl0EQM&BdCLwGB|8jr#3=rl@N5L5mi`(kk#GuOTRoE2x5xIEd+T0vB+3A#=%3@z%7am__@>y_IR}ZsnuVjH!Le zxk8Jwg`(8-&K&XhPHgf0^qhw9tDJ8xDO#0V!npJmD2mWRV!Dic3ZVUtXfY#i>~c#EseS4_XT^8 z>kd2GiwxJ>PGA*tqg4Zg*WdFM{r35gBmhE_S=5C!Aw+x6oEO~g3+>`1FudwdIG@dh z?+Ty9?V#BXEe+SKXLR?8$*3U)8CDgj^SaP*tzr$EE2cHs(Uef9T80?QMv*%j!N=#h~2SFy92u)W7LI>o@#fCqX4`$DHXU*YSpK)D&^tsr4(9 zYRl)!;ra6hi{sU3L3b|+wI^uaOj)nLb!lrGAy$VQ?{yW5wU#t32V1E_xDnoDbHt}{ zcsa>3xzjaM?U~OD1xw3{=Pmc_1^7izn)?!bv2(x1Lx@lt^Lgj}t-5TM>yH5CO-iWm zkD0RgL+#^PQ9RnZxBwA%nqb_bav z)JWv=ecIsMqWvYi`fV3`QwTcGnz3cR38x-&gSIn(H6W;rKaG2 zL)9@am@y#=OiSJ32}^D8u1P8%o2YaAOW4tnpFIrn zhQn>X*v%ZG+MQas?MM3RWj<$ncP2hqC)yTXDwxyI6rl3NuWs@hr+T7jqKkrX)gQOB zA@oPdM_r^e$<+0_lHc-ZapYYsJPC9@L3~ombZ>}J!pj=LIV!_%4Z%edb|*q-TF8wA zFG6ctrvG-grG!hK^ELI-s2Hqv|Z;jc|t)pK=5E zs_dLlnHwO^ff-Cv>s= zX|k!5=Vc|-kT}1!Rtey|SBINA@jQ(k4_c@EfpkeVZBRzvnx>f%8?LA-f5%jY5H;>& z)ciH_{R)}98^Lno3q%5Z0smHaGJ;Sh(QEGo-?<4!7)fP6F*n zBelnem_L=y=uzZOc2S+v$cwTp*m7tad};}L8u2j-Md1X6>&lc+Vo{`070UliNbwjO z{@^^YBX_`XrOsqOX=fcC&aqq(=~GjR2rNjux+5+z)wUPdu>N4dxuOQ?{BA3 zeDzXfo4vM4OX125*PdVR+jpaTo1zh~mj~Sob)%Q`D^)%?*0pf#Bg}EPU`&Et0q(0l z;gPZPD%3a+vKp|v5|;nK6vO_#oe>yz3T4VxUpclLwA0~}u_QeUOvE=Ti-7onPKTfE zTuloT!ADc^)@Z+67s@qN{dfK`|HcvPXD{LLpy{Vf+l3jht#h|d9hv$JD}4y3datV> zXj-l8wT=BY5U81DFZPvlj-EguNwU7*Sw47w9^tL|>!U886p{%mpRu^wKw>%6g1KjY+^>84EW>qHK7R!nD8O!A7 z?a7%qR@Qd-rNlv@d+*@%xk?pcS{X142V-}=+o2Fq#j{oACn!^U76HqPk?b&>wXlMB z#%9%oQ3e8IcM$me1mw79`L)Cf?Z!-LpFRvlhue=YY1RE#VSh7)a~3{E8|~OFtasX~)1LXSL>wO-AaEI| z=5Q827whlWnbzit{#hp{XkK;9N;W*E4A!r#`<%94XD$93Kl=xkQZeY{E~DkGZVQZ3 zPL5l|Hh&-FJ4b#UmJ@4TY)7_AqIG?08jBd)kJO`7rhi91lZJ>$m=2gzD8s^Z&*uCV zKNx;TBFbL8OBxTGZX|NpcelVQ=xvfeeu*;(lND|cV5Fl&sV?8#~c{K6B?{%wCGJPTZ&If`unlAl2ZmXF4FB^K=h4<@_qkSd=uydGUTC(H{aLFr73{ zH%v;TcuT6IJNn)I((hTAk+Hhb8?A*(seMT#+4I9<)8@Y1ad*P!gj!S`Iml;aYJE-7 zQrZJao!B{qy42>_vr}-D|HaaDfavS#Mz&$h{G`c?V5=(ov(Y{?PDy7SMq-waN*Yw(8 zz+e*Y3LWTyI4(F6p}#rO1#%kL z*oh(F_MRJPiQghVf{j}vRx6MZD6VKoZ#YU6Li#A0h<$~=~qiNBF+UpFauL?RIfqT+`ezd z)|&4H zHw3uI~GD zYQf!G$a&aB9z1nUsBmeVIOxP*-e3mIE)Y_9fWy5q5Th|MJv-7%D0!vk%;Pa!(qPS( zWxga%!4D5iw#ecA#AgtiW$AyP!%ZZfYONo{t}=yHrGw>`^c?WUgjVaYWu}6%i?hUY zWHzNSm&&V&Iyx9@sn9VlLJy5ui&@YvryQ`JenPrSZR1BIR}%FkqEV#N%Pr+w5RBBz z_?fp+b%YS|?D}YbPNc3T(&+jSevB2epr$T-Rnc4I{;pnNVy{7SBDajMSeIOx<2~un z{e(%E`;(?7)C(c5VBDyg?#GSgNCfIW@}1#7pa1N;sI`fPW0s9wLMwTZlNzN8m zfG}QUY<8lo=u`EgFxO0gwtxc!@`>yPG*-tC z2F|@7U=RFtyjN}@g}?nNR=7plMF2Jp7niVyYrG3VMf1%>e$z53Vk%e%r!i6K;YI;0 zygCPE&k`yy2O2?>GU@bizHC(3hGJ_!CSB$_FM2 z467VP!*Bb31q*Y$>ls7Lfzirc(+OKQ(Z~LH$+3f3uU*0e>vJ7DFX7m-3T%%q0_LDR z2E)!fByTW8efpHAiQnDRLNYrY)D!hfop3j-S)c%PvhE>$ZSuSP6wN~r?04;Ryw@Cn ze|JR3PthS<@yW-kXXHdkhYHG`i59m?7Z7lLz4-_Br#xjad@hYQw+3C2feDX+pjbnB zlI1{H_;YrvCJ6cY1#bDrLID8Vi*@o~FCCrweA@2%TDk2W5_I4>>-e;QT@NNG`*Q6C z;8^H>3mE#cmG<;s#y34+T@M8CjD&#;5G!6(gF?`;if(4}ooHjX#B`mAw1b_%iht=K zXzT^u$ejso@s13)O|u)`Y#lKQ4$MHX?77@Ju)0WEXAPJRNAFL~?FU514|9L`6Izrh zDBGXRb8gT_d0Wpi9$=9x*x{HQ_s7u?``6SyN;%`gzqIhAng(c&^{5FI5v8*3PKAgjR@vf?QCI zhT1lB95f_c$iYcTbR;&9Z7}Sq9%_00u2wp?5W-`)!Pz!6!R|P9q-49+P&A$DLH?j* z@D2__#O7C}h+Jq+x~)AQ1a@n)>*E0dKW!4g#$wlvl3WMT>HCP5;V+|c1>f}7!Q#Xw z-<9l4d9NcmgjNpb?;z^9xf14j!@P>|zJ0$_!2XTf zW6U;s&&D%S%LO`y`tcOX=8IqCK1-B6wvHiZyx_6!?v0%zKk!oCQc0>j!Kf(l1Zt;% zl?l{O_F}z)yv1x&T6WTl6-;E~k2(u8?DeAGs}1+YAR~3K-JNXaug6R4?!Vgd+>l(= zIw*J?z57YOVd~~-?HKb9eMqk1PYLCI#11+yjEF{OLy}9Tj5Uq0f~Nzj>hNpZSx~gD zwL3yHx;2HFl|otk749!#NmQd1lMlYurZxkM=zFK2n^MlM7AU=I>Kgb_XFKfq<8r3T`} zlQU%QG>__cjo>rf{A$YTd>9pPkIr&l@IcJ|R=f4r>JZu9PV}?D)3IN@tkp!v9r%8t z&jt*@)V+(9gWjh;v@Ckt9=wNU)6*f7s7JbLk?Pm z^$SoYe<*H50nED;xnHgxOchgZiIG3xLR}ejv)hKXjkHSr`H#?6^i$<1(w?ca9Q>?v zz!+O;pDt`gI2y@YF*-pOCIO0Rc3@p;nY>92qrM39iNQx-HSJp}`r)FoD zyD?ci5T5+(hwhK7{T#F%_YfK$vn2-_dG();=hsJak?7eh()b+)o|N)%>&WzS3z(h# zy4qpZb+F7Q<&NrXi_lh+&iKk1Sar7>NcR?L<706&@Yh`X`9}{G<;(!x=APWsVQCTc zog?oB)p*VL{olt*A^QH3t*)dEy<28YK?xOK+hb;%+r5u`o8|Y>?E+uRwWUKyIQx|! z!LP3mo_cf&ss7{5whEMlTmGt)1}vsuS?5fgnpu_QfWoI+Mo1NAkhn}mzab2`H^-Z^ zTn=iXt z?>oX}C3rD+sxNa0bA`^MITJvav3?TdGjC~qeYs^orxGb zz_*woLLx*TBq~lg&0E91q)l@W$EkBn>7_Jh%tEq^LJ7NLl%!prNG144P$eK4xRTq;Gvi@EM)>5?iAu=1Aso#LAac^Le3kcU7gPE45K20H*s5DKNGQAlv`z-rPf;JhYWL}OQy;!!R|zZ20qyVm> zrYdJG17VjYx9$(n)od*V>sd(phF1aa1jABC=Xd=L)s5=+3wc% zd`HKL3mW(7u01zsOE&DxDcf=gDYRUDaQbqS)3n;DB^dUXY36u9;4}btX=eZ6k!jO0 zN18lZZe!k|KB)w$2l}0SWD^^KFfb1f)uT^gXKzb$W`geU<#Lt0xvTzjidN4z+6VN^ zGF=_p*Z|Oux-^qetli$X7G33-WN2pnf|&lK8Ij>ROqzR63YG%nw@Ic^-f^v<$dN9G zM2aW4!^khdsyC;#$2V}J6-0!R!|UZzAN@L#P2Y>#^L3p33Y1_zr+vK{E@qCRqR!{B=Y%1^N$y;hF>b};AG-5@3K z?S!=~p>!WA3xQ}1GnhvdE;ISs;_cvgNm0WH(qP_Ba!WD%DK|Rr-*noZ$5Ciw{saE@ zBcgfnNNYVmy_w4Dbe5>2thFgAVt7x#o$QfwHE>rI2eV8%a&VjqsFr|N#ByzRDmp*h zx>rNq{t`QHOwt+}Ss^va{(9y|IjMG`POU%NuEXaXs8s%HxD-j{=9Z}CDxa2*7hw!H zq+Wh<{Cn@#?$9sC>6409I1P4o1N-BUujx;1wGudc?Cpp%PGn;MO1KOXuMuue`An-@`t4U$a1f}!x{P-K>w3d+hCmr9T0p4*kb2XF&3&}F*oni zuYF%a=b@XRXdFJ9cHVVIVio1HjN&_|mws(wt9?}&-Y=_T{D z#7gT)H#VP51PcE$2>^>$2d!%RsPpycC_tf6mx905m~%7u3(txyD%|`6Cod@JsmEN- zO1MMv-J4*5w6?9svHS5%AOQWEzPB}%@nAWQ*vi?$yu)I58Qy++<;xQ2rvU2Fyg(L3 zg~Tz+=YXe1^&t8Un0XGXt95Vr^H}YdZ;^-vVjc(o_Sn8vK};7sfC9p%R|Kil#1kPg z)_2Qx4#f!Psm9CO)Uv!rBtZ)S^}a=#M9RhB6OL0y}LeY7jd3`;SDiE z)z1wnMRT-YC#M^KgnP`Fc*m?T()miRja<1}hZY^sVcrzwQS$MY=K=h^-vz+zL%wzU z{lqiRv{+HgH<*D^rGD}QGQ8%HL!!uWu=$(uf_D6BB##uZhP0JloQclZO_6{ z{BLE&E#U6!;Xq1I%K_s?Yn?oU?dO}dcUtO6b$G}b0gLReVPWEyJ#{Bliz=Gqltw8# zt^<=fMR9q1I*=tJ8AuAPvdS?5ds;ZYjTbyz#fc!D`}d`!izyIzWXAP}^tI~@GJQJNCUn}r{| zhrYu?0qj}OX(FHNW#6 zTY+UOq}Rhb3BS%O-=>0Q{iL|@!w{r#X?)1Aq2KxmEGc5UTV{p)DI|8#2)H6eG;by& zDWPu0TZfr%MJcVo!+;Aa^m?E4@Vy%~2z?|~?f`jHKCB^xkLdL~7EcGj_jdOrCh+@3 zi_}m6IQdXN7;TIZ?IE?LF82UOc1K<$X@C6-FSd4S_Y(NG)Tkx4ieMs{GKt=F%6R0Fbh9oq&7$4MKCM)uM?)(~*Iz@>T{uS;7jJn48C{X{W z%!vZ5-y{N=?;-G~t1tDI^9|=JU5)xz+on6N&AJCBveJf3ND7ag1U^CI zA0KZ#W`X)K0zA~pm?_zqCz%aO($;{?Lnh4M;W?+ke9r0BCv7>2y zW=X^n3NywZxO@WTqXV~lIBVT-T`sZ}bT}jRLhbI= zP<70HfrYyOl@K(|Kz;f_RvKJM8QjPryvp%+Z|N5nyPTdri;4hhzgsr>8mE8I3AXdA z2hleTsvx%3XXZHTkrKDYBv`PNgZc+&rd?a0vPY1j_0iJZKBLk^?F`uSY?y!A7JIV@ zFdr|(Bh=Vji`ozIM&h3ZU+Bmd8LL9y@hTtspaGh+;``I0Oo010)gSrZ>-P-YP$8*X zLxI{rCCqJ00Da}2Bdz1RJ?q(fso7WAvG=lsDZ^zC#v0tW~pG{Dbk~7 zsXN9D)#dgcqVPXJAUv?hpx6itVARVSRLk};;m&2$<&ylOpz2Bb0PVD`-Yv<|ey2P9 zO8J1bLm}jXjafBi_pmkj{0|^k;TDchF~0qGiis!$R0bVPL4aW!+)nW9Z^d_(M9IG148FuvF8;5T4=}34RhPs6d?Y^+Nq^V(%&gB! z-lDoy5OA44a~M^keG{R#yu8{ABCUDD{9d|_L@JOxtfQ7<(5K<*5X7ZoL43W#JjWf> z?Ium1dpQ|~mAw{?`K4Ac;dKtmanyF_UvvGI(>JltX#NcsA`W#z`lJW=5_Kp#wrH<# zIhY-v4yXyq3?NDA2gz>dH`@U36eY{6Ra&qBp^IZjOd$857@C_FKZ=8i$ZRQHy4nUR z#kVJ)S%xgHsnN=&=9&Lt9YR8Pg3ngJWVQiXlW)=oP4>MhICT|^wp@8V=iABcR0D+6na2%PCVIqcGvC9 ztQfI)RIzd zFR>lFqEHd=NS0LB*v>dYd3Oj0)7ZIhDd;u!AqRZ{h^K;iuQ5<-?mD(chnKdFpkqL9(@iSOnS>R(VIOO`jBHwG+~<*x8!C*f+&N6nc8{!0=aJ*or7U-gg5T_=MjR=qBdwuYCT(C ziCTSTVlfi!rG2+D`a7vbj26soR)ikifReZ?th#zX85qCnSZ6Em)Z04;4z15ly>GlO zP}Z!S;&>pXxlkRi%k+-%{OZjaReMpwFYzcCQDXfLS!a$>J^z?xXas%ew3`mPL_nWS z-ErNhGxKN@g*~tGm@fFw3Rdze-P)(!?)ImcRM_yoxZ#hKS0cQ&;cQV#%qJ8bp%PT; z210bK*e&p2b(w7#MSgOI7_QNKXCE}N5f9F1tk#~?>^4(80y9Y`1~+f6ZSt&-L*D&M zWggf9LDzG@Tt;8B4os`7`Eh2>e=HhsQfaJQhgBT7!Qb{A;$+osrM61`rO*0Vi+8<<8pU`dcds|=5XxjXukX$VTXm*58fLj`rSEal=5 zJw0={Wzp17%qH0PlcB5Ej7~oMikB_DLqMI8=Kd z>HaWu|1RR4x~vEO)5RTN+C3Q9(BYWvGyPdIXx%=9$)ErehtV_t2BkOJx;3NaeXk5M ztiT)ydLc?Q(94%VAIA?oGFNFVMO6)he=%n&uw1<(?DBT_pFk?*TJEX-JdF3^aF-SJ zb+0A({I5E$JRHikjXz^lJ{5{dmT)LST4Q8O*t zEN$o*%MfA|Y8>H|HBrsjVvI4{xnHi!$#uT|nCp73d7th6-S>Mx_wzjO{WFpJO3i;; zcH>e~dYSGtPYEJm%7o$d{Sl-uXSxkMl~IAKh!^_GFD=6U(RHF@nx!$UUi!#Ab`HVT z_rAF*XF;v%zN1|k7|)eA&}$i~t4zsQQ6TTs-?g1M-v%Mkd8f)%CwdsJMJ=hr<)x*S zMN53`s$H5K+iEHWlK@STgIn6+W(H?kZ0ZqLFH!HO6LW7KtuZT+&%8AWrUPQpLP^q3 ze!6}G&Qo0HpzW&!WtB`y=JiC`>^{ofH`-)2LB|T+)81xW5VQ1~`TF4IqdR7Ox9eVB z2pPdi;Z2lli*NhJ4@}iD1f3QT$^CLH*POfs(oWSQ0#p4m*pwI3;mX0`Mc*S8Dq>AV zLuSQ@ywa+Oj9Jo!E_AC!FG#&< zR~uBaB~=VbXeb21CV>r6ae+JvyofpoNj6T+$@$=A2W3Q=)28}&lw5_OVS459MBW#%B=>o}Jw~7Lxb6mmK zSpUz4>MQyDBql`#E|Rx-CN>d+~g{NXc<*J7O61CcRVly>0d#S}<_ zd~X;ewu~Hx^mOO=ePwyqQISBlwG7c$kWeB0e_k=v!XE{Dq`nlFcO$W8F~lmL{Ia(i z?*zm~a~jG&2j0aLC44mkk>Z1isunSlxe>)QYbq`N@kJZ^ODx0a@CMX zFEvOZjysPLs)etH(q1KLc!B{h3EMj1TxmII< z@MxW2L+Rnr-hANNf4EESe8y}r_sIG|VY%J8(J^0*bwsw6gWhdKk;!lqX86(7iI&&1 zpi7v84)j;<7o-c^Sw5!hATbf^RYFKxg=*3|{jZI2+dAJfJN6F`d^z78$FvGb<6{GI zxVltKTGeIln>6e@Fa4;Gg8J*HM|;}5eF~{8XK`JjA+*lEn%(5rQtVoNX02VgIOgi4 z=)#sC4^+}V(8e^rk56Rqh<3iLo2SpmU)U~dJfdRTJfwS;$dWk|e0lOY|2S^>?|^BZc<&`J}| z<9%&;@i0J;)gSPn3k#FesgfZN&HSrduE^L?ksKlD6t>*drETf2r?S0ey4!06#LG#h z<}0+3&y^>~LmIRB!(POqv~5e+`Ui8&_7&yAV{lQ)q(^LL1uYAX3 zeHNA9V|gWMhFMj4*L*ugoKXY6=Q$d`laEuD(DxWx9=t|p zk`XUkOG?CNr$V6H))5Z&Rpi#sl3}+?MP&|Z7DqDn4q1-5id%>tBjifNyhOxfYOjsb z2RmBcB!yKFoPfK#iLg#%h~X)ALr+g(9^xl=G9@r3fD-+13jJ(gQ;lawt_EG))xLFA zMtcpgv3#3@>~lR$P%}hu%lcxrR{ud2$rhOh)}P2!o@wrj*shb0na)HZ2S|MXHZz|a z!~>jaYeUcdbT}HxmAQP&w6x^{igaM9pZd~vm*T`{PA1%(9WX_X4rD(Uym+ zth81i4?07rPGSD$w^uRj6#+L;oqJ6pPwvZJNx3YxpQ*V-8HFW&D-#fKF;C2YBLFZ`qnUSX$58#j1~59Z_YxKqi4*A&9u-?~d#e)i}oh1ER+`lqgv)O8n&;~+*Z zV#;9632;hM_dOz8L@0vKkFce31Ph4eApIjDJnz2*h-G0Q+^s@bp^G{W-M9znTfxcP z80iuaAiZ+{%>N-)g2%WDa1{Yp(NhB5aS>D=Wp^S6OvajJp!qlxmk!jyTBo7h%E5_@ zSm$IhaEL$E1kSEaIk8ZCB2yOz9iV7^G)VfH7zr?d@hPanJLk!SCRTbVfny9VE^AMP zcP8#V{!e!x{+Pgc0jH;;jtekPu#R4WAUb6!#G--pn@jqGpf${WzgN^r1ekzMqT2ElUQqm>t z0Ggp+p^W!?va)0xtJ`Ym%ow=xThtc*EYvu-;{^=KBfasKP(Wn(p<*>8?*{plxKQ)m q51*bexYb;AVH9s%v*U2crb>x~I(}<_Q4bsM!fw6Ox=gDb(SHLEzjh7) literal 0 HcmV?d00001 diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx new file mode 100644 index 00000000..bf81640c --- /dev/null +++ b/src/app/features/push/PushNotification.tsx @@ -0,0 +1,69 @@ +import React, { useCallback, useEffect, useState } from 'react'; +import { Switch, Text, color } from 'folds'; +import { SettingTile } from '../../components/setting-tile'; +import { useMatrixClient } from '../../hooks/useMatrixClient'; +import { getNotificationState, usePermissionState } from '../../hooks/usePermission'; +import type { ActivePushConfig } from './config'; +import { disablePush, enablePush } from './pusher'; + +export const pushSupported = (): boolean => + 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; + +const readyManager = async () => (await navigator.serviceWorker.ready).pushManager; + +/** Background notifications through the installed app's service worker. The caller renders it only when the host's config fills the push block and the browser can. */ +export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { + const mx = useMatrixClient(); + const permission = usePermissionState('notifications', getNotificationState()); + const [on, setOn] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(); + + useEffect(() => { + readyManager() + .then((m) => m.getSubscription()) + .then((s) => setOn(!!s)) + .catch(() => undefined); + }, [cfg]); + + const toggle = useCallback( + async (enable: boolean) => { + setBusy(true); + setError(undefined); + try { + if (enable) { + if ((await window.Notification.requestPermission()) !== 'granted') { + throw new Error('Notification permission was not granted.'); + } + await enablePush(mx, await readyManager(), cfg, navigator.userAgent.slice(0, 64)); + } else { + await disablePush(mx, await readyManager(), cfg); + } + setOn(enable); + } catch (e) { + setError(e instanceof Error ? e.message : 'Could not change background notifications.'); + } finally { + setBusy(false); + } + }, + [cfg, mx] + ); + + return ( + + {error ?? 'Notification permission is blocked. Allow it from the browser address bar.'} + + ) : ( + + Notify when the app is closed. Installing the app to the home screen helps on iOS. + + ) + } + after={} + /> + ); +} diff --git a/src/app/features/push/config.test.ts b/src/app/features/push/config.test.ts new file mode 100644 index 00000000..f014138a --- /dev/null +++ b/src/app/features/push/config.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; +import { activePushConfig } from './config'; + +describe('activePushConfig', () => { + it('is off unless all three keys are filled', () => { + expect(activePushConfig(undefined)).toBeUndefined(); + expect(activePushConfig({})).toBeUndefined(); + expect( + activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'https://g/_matrix/push/v1/notify' }) + ).toBeUndefined(); + expect(activePushConfig({ vapidPublicKey: 'k', gatewayUrl: 'u', appId: 'a' })).toEqual({ + vapidPublicKey: 'k', + gatewayUrl: 'u', + appId: 'a', + }); + }); + it('the shipped config.json carries the block empty, so push is off', () => { + const cfg = JSON.parse(readFileSync(join(__dirname, '../../../../config.json'), 'utf8')); + expect(cfg.push).toEqual({ vapidPublicKey: '', gatewayUrl: '', appId: '' }); + expect(activePushConfig(cfg.push)).toBeUndefined(); + }); +}); diff --git a/src/app/features/push/config.ts b/src/app/features/push/config.ts new file mode 100644 index 00000000..31e476cd --- /dev/null +++ b/src/app/features/push/config.ts @@ -0,0 +1,12 @@ +// Web Push is off unless the host's config.json fills all three keys (ships empty). The public key is not a secret; +// the gateway URL is the push gateway's notify endpoint the homeserver calls (`/_matrix/push/v1/notify`). +export type PushConfig = { + vapidPublicKey?: string; + gatewayUrl?: string; + appId?: string; +}; + +export type ActivePushConfig = Required; + +export const activePushConfig = (c?: PushConfig): ActivePushConfig | undefined => + c?.vapidPublicKey && c.gatewayUrl && c.appId ? (c as ActivePushConfig) : undefined; diff --git a/src/app/features/push/manifest.test.ts b/src/app/features/push/manifest.test.ts new file mode 100644 index 00000000..806b3fc4 --- /dev/null +++ b/src/app/features/push/manifest.test.ts @@ -0,0 +1,29 @@ +import { existsSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { describe, expect, it } from 'vitest'; + +const ROOT = join(__dirname, '../../../..'); +const manifest = JSON.parse(readFileSync(join(ROOT, 'public/manifest.json'), 'utf8')); + +describe('web app manifest', () => { + it('has a stable identity and scope, and no orientation lock', () => { + expect(manifest.id).toBe('/'); // what Chrome already derives from start_url "./" at the site root + expect(manifest.scope).toBe('./'); + expect(manifest.start_url).toBe('./'); + expect(manifest.display).toBe('standalone'); + expect(manifest.orientation).toBeUndefined(); + }); + it('offers 192 and 512 icons for any use and a 512 maskable one, every file present', () => { + const icons = manifest.icons as { src: string; sizes: string; purpose?: string }[]; + const any = icons.filter((i) => !i.purpose); + expect(any.map((i) => i.sizes)).toEqual(expect.arrayContaining(['192x192', '512x512'])); + const maskable = icons.filter((i) => i.purpose === 'maskable'); + expect(maskable.map((i) => i.sizes)).toEqual(['512x512']); + // the build copies public/res/android to public/android (vite.config.js) + icons.forEach((i) => + expect( + existsSync(join(ROOT, i.src.replace('./public/android/', 'public/res/android/'))) + ).toBe(true) + ); + }); +}); diff --git a/src/app/features/push/payload.test.ts b/src/app/features/push/payload.test.ts new file mode 100644 index 00000000..8e9e04eb --- /dev/null +++ b/src/app/features/push/payload.test.ts @@ -0,0 +1,108 @@ +import { describe, expect, it } from 'vitest'; +import { describePush, GENERIC, parsePush } from './payload'; +import { matrixNotify, mockHomeserver, SESSION } from './testkit'; + +describe('parsePush', () => { + it('reads the Matrix gateway body and the bare inner object', () => { + const a = parsePush(matrixNotify('!r:x', '$e')); + expect(a).toMatchObject({ roomId: '!r:x', eventId: '$e' }); + expect(parsePush({ room_id: '!r:x', event_id: '$e' })).toMatchObject({ roomId: '!r:x' }); + }); + it('rejects a body with no room id', () => { + expect(parsePush({ notification: { event_id: '$e' } })).toBeUndefined(); + expect(parsePush(undefined)).toBeUndefined(); + expect(parsePush('x')).toBeUndefined(); + }); + it('carries text the gateway added', () => { + expect( + parsePush({ + notification: { + room_id: '!r:x', + room_name: 'Lobby', + sender_display_name: 'Ann', + content: { body: 'hi' }, + }, + }) + ).toMatchObject({ roomName: 'Lobby', sender: 'Ann', text: 'hi' }); + }); +}); + +describe('describePush', () => { + const ids = { roomId: '!r:x', eventId: '$e' }; + const event = { + type: 'm.room.message', + sender: '@ann:x', + content: { msgtype: 'm.text', body: 'hello there' }, + }; + + it('resolves ids through the homeserver with the stored token', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: event }, + roomNames: { '!r:x': 'Lobby' }, + }); + expect(await describePush(ids, SESSION, hs.fetchFn)).toEqual({ + title: 'Lobby', + body: 'ann: hello there', + }); + expect(hs.seen).toHaveLength(2); + expect( + hs.seen.every( + (r) => r.auth === 'Bearer tok-1' && r.url.startsWith('https://matrix.example/_matrix/') + ) + ).toBe(true); + expect(hs.seen[0].url).toContain('/rooms/!r%3Ax/'); + }); + it('shows the generic notification with no session, and asks nobody', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + expect(await describePush(ids, undefined, hs.fetchFn)).toEqual({ + title: 'Ratatoskr', + body: 'New message', + }); + expect(hs.seen).toHaveLength(0); + }); + it('falls back when the token is refused, the network is down, or the event is encrypted', async () => { + const stale = mockHomeserver({ token: 'other', events: { $e: event } }); + expect(await describePush(ids, SESSION, stale.fetchFn)).toEqual(GENERIC); + const down = mockHomeserver({ token: 'tok-1', failAll: true }); + expect(await describePush(ids, SESSION, down.fetchFn)).toEqual(GENERIC); + const enc = mockHomeserver({ + token: 'tok-1', + events: { + $e: { + type: 'm.room.encrypted', + sender: '@ann:x', + content: { algorithm: 'm.megolm.v1.aes-sha2' }, + }, + }, + roomNames: { '!r:x': 'Lobby' }, + }); + expect(await describePush(ids, SESSION, enc.fetchFn)).toEqual({ + title: 'Lobby', + body: 'New message', + }); + }); + it('does not fetch what the push already carried', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + const shown = await describePush( + { ...ids, roomName: 'R', sender: 'Bo', text: 'yo' }, + SESSION, + hs.fetchFn + ); + expect(shown).toEqual({ title: 'R', body: 'Bo: yo' }); + expect(hs.seen).toHaveLength(0); + }); + it('clips a long body', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: { ...event, content: { body: 'x'.repeat(500) } } }, + }); + const shown = await describePush(ids, SESSION, hs.fetchFn); + expect(shown.body.length).toBeLessThanOrEqual(200); + expect(shown.body.endsWith('…')).toBe(true); + }); + it('shows the generic notification when the push names no room', async () => { + const hs = mockHomeserver({ token: 'tok-1' }); + expect(await describePush(undefined, SESSION, hs.fetchFn)).toEqual(GENERIC); + }); +}); diff --git a/src/app/features/push/payload.ts b/src/app/features/push/payload.ts new file mode 100644 index 00000000..b722d92a --- /dev/null +++ b/src/app/features/push/payload.ts @@ -0,0 +1,95 @@ +import type { SessionRecord } from './sessionStore'; + +/** What a push names. The homeserver sends ids only (`push.include_content: false`); a gateway may add text. */ +export interface PushIds { + roomId: string; + eventId?: string; + roomName?: string; + sender?: string; + text?: string; +} + +export interface Shown { + title: string; + body: string; +} + +const str = (v: unknown): string | undefined => (typeof v === 'string' && v ? v : undefined); + +/** The Matrix push body is `{ notification: {...} }`; a gateway may forward the inner object. No room id: not ours to show. */ +export function parsePush(raw: unknown): PushIds | undefined { + const top = (raw ?? {}) as Record; + const n = (top.notification ?? top) as Record; + const roomId = str(n.room_id); + if (!roomId) return undefined; + const content = (n.content ?? {}) as Record; + return { + roomId, + eventId: str(n.event_id), + roomName: str(n.room_name), + sender: str(n.sender_display_name) ?? str(n.sender), + text: str(content.body), + }; +} + +export const GENERIC: Shown = { title: 'Ratatoskr', body: 'New message' }; +const MAX_BODY = 200; +const clip = (s: string): string => (s.length > MAX_BODY ? `${s.slice(0, MAX_BODY - 1)}…` : s); +const localpart = (userId: string): string => userId.replace(/^@/, '').split(':')[0]; + +type Fetch = (url: string, init: RequestInit) => Promise; + +async function getJson( + f: Fetch, + session: SessionRecord, + path: string, + timeoutMs: number +): Promise | undefined> { + try { + const res = await f(`${session.baseUrl.replace(/\/+$/, '')}${path}`, { + headers: { Authorization: `Bearer ${session.accessToken}` }, + signal: AbortSignal.timeout(timeoutMs), + }); + return res.ok ? ((await res.json()) as Record) : undefined; + } catch { + return undefined; + } +} + +/** + * Title and body for a push. Text the push carried wins. Otherwise, with a session, the event and the room's name are + * fetched (clear-text rooms; an encrypted event shows the generic body). Every failure ends at the generic notification: + * a push must always show something. + */ +export async function describePush( + ids: PushIds | undefined, + session: SessionRecord | undefined, + f: Fetch, + timeoutMs = 4000 +): Promise { + if (!ids) return GENERIC; + let { roomName, sender, text } = ids; + if (session && (!text || !roomName)) { + const room = encodeURIComponent(ids.roomId); + const [event, name] = await Promise.all([ + !text && ids.eventId + ? getJson( + f, + session, + `/_matrix/client/v3/rooms/${room}/event/${encodeURIComponent(ids.eventId)}`, + timeoutMs + ) + : undefined, + !roomName + ? getJson(f, session, `/_matrix/client/v3/rooms/${room}/state/m.room.name`, timeoutMs) + : undefined, + ]); + const content = (event?.content ?? {}) as Record; + if (event?.type === 'm.room.message') text = str(content.body); + if (!sender && str(event?.sender)) sender = localpart(event?.sender as string); + roomName = roomName ?? str(name?.name); + } + let { body } = GENERIC; + if (text) body = sender ? `${sender}: ${text}` : text; + return { title: roomName ?? GENERIC.title, body: clip(body) }; +} diff --git a/src/app/features/push/pusher.test.ts b/src/app/features/push/pusher.test.ts new file mode 100644 index 00000000..73b270cb --- /dev/null +++ b/src/app/features/push/pusher.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, it } from 'vitest'; +import type { IPusherRequest } from 'matrix-js-sdk'; +import { disablePush, enablePush, PushManagerPort, vapidKeyBytes, webPushPusher } from './pusher'; + +const cfg = { + vapidPublicKey: 'BAUD', + gatewayUrl: 'https://matrix.example/_matrix/push/v1/notify', + appId: 'org.gamecult.ratatoskr.web', +}; +const key = (...b: number[]) => Uint8Array.from(b).buffer; + +function subscription(unsubscribed: string[] = []): PushSubscription { + return { + endpoint: 'https://push.example/send/abc', + getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)), + unsubscribe: async () => { + unsubscribed.push('x'); + return true; + }, + } as unknown as PushSubscription; +} + +function world() { + const calls: string[] = []; + const pushers: IPusherRequest[] = []; + let sub: PushSubscription | null = null; + const unsub: string[] = []; + const mx = { + setPusher: async (p: IPusherRequest) => { + calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher'); + pushers.push(p); + }, + }; + const push: PushManagerPort = { + getSubscription: async () => sub, + subscribe: async (o) => { + calls.push(`subscribe:${o.userVisibleOnly}:${o.applicationServerKey.length}`); + sub = subscription(unsub); + return sub; + }, + }; + return { mx, push, calls, pushers, unsub }; +} + +describe('web push pusher', () => { + it('decodes the URL-safe VAPID key', () => { + expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]); + }); + it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => { + const p = webPushPusher(cfg, subscription(), 'phone') as unknown as Record & { + data: Record; + }; + expect(p).toMatchObject({ + kind: 'http', + app_id: cfg.appId, + pushkey: '-_8B', + device_display_name: 'phone', + append: false, + }); + expect(p.data).toMatchObject({ + url: cfg.gatewayUrl, + format: 'event_id_only', + endpoint: 'https://push.example/send/abc', + auth: '-gI', + p256dh: '-_8B', + }); + }); + it('enable subscribes with userVisibleOnly, then registers the pusher; a second enable reuses the subscription', async () => { + const w = world(); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher', 'set-pusher']); + }); + it('disable deletes the pusher before the subscription, and does nothing when there is none', async () => { + const w = world(); + await disablePush(w.mx, w.push, cfg); + expect(w.calls).toEqual([]); + await enablePush(w.mx, w.push, cfg, 'phone'); + await disablePush(w.mx, w.push, cfg); + expect(w.calls.slice(-1)).toEqual(['delete-pusher']); + expect(w.pushers.at(-1)).toMatchObject({ kind: null, app_id: cfg.appId, pushkey: '-_8B' }); + expect(w.unsub).toEqual(['x']); + }); + it('a failed pusher write leaves the subscription alone', async () => { + const w = world(); + await enablePush(w.mx, w.push, cfg, 'phone'); + w.mx.setPusher = async () => { + throw new Error('homeserver said no'); + }; + await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no'); + expect(w.unsub).toEqual([]); + }); +}); diff --git a/src/app/features/push/pusher.ts b/src/app/features/push/pusher.ts new file mode 100644 index 00000000..65c0aca4 --- /dev/null +++ b/src/app/features/push/pusher.ts @@ -0,0 +1,86 @@ +import type { IPusherRequest } from 'matrix-js-sdk'; +import type { ActivePushConfig } from './config'; + +/** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */ +export interface PusherClient { + setPusher(p: IPusherRequest): Promise; +} +export interface PushManagerPort { + getSubscription(): Promise; + subscribe(o: { + userVisibleOnly: boolean; + applicationServerKey: Uint8Array; + }): Promise; +} + +export function vapidKeyBytes(b64url: string): Uint8Array { + const pad = '='.repeat((4 - (b64url.length % 4)) % 4); + const bin = atob((b64url + pad).replace(/-/g, '+').replace(/_/g, '/')); + return Uint8Array.from(bin, (c) => c.charCodeAt(0)); +} + +const b64url = (buf: ArrayBuffer | null): string => + btoa(String.fromCharCode(...new Uint8Array(buf ?? new ArrayBuffer(0)))) + .replace(/\+/g, '-') + .replace(/\//g, '_') + .replace(/=+$/, ''); + +/** + * The Matrix pusher for a browser subscription (Sygnal's `webpush` shape: the subscription's keys ride in `data`; the + * pushkey is the subscription's p256dh key, as Element Web does). `event_id_only` asks the homeserver for ids, which + * the worker resolves itself. + */ +export function webPushPusher( + cfg: ActivePushConfig, + sub: PushSubscription, + deviceName: string +): IPusherRequest { + const p256dh = b64url(sub.getKey('p256dh')); + return { + kind: 'http', + app_id: cfg.appId, + pushkey: p256dh, + app_display_name: 'Ratatoskr', + device_display_name: deviceName, + lang: navigator.language || 'en', + data: { + url: cfg.gatewayUrl, + format: 'event_id_only', + endpoint: sub.endpoint, + auth: b64url(sub.getKey('auth')), + p256dh, + }, + append: false, + } as IPusherRequest; +} + +export async function enablePush( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig, + deviceName: string +): Promise { + const sub = + (await push.getSubscription()) ?? + (await push.subscribe({ + userVisibleOnly: true, + applicationServerKey: vapidKeyBytes(cfg.vapidPublicKey), + })); + await mx.setPusher(webPushPusher(cfg, sub, deviceName)); +} + +/** Delete the pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ +export async function disablePush( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig +): Promise { + const sub = await push.getSubscription(); + if (!sub) return; + await mx.setPusher({ + kind: null, + app_id: cfg.appId, + pushkey: b64url(sub.getKey('p256dh')), + } as unknown as IPusherRequest); + await sub.unsubscribe(); +} diff --git a/src/app/features/push/sessionStore.test.ts b/src/app/features/push/sessionStore.test.ts new file mode 100644 index 00000000..81668a5f --- /dev/null +++ b/src/app/features/push/sessionStore.test.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; +import { idbPort, RecordPort, SessionRecord, sessionStore } from './sessionStore'; + +const memory = (): RecordPort & { held?: unknown } => { + const m: RecordPort & { held?: unknown } = { + async read() { + return m.held; + }, + async write(v) { + m.held = v; + }, + }; + return m; +}; + +describe('sessionStore', () => { + it('saves a valid session and loads it back', async () => { + const s = sessionStore(memory()); + await s.save('https://hs/', 'tok'); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + }); + it('deletes the record on logout, and on anything that is not two strings', async () => { + const port = memory(); + const s = sessionStore(port); + await s.save('https://hs/', 'tok'); + await s.save(undefined, undefined); + expect(port.held).toBeUndefined(); + await s.save('https://hs/', 'tok'); + await s.save('https://hs/', 5); + expect(await s.load()).toBeUndefined(); + }); + it('ignores a record that is not a session', async () => { + const port = memory(); + port.held = { baseUrl: 1 }; + expect(await sessionStore(port).load()).toBeUndefined(); + }); +}); + +// Just enough of IndexedDB for the port: one database, object stores of key -> value, async request callbacks. +function fakeIndexedDB(): IDBFactory { + const dbs = new Map>>(); + const request = (run: () => T) => { + const r = {} as IDBRequest; + setTimeout(() => { + (r as { result: T }).result = run(); + r.onsuccess?.(new Event('success')); + }, 0); + return r; + }; + return { + open(name: string) { + const r = {} as IDBOpenDBRequest; + setTimeout(() => { + const fresh = !dbs.has(name); + const stores = dbs.get(name) ?? new Map(); + dbs.set(name, stores); + const db = { + createObjectStore: (n: string) => stores.set(n, new Map()), + close: () => undefined, + transaction: (n: string) => { + const tx = {} as IDBTransaction; + const store = stores.get(n) as Map; + const api = { + get: (k: string) => request(() => store.get(k)), + put: (v: unknown, k: string) => { + store.set(k, v); + setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); + }, + delete: (k: string) => { + store.delete(k); + setTimeout(() => tx.oncomplete?.(new Event('complete')), 0); + }, + }; + (tx as { objectStore: unknown }).objectStore = () => api; + return tx; + }, + } as unknown as IDBDatabase; + (r as { result: IDBDatabase }).result = db; + if (fresh) r.onupgradeneeded?.(new Event('upgradeneeded') as IDBVersionChangeEvent); + r.onsuccess?.(new Event('success')); + }, 0); + return r; + }, + } as unknown as IDBFactory; +} + +describe('idbPort', () => { + it('round-trips the one record through IndexedDB and deletes it', async () => { + const s = sessionStore(idbPort(fakeIndexedDB())); + expect(await s.load()).toBeUndefined(); + await s.save('https://hs/', 'tok'); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + await s.save(undefined, undefined); + expect(await s.load()).toBeUndefined(); + }); +}); diff --git a/src/app/features/push/sessionStore.ts b/src/app/features/push/sessionStore.ts new file mode 100644 index 00000000..adaa711b --- /dev/null +++ b/src/app/features/push/sessionStore.ts @@ -0,0 +1,78 @@ +// The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names. +// Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other +// origins; the token is already in the page's own storage). Written when the page hands the worker its session, +// deleted when the page hands it none (logout). Nothing else is stored, and nothing else reads it but the push handler. +export interface SessionRecord { + baseUrl: string; + accessToken: string; +} + +/** The two operations the store needs from a key-value backend, so tests run without IndexedDB. */ +export interface RecordPort { + read(): Promise; + write(value: SessionRecord | undefined): Promise; +} + +const DB = 'ratatoskr-sw'; +const STORE = 'session'; +const KEY = 'session'; + +const done = (req: IDBRequest): Promise => + new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result); + req.onerror = () => reject(req.error); + }); + +export function idbPort(factory: IDBFactory): RecordPort { + const open = (): Promise => { + const req = factory.open(DB, 1); + req.onupgradeneeded = () => req.result.createObjectStore(STORE); + return done(req); + }; + return { + async read() { + const db = await open(); + try { + return await done(db.transaction(STORE).objectStore(STORE).get(KEY)); + } finally { + db.close(); + } + }, + async write(value) { + const db = await open(); + try { + const tx = db.transaction(STORE, 'readwrite'); + const store = tx.objectStore(STORE); + const finished = new Promise((resolve, reject) => { + tx.oncomplete = () => resolve(); + tx.onerror = () => reject(tx.error); + tx.onabort = () => reject(tx.error); + }); + if (value) store.put(value, KEY); + else store.delete(KEY); + await finished; + } finally { + db.close(); + } + }, + }; +} + +export function sessionStore(port: RecordPort) { + return { + /** A valid session replaces the record; anything else (logout) deletes it. */ + async save(baseUrl: unknown, accessToken: unknown): Promise { + await port.write( + typeof baseUrl === 'string' && typeof accessToken === 'string' && baseUrl && accessToken + ? { baseUrl, accessToken } + : undefined + ); + }, + async load(): Promise { + const v = (await port.read()) as Partial | undefined; + return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string' + ? { baseUrl: v.baseUrl, accessToken: v.accessToken } + : undefined; + }, + }; +} diff --git a/src/app/features/push/testkit.ts b/src/app/features/push/testkit.ts new file mode 100644 index 00000000..7f594f9f --- /dev/null +++ b/src/app/features/push/testkit.ts @@ -0,0 +1,41 @@ +import type { SessionRecord } from './sessionStore'; + +/** A mock homeserver: answers the two reads the push handler makes, and records every request it saw. */ +export function mockHomeserver(opts: { + token: string; + events?: Record; + roomNames?: Record; + failAll?: boolean; +}) { + const seen: { url: string; auth: string | null }[] = []; + const fetchFn = async (url: string, init: RequestInit): Promise => { + const auth = new Headers(init.headers).get('Authorization'); + seen.push({ url, auth }); + if (opts.failAll) throw new Error('network down'); + if (auth !== `Bearer ${opts.token}`) return new Response('{}', { status: 401 }); + const ev = /\/rooms\/([^/]+)\/event\/([^/]+)$/.exec(url); + if (ev) { + const e = opts.events?.[decodeURIComponent(ev[2])]; + return e ? new Response(JSON.stringify(e)) : new Response('{}', { status: 404 }); + } + const nm = /\/rooms\/([^/]+)\/state\/m\.room\.name$/.exec(url); + if (nm) { + const name = opts.roomNames?.[decodeURIComponent(nm[1])]; + return name ? new Response(JSON.stringify({ name })) : new Response('{}', { status: 404 }); + } + return new Response('{}', { status: 404 }); + }; + return { fetchFn, seen }; +} + +export const SESSION: SessionRecord = { baseUrl: 'https://matrix.example/', accessToken: 'tok-1' }; + +/** The body a homeserver posts to a push gateway for `format: event_id_only` (Matrix push gateway API). */ +export const matrixNotify = (roomId: string, eventId: string) => ({ + notification: { + event_id: eventId, + room_id: roomId, + counts: { unread: 1 }, + devices: [{ app_id: 'org.gamecult.ratatoskr.web', pushkey: 'k', data: {} }], + }, +}); diff --git a/src/app/features/push/usePushOpen.ts b/src/app/features/push/usePushOpen.ts new file mode 100644 index 00000000..e3b88d3a --- /dev/null +++ b/src/app/features/push/usePushOpen.ts @@ -0,0 +1,33 @@ +import { useEffect } from 'react'; +import { useRoomNavigate } from '../../hooks/useRoomNavigate'; +import { OPEN_ROOM, PUSH_EVENT_PARAM, PUSH_ROOM_PARAM } from './worker'; + +/** Opens the room a push notification named: from the worker's message (app open) or the start URL (app was closed). */ +export function usePushOpen(): void { + const { navigateRoom } = useRoomNavigate(); + useEffect(() => { + const params = new URLSearchParams(window.location.search); + const roomId = params.get(PUSH_ROOM_PARAM); + if (roomId) { + navigateRoom(roomId, params.get(PUSH_EVENT_PARAM) ?? undefined); + params.delete(PUSH_ROOM_PARAM); + params.delete(PUSH_EVENT_PARAM); + const rest = params.toString(); + window.history.replaceState( + null, + '', + `${window.location.pathname}${rest ? `?${rest}` : ''}${window.location.hash}` + ); + } + const onMessage = (ev: MessageEvent) => { + if (ev.data?.type === OPEN_ROOM && typeof ev.data.roomId === 'string') { + navigateRoom( + ev.data.roomId, + typeof ev.data.eventId === 'string' ? ev.data.eventId : undefined + ); + } + }; + navigator.serviceWorker?.addEventListener('message', onMessage); + return () => navigator.serviceWorker?.removeEventListener('message', onMessage); + }, [navigateRoom]); +} diff --git a/src/app/features/push/worker.test.ts b/src/app/features/push/worker.test.ts new file mode 100644 index 00000000..f1f766ce --- /dev/null +++ b/src/app/features/push/worker.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it, vi } from 'vitest'; +import { handleClick, handlePush, OPEN_ROOM, WorkerPort } from './worker'; +import { matrixNotify, mockHomeserver, SESSION } from './testkit'; + +type Shown = { title: string; options: NotificationOptions & { renotify?: boolean } }; + +function port(over: Partial = {}) { + const shown: Shown[] = []; + const opened: string[] = []; + const w: WorkerPort = { + windows: async () => [], + openWindow: async (u) => { + opened.push(u); + }, + show: async (title, options) => { + shown.push({ title, options }); + }, + loadSession: async () => SESSION, + fetch: mockHomeserver({ token: 'tok-1' }).fetchFn, + scope: 'https://chat.example/', + ...over, + }; + return { w, shown, opened }; +} + +const msg = { + type: 'm.room.message', + sender: '@ann:x', + content: { msgtype: 'm.text', body: 'hello' }, +}; + +describe('push against a mock gateway and homeserver', () => { + it('a push the homeserver sent for event_id_only becomes a notification naming room, sender and text', async () => { + const hs = mockHomeserver({ + token: 'tok-1', + events: { $e: msg }, + roomNames: { '!r:x': 'Lobby' }, + }); + const { w, shown } = port({ fetch: hs.fetchFn }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown).toHaveLength(1); + expect(shown[0].title).toBe('Lobby'); + expect(shown[0].options.body).toBe('ann: hello'); + expect(shown[0].options.tag).toBe('!r:x'); + expect(shown[0].options.renotify).toBe(true); + expect(shown[0].options.data).toEqual({ roomId: '!r:x', eventId: '$e' }); + }); + it('with no stored session it still shows (a push must always show) and sends the token nowhere', async () => { + const hs = mockHomeserver({ token: 'tok-1', events: { $e: msg } }); + const { w, shown } = port({ fetch: hs.fetchFn, loadSession: async () => undefined }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown[0]).toMatchObject({ title: 'Ratatoskr', options: { body: 'New message' } }); + expect(hs.seen).toHaveLength(0); + }); + it('shows the generic notification for a body it cannot read', async () => { + const { w, shown } = port(); + await handlePush(undefined, w); + expect(shown[0].title).toBe('Ratatoskr'); + expect(shown[0].options.tag).toBeUndefined(); + expect(shown[0].options.data).toBeUndefined(); + }); + it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => { + const win = (visibilityState: string, focused: boolean) => ({ + visibilityState, + focused, + focus: async () => undefined, + postMessage: () => undefined, + }); + const a = port({ windows: async () => [win('visible', true)] }); + await handlePush(matrixNotify('!r:x', '$e'), a.w); + expect(a.shown).toHaveLength(0); + const b = port({ windows: async () => [win('hidden', false), win('visible', false)] }); + await handlePush(matrixNotify('!r:x', '$e'), b.w); + expect(b.shown).toHaveLength(1); + }); +}); + +describe('notification click', () => { + it('focuses the open window and tells it the room', async () => { + const focus = vi.fn(async () => undefined); + const postMessage = vi.fn(); + const { w, opened } = port({ + windows: async () => [{ visibilityState: 'hidden', focused: false, focus, postMessage }], + }); + await handleClick({ roomId: '!r:x', eventId: '$e' }, w); + expect(focus).toHaveBeenCalled(); + expect(postMessage).toHaveBeenCalledWith({ type: OPEN_ROOM, roomId: '!r:x', eventId: '$e' }); + expect(opened).toHaveLength(0); + }); + it('with no window, starts the app with the room in the URL', async () => { + const { w, opened } = port(); + await handleClick({ roomId: '!r:x', eventId: '$e' }, w); + const u = new URL(opened[0]); + expect(u.origin + u.pathname).toBe('https://chat.example/'); + expect(u.searchParams.get('pushRoom')).toBe('!r:x'); + expect(u.searchParams.get('pushEvent')).toBe('$e'); + }); + it('with no window and no target, just opens the app', async () => { + const { w, opened } = port(); + await handleClick(undefined, w); + expect(opened).toEqual(['https://chat.example/']); + }); +}); diff --git a/src/app/features/push/worker.ts b/src/app/features/push/worker.ts new file mode 100644 index 00000000..5e3ccd75 --- /dev/null +++ b/src/app/features/push/worker.ts @@ -0,0 +1,61 @@ +import { describePush, parsePush } from './payload'; +import type { SessionRecord } from './sessionStore'; + +/** The slice of the service worker's scope the handlers use, so they run in tests without a worker. */ +export interface WorkerPort { + windows(): Promise< + { + visibilityState: string; + focused: boolean; + focus(): Promise; + postMessage(m: unknown): void; + }[] + >; + openWindow(url: string): Promise; + show(title: string, options: NotificationOptions): Promise; + loadSession(): Promise; + fetch(url: string, init: RequestInit): Promise; + /** The worker's scope URL: where a cold click opens the app. */ + scope: string; +} + +export interface PushTarget { + roomId: string; + eventId?: string; +} + +/** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */ +export async function handlePush(raw: unknown, w: WorkerPort): Promise { + const ids = parsePush(raw); + if ((await w.windows()).some((c) => c.visibilityState === 'visible' && c.focused)) return; + const shown = await describePush(ids, await w.loadSession(), w.fetch); + await w.show(shown.title, { + body: shown.body, + // One notification per room, replaced (and re-alerted) by the next message there. + tag: ids?.roomId, + renotify: !!ids?.roomId, + icon: './public/android/android-chrome-192x192.png', + data: ids ? ({ roomId: ids.roomId, eventId: ids.eventId } satisfies PushTarget) : undefined, + } as NotificationOptions); +} + +export const OPEN_ROOM = 'openRoom'; +export const PUSH_ROOM_PARAM = 'pushRoom'; +export const PUSH_EVENT_PARAM = 'pushEvent'; + +/** A click focuses an open window and tells it which room; with none open it starts the app with the room in the URL. */ +export async function handleClick(target: PushTarget | undefined, w: WorkerPort): Promise { + const windows = await w.windows(); + const open = windows[0]; + if (open) { + await open.focus(); + if (target?.roomId) open.postMessage({ type: OPEN_ROOM, ...target }); + return; + } + const url = new URL(w.scope); + if (target?.roomId) { + url.searchParams.set(PUSH_ROOM_PARAM, target.roomId); + if (target.eventId) url.searchParams.set(PUSH_EVENT_PARAM, target.eventId); + } + await w.openWindow(url.href); +} diff --git a/src/app/features/settings/notifications/SystemNotification.tsx b/src/app/features/settings/notifications/SystemNotification.tsx index 38115980..90630e1c 100644 --- a/src/app/features/settings/notifications/SystemNotification.tsx +++ b/src/app/features/settings/notifications/SystemNotification.tsx @@ -10,6 +10,9 @@ import { getNotificationState, usePermissionState } from '../../../hooks/usePerm import { useEmailNotifications } from '../../../hooks/useEmailNotifications'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { PushNotification, pushSupported } from '../../push/PushNotification'; +import { activePushConfig } from '../../push/config'; +import { useClientConfig } from '../../../hooks/useClientConfig'; function EmailNotification() { const mx = useMatrixClient(); @@ -85,6 +88,7 @@ function EmailNotification() { } export function SystemNotification() { + const pushCfg = activePushConfig(useClientConfig().push); const notifPermission = usePermissionState('notifications', getNotificationState()); const [showNotifications, setShowNotifications] = useSetting(settingsAtom, 'showNotifications'); const [isNotificationSounds, setIsNotificationSounds] = useSetting( @@ -133,6 +137,16 @@ export function SystemNotification() { } /> + {pushCfg && pushSupported() && ( + + + + )} diff --git a/src/sw.ts b/src/sw.ts index 69293b1d..4df85a88 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -1,8 +1,14 @@ /// +import { handleClick, handlePush, PushTarget, WorkerPort } from './app/features/push/worker'; +import { idbPort, sessionStore } from './app/features/push/sessionStore'; + export type {}; declare const self: ServiceWorkerGlobalScope; +// What a push can do with no tab open: the session is kept in IndexedDB (sessionStore.ts), not only in `sessions` below. +const persisted = sessionStore(idbPort(self.indexedDB)); + type SessionInfo = { accessToken: string; baseUrl: string; @@ -30,6 +36,7 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { + persisted.save(baseUrl, accessToken).catch(() => undefined); if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { @@ -156,3 +163,29 @@ self.addEventListener('fetch', (event: FetchEvent) => { }) ); }); + +const workerPort: WorkerPort = { + windows: async () => [ + ...(await self.clients.matchAll({ type: 'window', includeUncontrolled: true })), + ], + openWindow: (url) => self.clients.openWindow(url), + show: (title, options) => self.registration.showNotification(title, options), + loadSession: () => persisted.load().catch(() => undefined), + fetch: (url, init) => fetch(url, init), + scope: self.registration.scope, +}; + +self.addEventListener('push', (event: PushEvent) => { + let raw: unknown; + try { + raw = event.data?.json(); + } catch { + raw = undefined; + } + event.waitUntil(handlePush(raw, workerPort)); +}); + +self.addEventListener('notificationclick', (event: NotificationEvent) => { + event.notification.close(); + event.waitUntil(handleClick(event.notification.data as PushTarget | undefined, workerPort)); +}); From 695a016d4fa7a93b7a7544b72cbffb822b48c8b0 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 20:05:10 +0000 Subject: [PATCH 02/49] push: keep the token only while subscribed; toggle reads the account's pusher; replace a stale VAPID subscription Soul pass findings on the stage 0-1 branch: the worker stored the token on every deployment, push configured or not (now only while the browser holds a push subscription; the page re-sends its session after the toggle changes it); the toggle read the browser's subscription, not the account's pusher (now both); a subscription under an old VAPID key was reused (now replaced); a failing client list now still shows the notification. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/push/PushNotification.tsx | 11 +++-- src/app/features/push/pusher.test.ts | 57 ++++++++++++++++++++-- src/app/features/push/pusher.ts | 35 ++++++++++--- src/app/features/push/sessionStore.test.ts | 15 ++++++ src/app/features/push/sessionStore.ts | 14 +++++- src/app/features/push/worker.test.ts | 5 ++ src/app/features/push/worker.ts | 3 +- src/sw.ts | 8 ++- 8 files changed, 129 insertions(+), 19 deletions(-) diff --git a/src/app/features/push/PushNotification.tsx b/src/app/features/push/PushNotification.tsx index bf81640c..a818354e 100644 --- a/src/app/features/push/PushNotification.tsx +++ b/src/app/features/push/PushNotification.tsx @@ -4,7 +4,8 @@ import { SettingTile } from '../../components/setting-tile'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { getNotificationState, usePermissionState } from '../../hooks/usePermission'; import type { ActivePushConfig } from './config'; -import { disablePush, enablePush } from './pusher'; +import { pushSessionToSW } from '../../../sw-session'; +import { disablePush, enablePush, pushIsOn } from './pusher'; export const pushSupported = (): boolean => 'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window; @@ -21,10 +22,10 @@ export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { useEffect(() => { readyManager() - .then((m) => m.getSubscription()) - .then((s) => setOn(!!s)) + .then((m) => pushIsOn(mx, m, cfg)) + .then(setOn) .catch(() => undefined); - }, [cfg]); + }, [cfg, mx]); const toggle = useCallback( async (enable: boolean) => { @@ -39,6 +40,8 @@ export function PushNotification({ cfg }: { cfg: ActivePushConfig }) { } else { await disablePush(mx, await readyManager(), cfg); } + // The worker keeps the token only while a subscription exists: tell it the new state. + pushSessionToSW(mx.baseUrl, mx.getAccessToken() ?? undefined); setOn(enable); } catch (e) { setError(e instanceof Error ? e.message : 'Could not change background notifications.'); diff --git a/src/app/features/push/pusher.test.ts b/src/app/features/push/pusher.test.ts index 73b270cb..ffefa1f4 100644 --- a/src/app/features/push/pusher.test.ts +++ b/src/app/features/push/pusher.test.ts @@ -1,6 +1,13 @@ import { describe, expect, it } from 'vitest'; import type { IPusherRequest } from 'matrix-js-sdk'; -import { disablePush, enablePush, PushManagerPort, vapidKeyBytes, webPushPusher } from './pusher'; +import { + disablePush, + enablePush, + PushManagerPort, + pushIsOn, + vapidKeyBytes, + webPushPusher, +} from './pusher'; const cfg = { vapidPublicKey: 'BAUD', @@ -9,8 +16,9 @@ const cfg = { }; const key = (...b: number[]) => Uint8Array.from(b).buffer; -function subscription(unsubscribed: string[] = []): PushSubscription { +function subscription(unsubscribed: string[], serverKey?: ArrayBuffer): PushSubscription { return { + options: { applicationServerKey: serverKey ?? vapidKeyBytes(cfg.vapidPublicKey).buffer }, endpoint: 'https://push.example/send/abc', getKey: (n: string) => (n === 'p256dh' ? key(251, 255, 1) : key(250, 2)), unsubscribe: async () => { @@ -30,6 +38,16 @@ function world() { calls.push(p.kind === null ? 'delete-pusher' : 'set-pusher'); pushers.push(p); }, + // The account's pushers as the homeserver would list them: what setPusher last wrote, minus deletions. + getPushers: async () => ({ + pushers: pushers + .filter( + (p, i) => + p.kind !== null && + !pushers.slice(i + 1).some((q) => q.kind === null && q.pushkey === p.pushkey) + ) + .map((p) => ({ app_id: p.app_id, pushkey: p.pushkey })), + }), }; const push: PushManagerPort = { getSubscription: async () => sub, @@ -39,7 +57,10 @@ function world() { return sub; }, }; - return { mx, push, calls, pushers, unsub }; + const swapSub = (s: PushSubscription | null) => { + sub = s; + }; + return { mx, push, calls, pushers, unsub, swapSub }; } describe('web push pusher', () => { @@ -47,7 +68,10 @@ describe('web push pusher', () => { expect([...vapidKeyBytes('-_8')]).toEqual([251, 255]); }); it('is the Sygnal webpush shape: app id, p256dh pushkey, subscription in data, ids only', () => { - const p = webPushPusher(cfg, subscription(), 'phone') as unknown as Record & { + const p = webPushPusher(cfg, subscription([]), 'phone') as unknown as Record< + string, + unknown + > & { data: Record; }; expect(p).toMatchObject({ @@ -91,4 +115,29 @@ describe('web push pusher', () => { await expect(disablePush(w.mx, w.push, cfg)).rejects.toThrow('homeserver said no'); expect(w.unsub).toEqual([]); }); + it('the toggle is on only with a subscription and the pusher of this account for it', async () => { + const w = world(); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(true); + // another account on the same browser: the subscription exists, its pusher does not + const other = { ...w.mx, getPushers: async () => ({ pushers: [] }) }; + expect(await pushIsOn(other, w.push, cfg)).toBe(false); + await disablePush(w.mx, w.push, cfg); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + }); + it('a subscription made under another VAPID key is replaced, not reused', async () => { + const w = world(); + const old = [] as string[]; + w.swapSub(subscription(old, Uint8Array.from([9, 9, 9]).buffer)); + await enablePush(w.mx, w.push, cfg, 'phone'); + expect(old).toEqual(['x']); + expect(w.calls).toEqual(['subscribe:true:3', 'set-pusher']); + }); + it('a failed pusher write after subscribing leaves the toggle off', async () => { + const w = world(); + const fail = { ...w.mx, setPusher: async () => Promise.reject(new Error('no')) }; + await expect(enablePush(fail, w.push, cfg, 'phone')).rejects.toThrow('no'); + expect(await pushIsOn(w.mx, w.push, cfg)).toBe(false); + }); }); diff --git a/src/app/features/push/pusher.ts b/src/app/features/push/pusher.ts index 65c0aca4..d57c2821 100644 --- a/src/app/features/push/pusher.ts +++ b/src/app/features/push/pusher.ts @@ -4,6 +4,7 @@ import type { ActivePushConfig } from './config'; /** What the page needs of the homeserver client and the browser's push manager, so tests supply fakes. */ export interface PusherClient { setPusher(p: IPusherRequest): Promise; + getPushers(): Promise<{ pushers: { app_id: string; pushkey: string }[] }>; } export interface PushManagerPort { getSubscription(): Promise; @@ -54,22 +55,42 @@ export function webPushPusher( } as IPusherRequest; } +const sameKey = (sub: PushSubscription, vapid: Uint8Array): boolean => { + const have = sub.options?.applicationServerKey; + return !have || b64url(have) === b64url(vapid.buffer as ArrayBuffer); +}; + +/** Subscribe (replacing a subscription made under another VAPID key), then register the pusher for this account. */ export async function enablePush( mx: PusherClient, push: PushManagerPort, cfg: ActivePushConfig, deviceName: string ): Promise { - const sub = - (await push.getSubscription()) ?? - (await push.subscribe({ - userVisibleOnly: true, - applicationServerKey: vapidKeyBytes(cfg.vapidPublicKey), - })); + const key = vapidKeyBytes(cfg.vapidPublicKey); + let sub = await push.getSubscription(); + if (sub && !sameKey(sub, key)) { + await sub.unsubscribe(); + sub = null; + } + sub = sub ?? (await push.subscribe({ userVisibleOnly: true, applicationServerKey: key })); await mx.setPusher(webPushPusher(cfg, sub, deviceName)); } -/** Delete the pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ +/** On only when this browser holds a subscription AND this account has a pusher for it (the two live in different places). */ +export async function pushIsOn( + mx: PusherClient, + push: PushManagerPort, + cfg: ActivePushConfig +): Promise { + const sub = await push.getSubscription(); + if (!sub) return false; + const pushkey = b64url(sub.getKey('p256dh')); + const { pushers } = await mx.getPushers(); + return pushers.some((p) => p.app_id === cfg.appId && p.pushkey === pushkey); +} + +/** Delete this account's pusher, then the subscription. The order matters: a pusher with no subscription would keep failing at the gateway. */ export async function disablePush( mx: PusherClient, push: PushManagerPort, diff --git a/src/app/features/push/sessionStore.test.ts b/src/app/features/push/sessionStore.test.ts index 81668a5f..86cf9147 100644 --- a/src/app/features/push/sessionStore.test.ts +++ b/src/app/features/push/sessionStore.test.ts @@ -29,6 +29,21 @@ describe('sessionStore', () => { await s.save('https://hs/', 5); expect(await s.load()).toBeUndefined(); }); + it('keeps the token only while a push subscription exists', async () => { + const port = memory(); + const s = sessionStore(port); + await s.saveIfSubscribed('https://hs/', 'tok', async () => false); + expect(port.held).toBeUndefined(); + await s.saveIfSubscribed('https://hs/', 'tok', async () => true); + expect(await s.load()).toEqual({ baseUrl: 'https://hs/', accessToken: 'tok' }); + await s.saveIfSubscribed('https://hs/', 'tok', async () => false); + expect(port.held).toBeUndefined(); + await s.saveIfSubscribed('https://hs/', 'tok', async () => true); + await s.saveIfSubscribed('https://hs/', 'tok', () => + Promise.reject(new Error('no push manager')) + ); + expect(port.held).toBeUndefined(); + }); it('ignores a record that is not a session', async () => { const port = memory(); port.held = { baseUrl: 1 }; diff --git a/src/app/features/push/sessionStore.ts b/src/app/features/push/sessionStore.ts index adaa711b..f1835e9c 100644 --- a/src/app/features/push/sessionStore.ts +++ b/src/app/features/push/sessionStore.ts @@ -1,7 +1,8 @@ // The one thing a push needs that a closed app cannot give: the access token, to fetch the event a push names. // Stored: `{ baseUrl, accessToken }`, one record, in this origin's IndexedDB `ratatoskr-sw` (not reachable by other -// origins; the token is already in the page's own storage). Written when the page hands the worker its session, -// deleted when the page hands it none (logout). Nothing else is stored, and nothing else reads it but the push handler. +// origins; the token is already in the page's own storage). Written when the page hands the worker its session +// while this browser holds a push subscription; deleted when the page hands it none (logout) or the subscription is +// gone. A browser that never turned push on stores nothing. Nothing else reads it but the push handler. export interface SessionRecord { baseUrl: string; accessToken: string; @@ -68,6 +69,15 @@ export function sessionStore(port: RecordPort) { : undefined ); }, + /** The token is kept only while this browser holds a push subscription; without one the record is deleted. */ + async saveIfSubscribed( + baseUrl: unknown, + accessToken: unknown, + subscribed: () => Promise + ): Promise { + const keep = await subscribed().catch(() => false); + await this.save(keep ? baseUrl : undefined, keep ? accessToken : undefined); + }, async load(): Promise { const v = (await port.read()) as Partial | undefined; return v && typeof v.baseUrl === 'string' && typeof v.accessToken === 'string' diff --git a/src/app/features/push/worker.test.ts b/src/app/features/push/worker.test.ts index f1f766ce..5eaad32f 100644 --- a/src/app/features/push/worker.test.ts +++ b/src/app/features/push/worker.test.ts @@ -59,6 +59,11 @@ describe('push against a mock gateway and homeserver', () => { expect(shown[0].options.tag).toBeUndefined(); expect(shown[0].options.data).toBeUndefined(); }); + it('shows even when the window list cannot be read', async () => { + const { w, shown } = port({ windows: () => Promise.reject(new Error('no clients')) }); + await handlePush(matrixNotify('!r:x', '$e'), w); + expect(shown).toHaveLength(1); + }); it('shows nothing while a window is visible and focused, and shows when the window is hidden', async () => { const win = (visibilityState: string, focused: boolean) => ({ visibilityState, diff --git a/src/app/features/push/worker.ts b/src/app/features/push/worker.ts index 5e3ccd75..a98f873d 100644 --- a/src/app/features/push/worker.ts +++ b/src/app/features/push/worker.ts @@ -27,7 +27,8 @@ export interface PushTarget { /** A push shows a notification, unless the app is open and focused (browsers allow that, and the page notifies itself). */ export async function handlePush(raw: unknown, w: WorkerPort): Promise { const ids = parsePush(raw); - if ((await w.windows()).some((c) => c.visibilityState === 'visible' && c.focused)) return; + const open = await w.windows().catch(() => []); + if (open.some((c) => c.visibilityState === 'visible' && c.focused)) return; const shown = await describePush(ids, await w.loadSession(), w.fetch); await w.show(shown.title, { body: shown.body, diff --git a/src/sw.ts b/src/sw.ts index 4df85a88..ce9878c9 100644 --- a/src/sw.ts +++ b/src/sw.ts @@ -36,7 +36,13 @@ async function cleanupDeadClients() { } function setSession(clientId: string, accessToken: any, baseUrl: any) { - persisted.save(baseUrl, accessToken).catch(() => undefined); + persisted + .saveIfSubscribed( + baseUrl, + accessToken, + async () => !!(await self.registration.pushManager.getSubscription()) + ) + .catch(() => undefined); if (typeof accessToken === 'string' && typeof baseUrl === 'string') { sessions.set(clientId, { accessToken, baseUrl }); } else { From 7316c92ff32f195f38587ff5cc9184ea55144d4d Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:14:16 +0000 Subject: [PATCH 03/49] composer: persist the draft on input, keep unconfirmed sends in a durable outbox The draft lived in a memory-only jotai atom written only when RoomInput unmounted, so a reload lost it. A sent message was cleared from the editor at once and held nowhere durable. The draft is now written to localStorage (debounced, flushed on pagehide/hidden) and restored on load; a send stays in a per-account outbox under its txn id until the server confirms, shown above the composer with Resend and Discard. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 137 ++++++++++++++----- src/app/features/room/RoomOutbox.test.ts | 55 ++++++++ src/app/features/room/RoomOutbox.tsx | 96 +++++++++++++ src/app/state/room/composerStore.test.ts | 116 ++++++++++++++++ src/app/state/room/composerStore.ts | 164 +++++++++++++++++++++++ 5 files changed, 531 insertions(+), 37 deletions(-) create mode 100644 src/app/features/room/RoomOutbox.test.ts create mode 100644 src/app/features/room/RoomOutbox.tsx create mode 100644 src/app/state/room/composerStore.test.ts create mode 100644 src/app/state/room/composerStore.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b4483d8b..b3d59d18 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -12,6 +12,8 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; +import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { Box, Dialog, @@ -221,22 +223,66 @@ export const RoomInput = forwardRef( useCallback((width) => setHideStickerBtn(width < 500), []) ); + const userId = mx.getUserId() ?? ''; + const draftSaveTimer = useRef>(); + + // The atom is only a cache: after a reload it is empty and the stored draft is the truth. + useEffect(() => { + if (msgDraft.length === 0) { + const stored = loadDraft(userId, roomId); + if (stored.length > 0) setMsgDraft(stored); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, roomId]); + useEffect(() => { Transforms.insertFragment(editor, msgDraft); }, [editor, msgDraft]); + const persistDraft = useCallback(() => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; + saveDraft(userId, roomId, editor.children); + }, [userId, roomId, editor]); + + // Write on input (debounced), and flush when the page is hidden or unloading, which is + // all a mobile reload gives us. + const handleEditorChange = useCallback(() => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = setTimeout(persistDraft, 400); + }, [persistDraft]); + + useEffect(() => { + const flush = () => { + if (draftSaveTimer.current !== undefined) persistDraft(); + }; + const onVisibility = () => { + if (document.visibilityState === 'hidden') flush(); + }; + window.addEventListener('pagehide', flush); + document.addEventListener('visibilitychange', onVisibility); + return () => { + window.removeEventListener('pagehide', flush); + document.removeEventListener('visibilitychange', onVisibility); + }; + }, [persistDraft]); + useEffect( () => () => { + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; if (!isEmptyEditor(editor)) { const parsedDraft = JSON.parse(JSON.stringify(editor.children)); setMsgDraft(parsedDraft); + saveDraft(userId, roomId, parsedDraft); } else { setMsgDraft([]); + clearDraft(userId, roomId); } resetEditor(editor); resetEditorHistory(editor); }, - [roomId, editor, setMsgDraft] + [roomId, editor, setMsgDraft, userId] ); const handleFileMetadata = useCallback( @@ -366,12 +412,25 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - mx.sendMessage(roomId, content as any); + sendTracked(mx, roomId, content); + clearTimeout(draftSaveTimer.current); + draftSaveTimer.current = undefined; + clearDraft(userId, roomId); resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); sendTypingStatus(false); - }, [mx, roomId, editor, replyDraft, sendTypingStatus, setReplyDraft, isMarkdown, commands]); + }, [ + mx, + roomId, + userId, + editor, + replyDraft, + sendTypingStatus, + setReplyDraft, + isMarkdown, + commands, + ]); const handleKeyDown: KeyboardEventHandler = useCallback( (evt) => { @@ -537,44 +596,48 @@ export const RoomInput = forwardRef( onKeyDown={handleKeyDown} onKeyUp={handleKeyUp} onPaste={handlePaste} + onChange={handleEditorChange} top={ - replyDraft && ( -
- - setReplyDraft(undefined)} - variant="SurfaceVariant" - size="300" - radii="300" + <> + + {replyDraft && ( +
+ - - - - {replyDraft.relation?.rel_type === RelationType.Thread && } - - - {getMemberDisplayName(room, replyDraft.userId) ?? - getMxIdLocalPart(replyDraft.userId) ?? - replyDraft.userId} - - - } + setReplyDraft(undefined)} + variant="SurfaceVariant" + size="300" + radii="300" > - - {trimReplyFromBody(replyDraft.body)} - - + + + + {replyDraft.relation?.rel_type === RelationType.Thread && } + + + {getMemberDisplayName(room, replyDraft.userId) ?? + getMxIdLocalPart(replyDraft.userId) ?? + replyDraft.userId} + + + } + > + + {trimReplyFromBody(replyDraft.body)} + + + - -
- ) +
+ )} + } before={ { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); +}; + +const fakeClient = (send: (...a: unknown[]) => Promise) => + ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendMessage: send } as never); + +describe('sendTracked', () => { + beforeEach(stubLocalStorage); + afterEach(() => vi.unstubAllGlobals()); + + const content = { msgtype: 'm.text', body: 'typed before reload' }; + + it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { + let finish!: () => void; + const mx = fakeClient( + () => + new Promise((r) => { + finish = () => r({ event_id: '$e' }); + }) + ); + const done = sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); + finish(); + await done; + expect(loadOutbox('@a:x')).toEqual([]); + }); + + it('a failed send stays, marked failed, and is not cleared', async () => { + const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); + await sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([ + { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, + ]); + }); + + it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { + const send = vi.fn().mockResolvedValue({}); + await sendTracked(fakeClient(send), '!r:x', content); + expect(send).toHaveBeenCalledWith('!r:x', content, 'txn-1'); + }); +}); diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx new file mode 100644 index 00000000..4d3d6aca --- /dev/null +++ b/src/app/features/room/RoomOutbox.tsx @@ -0,0 +1,96 @@ +import React, { useEffect, useState } from 'react'; +import { MatrixClient } from 'matrix-js-sdk'; +import { Box, Chip, Text, color, config } from 'folds'; +import { + OutboxEntry, + enqueueOutbox, + failOutbox, + loadOutbox, + removeOutbox, + retryOutbox, + subscribeOutbox, +} from '../../state/room/composerStore'; + +const attempt = (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => + mx + .sendMessage(entry.roomId, entry.content as never, entry.txnId) + .then(() => removeOutbox(userId, entry.txnId)) + .catch((e: unknown) => + failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)) + ); + +/** Send a message, keeping it in the durable outbox until the server confirms it. */ +export const sendTracked = ( + mx: MatrixClient, + roomId: string, + content: Record +): Promise => { + const userId = mx.getUserId() ?? ''; + const entry = { roomId, content, txnId: mx.makeTxnId() }; + enqueueOutbox(userId, entry); + return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); +}; + +export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => { + const read = () => loadOutbox(userId).filter((e) => e.roomId === roomId); + const [entries, setEntries] = useState(read); + useEffect(() => { + setEntries(read()); + return subscribeOutbox(() => setEntries(read())); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [userId, roomId]); + return entries; +}; + +type RoomOutboxProps = { mx: MatrixClient; roomId: string }; + +/** + * Messages this client sent that the server has not confirmed: still sending, or failed. + * They also appear here after a reload, since the outbox is durable. Resending reuses the + * original txn id, so a send that did land the first time is not duplicated. + */ +export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { + const userId = mx.getUserId() ?? ''; + const entries = useRoomOutbox(userId, roomId); + if (entries.length === 0) return null; + + return ( + + {entries.map((entry) => { + const failed = entry.status === 'failed'; + return ( + + + + {failed ? `Not sent${entry.error ? `: ${entry.error}` : ''}` : 'Sending…'} + + + {String(entry.content.body ?? '')} + + + { + retryOutbox(userId, entry.txnId); + attempt(mx, userId, entry); + }} + > + Resend + + removeOutbox(userId, entry.txnId)}> + Discard + + + ); + })} + + ); +} diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts new file mode 100644 index 00000000..d3957d68 --- /dev/null +++ b/src/app/state/room/composerStore.test.ts @@ -0,0 +1,116 @@ +import { describe, expect, it } from 'vitest'; +import { Descendant } from 'slate'; +import { + clearDraft, + enqueueOutbox, + failOutbox, + loadDraft, + loadOutbox, + removeOutbox, + retryOutbox, + saveDraft, + subscribeOutbox, +} from './composerStore'; + +const memoryStore = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + size: () => m.size, + }; +}; + +const para = (text: string): Descendant[] => [{ type: 'paragraph', children: [{ text }] } as never]; + +describe('draft', () => { + it('survives a reload: a new read of the same store returns what input wrote', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('half a thought'), store); + expect(loadDraft('@a:x', '!r:x', store)).toEqual(para('half a thought')); + }); + + it('is keyed by account and room', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('one'), store); + expect(loadDraft('@b:x', '!r:x', store)).toEqual([]); + expect(loadDraft('@a:x', '!other:x', store)).toEqual([]); + }); + + it('emptying the editor removes the stored draft', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('x'), store); + saveDraft('@a:x', '!r:x', para(''), store); + expect(store.size()).toBe(0); + }); + + it('clearDraft removes it; corrupt storage reads as no draft', () => { + const store = memoryStore(); + saveDraft('@a:x', '!r:x', para('x'), store); + clearDraft('@a:x', '!r:x', store); + expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); + store.setItem('ratatoskr.draft.@a:x.!r:x', '{not json'); + expect(loadDraft('@a:x', '!r:x', store)).toEqual([]); + }); + + it('a throwing store (quota) does not throw into the composer', () => { + const store = { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => {}, + }; + expect(() => saveDraft('@a:x', '!r:x', para('x'), store)).not.toThrow(); + }); +}); + +describe('outbox', () => { + const entry = { txnId: 't1', roomId: '!r:x', content: { msgtype: 'm.text', body: 'hi' } }; + + it('keeps a sent message until it is confirmed, across a reload', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + const after = loadOutbox('@a:x', store); + expect(after).toEqual([{ ...entry, status: 'pending', ts: 5 }]); + removeOutbox('@a:x', 't1', store); + expect(loadOutbox('@a:x', store)).toEqual([]); + expect(store.size()).toBe(0); + }); + + it('a failed send stays, marked failed with its error; retry returns it to pending', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + failOutbox('@a:x', 't1', 'offline', store); + expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'failed', error: 'offline' }); + retryOutbox('@a:x', 't1', store); + expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'pending', txnId: 't1' }); + expect(loadOutbox('@a:x', store)[0].error).toBeUndefined(); + }); + + it('re-enqueueing the same txn id does not duplicate it; other accounts are separate', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + enqueueOutbox('@a:x', entry, 6, store); + expect(loadOutbox('@a:x', store)).toHaveLength(1); + expect(loadOutbox('@b:x', store)).toEqual([]); + }); + + it('notifies subscribers on every change', () => { + const store = memoryStore(); + let n = 0; + const off = subscribeOutbox(() => { + n += 1; + }); + enqueueOutbox('@a:x', entry, 5, store); + removeOutbox('@a:x', 't1', store); + off(); + enqueueOutbox('@a:x', entry, 5, store); + expect(n).toBe(2); + }); +}); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts new file mode 100644 index 00000000..8401e0d7 --- /dev/null +++ b/src/app/state/room/composerStore.ts @@ -0,0 +1,164 @@ +import { Descendant } from 'slate'; + +/** + * Durable composer state. The in-memory jotai atoms in roomInputDrafts.ts are a cache of this; + * a page reload keeps only what was written here. + * + * Draft: the unsent editor contents of one room, rewritten on input and removed when emptied. + * Outbox: a sent message stays here, with its txn id, until the server confirms it. A reload + * mid-send therefore leaves it behind to be shown and resent; resending under the same txn id + * makes the server deduplicate if the first attempt did land. + */ + +export type OutboxStatus = 'pending' | 'failed'; + +export type OutboxEntry = { + txnId: string; + roomId: string; + content: Record; + status: OutboxStatus; + error?: string; + ts: number; +}; + +type Store = Pick; + +const defaultStore = (): Store | undefined => { + try { + return localStorage; + } catch { + return undefined; + } +}; + +const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}.${roomId}`; +const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; + +const readJson = (store: Store | undefined, key: string): T | undefined => { + const raw = store?.getItem(key); + if (raw === null || raw === undefined) return undefined; + try { + return JSON.parse(raw) as T; + } catch { + return undefined; + } +}; + +const writeJson = (store: Store | undefined, key: string, value: unknown): void => { + try { + store?.setItem(key, JSON.stringify(value)); + } catch { + // quota or private mode: the in-memory atom still holds the draft for this session + } +}; + +const isEmptyDraft = (draft: Descendant[]): boolean => + draft.length === 0 || + (draft.length === 1 && + 'children' in draft[0] && + draft[0].children.length === 1 && + 'text' in draft[0].children[0] && + draft[0].children[0].text === ''); + +export const loadDraft = ( + userId: string, + roomId: string, + store: Store | undefined = defaultStore() +): Descendant[] => { + const draft = readJson(store, draftKey(userId, roomId)); + return Array.isArray(draft) ? draft : []; +}; + +export const saveDraft = ( + userId: string, + roomId: string, + draft: Descendant[], + store: Store | undefined = defaultStore() +): void => { + if (isEmptyDraft(draft)) { + store?.removeItem(draftKey(userId, roomId)); + return; + } + writeJson(store, draftKey(userId, roomId), draft); +}; + +export const clearDraft = ( + userId: string, + roomId: string, + store: Store | undefined = defaultStore() +): void => { + store?.removeItem(draftKey(userId, roomId)); +}; + +export const loadOutbox = ( + userId: string, + store: Store | undefined = defaultStore() +): OutboxEntry[] => { + const entries = readJson(store, outboxKey(userId)); + return Array.isArray(entries) ? entries : []; +}; + +const outboxListeners = new Set<() => void>(); +export const subscribeOutbox = (listener: () => void): (() => void) => { + outboxListeners.add(listener); + return () => outboxListeners.delete(listener); +}; + +const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { + if (entries.length === 0) store?.removeItem(outboxKey(userId)); + else writeJson(store, outboxKey(userId), entries); + outboxListeners.forEach((l) => l()); +}; + +export const enqueueOutbox = ( + userId: string, + entry: Omit, + now: number = Date.now(), + store: Store | undefined = defaultStore() +): void => { + const rest = loadOutbox(userId, store).filter((e) => e.txnId !== entry.txnId); + saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now }], store); +}; + +export const failOutbox = ( + userId: string, + txnId: string, + error: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).map((e) => + e.txnId === txnId ? { ...e, status: 'failed', error } : e + ), + store + ); +}; + +/** Retry in flight: failed goes back to pending so it is not shown as dead while it is sent. */ +export const retryOutbox = ( + userId: string, + txnId: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).map((e) => + e.txnId === txnId ? { ...e, status: 'pending', error: undefined } : e + ), + store + ); +}; + +/** Server confirmed the send, or the user discarded it. */ +export const removeOutbox = ( + userId: string, + txnId: string, + store: Store | undefined = defaultStore() +): void => { + saveOutbox( + userId, + loadOutbox(userId, store).filter((e) => e.txnId !== txnId), + store + ); +}; From 350ec987e0e06d312809a2627f8097db9f8d71d7 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:17:06 +0000 Subject: [PATCH 04/49] outbox: survive a refused storage write, mark reload-interrupted sends, hide Resend on live sends Soul pass findings: a failed localStorage write no longer loses the entry (memory copy wins until a write succeeds), a stale draft is dropped when its rewrite fails, storage reads/removes cannot throw, and pending entries older than the page load read as interrupted with Resend available. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.test.ts | 4 ++- src/app/features/room/RoomOutbox.tsx | 14 ++++++++- src/app/state/room/composerStore.test.ts | 15 ++++++++++ src/app/state/room/composerStore.ts | 38 ++++++++++++++++++------ 4 files changed, 60 insertions(+), 11 deletions(-) diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index f0efe1ff..df22f9d4 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -20,7 +20,9 @@ const fakeClient = (send: (...a: unknown[]) => Promise) => describe('sendTracked', () => { beforeEach(stubLocalStorage); - afterEach(() => vi.unstubAllGlobals()); + afterEach(() => { + vi.unstubAllGlobals(); + }); const content = { msgtype: 'm.text', body: 'typed before reload' }; diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index 4d3d6aca..cf164472 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -42,6 +42,9 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => return entries; }; +/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ +const pageStart = Date.now(); + type RoomOutboxProps = { mx: MatrixClient; roomId: string }; /** @@ -62,6 +65,8 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { > {entries.map((entry) => { const failed = entry.status === 'failed'; + const interrupted = !failed && entry.ts < pageStart; + const canResend = failed || interrupted; return ( @@ -69,12 +74,17 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { size="T200" style={{ color: failed ? color.Critical.Main : color.Surface.OnContainer }} > - {failed ? `Not sent${entry.error ? `: ${entry.error}` : ''}` : 'Sending…'} + {(() => { + if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`; + return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…'; + })()} {String(entry.content.body ?? '')} + {canResend && ( + <> removeOutbox(userId, entry.txnId)}> Discard + + )} ); })} diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index d3957d68..8eb36e4f 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -113,4 +113,19 @@ describe('outbox', () => { enqueueOutbox('@a:x', entry, 5, store); expect(n).toBe(2); }); + + it('when storage refuses the write, memory keeps the entry so a failed send still shows', () => { + const broken = { + getItem: () => null, + setItem: () => { + throw new Error('quota'); + }, + removeItem: () => {}, + }; + enqueueOutbox('@q:x', entry, 5, broken); + failOutbox('@q:x', 't1', 'offline', broken); + expect(loadOutbox('@q:x', broken)).toMatchObject([{ txnId: 't1', status: 'failed' }]); + removeOutbox('@q:x', 't1', broken); + expect(loadOutbox('@q:x', broken)).toEqual([]); + }); }); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index 8401e0d7..1cbe54aa 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -35,20 +35,31 @@ const draftKey = (userId: string, roomId: string) => `ratatoskr.draft.${userId}. const outboxKey = (userId: string) => `ratatoskr.outbox.${userId}`; const readJson = (store: Store | undefined, key: string): T | undefined => { - const raw = store?.getItem(key); - if (raw === null || raw === undefined) return undefined; try { + const raw = store?.getItem(key); + if (raw === null || raw === undefined) return undefined; return JSON.parse(raw) as T; } catch { return undefined; } }; -const writeJson = (store: Store | undefined, key: string, value: unknown): void => { +const writeJson = (store: Store | undefined, key: string, value: unknown): boolean => { try { - store?.setItem(key, JSON.stringify(value)); + if (!store) return false; + store.setItem(key, JSON.stringify(value)); + return true; } catch { // quota or private mode: the in-memory atom still holds the draft for this session + return false; + } +}; + +const removeKey = (store: Store | undefined, key: string): void => { + try { + store?.removeItem(key); + } catch { + // nothing durable to remove } }; @@ -76,10 +87,10 @@ export const saveDraft = ( store: Store | undefined = defaultStore() ): void => { if (isEmptyDraft(draft)) { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); return; } - writeJson(store, draftKey(userId, roomId), draft); + if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId)); }; export const clearDraft = ( @@ -87,13 +98,18 @@ export const clearDraft = ( roomId: string, store: Store | undefined = defaultStore() ): void => { - store?.removeItem(draftKey(userId, roomId)); + removeKey(store, draftKey(userId, roomId)); }; +/** Outbox contents whose last write to storage failed: memory is then the only copy, so it wins. */ +const unpersisted = new Map(); + export const loadOutbox = ( userId: string, store: Store | undefined = defaultStore() ): OutboxEntry[] => { + const held = unpersisted.get(userId); + if (held) return held; const entries = readJson(store, outboxKey(userId)); return Array.isArray(entries) ? entries : []; }; @@ -105,8 +121,12 @@ export const subscribeOutbox = (listener: () => void): (() => void) => { }; const saveOutbox = (userId: string, entries: OutboxEntry[], store: Store | undefined) => { - if (entries.length === 0) store?.removeItem(outboxKey(userId)); - else writeJson(store, outboxKey(userId), entries); + const ok = + entries.length === 0 + ? (removeKey(store, outboxKey(userId)), true) + : writeJson(store, outboxKey(userId), entries); + if (ok) unpersisted.delete(userId); + else unpersisted.set(userId, entries); outboxListeners.forEach((l) => l()); }; From a5319766758b7e62f1e6a24d1b8ca5665666ddfc Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:17:26 +0000 Subject: [PATCH 05/49] lint: import order, explicit no-op test stubs Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 4 ++-- src/app/state/room/composerStore.test.ts | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index b3d59d18..e92ae575 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -12,8 +12,6 @@ import { isKeyHotkey } from 'is-hotkey'; import { EventType, IContent, MsgType, RelationType, Room } from 'matrix-js-sdk'; import { ReactEditor } from 'slate-react'; import { Transforms, Editor } from 'slate'; -import { RoomOutbox, sendTracked } from './RoomOutbox'; -import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { Box, Dialog, @@ -30,6 +28,8 @@ import { config, toRem, } from 'folds'; +import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index 8eb36e4f..92bdcdfd 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -64,7 +64,7 @@ describe('draft', () => { setItem: () => { throw new Error('quota'); }, - removeItem: () => {}, + removeItem: () => undefined, }; expect(() => saveDraft('@a:x', '!r:x', para('x'), store)).not.toThrow(); }); @@ -120,7 +120,7 @@ describe('outbox', () => { setItem: () => { throw new Error('quota'); }, - removeItem: () => {}, + removeItem: () => undefined, }; enqueueOutbox('@q:x', entry, 5, broken); failOutbox('@q:x', 't1', 'offline', broken); From a92f6c4294969188593e5677299edfb0d83c745e Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:25:50 +0000 Subject: [PATCH 06/49] timeline: hide unconfirmed local echoes; the outbox is the one place they show The SDK puts a local echo in the live timeline on sendMessage and marks it NOT_SENT on failure; the timeline rendered it like any delivered message and a reload dropped it, so a send killed with the page looked sent and vanished. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomTimeline.tsx | 20 ++++++++++++++++++++ src/app/features/room/localEcho.test.ts | 20 ++++++++++++++++++++ src/app/features/room/localEcho.ts | 10 ++++++++++ 3 files changed, 50 insertions(+) create mode 100644 src/app/features/room/localEcho.test.ts create mode 100644 src/app/features/room/localEcho.ts diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index e9c13e2a..37ad6f54 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -47,6 +47,7 @@ import { import { isKeyHotkey } from 'is-hotkey'; import { Opts as LinkifyOpts } from 'linkifyjs'; import { useTranslation } from 'react-i18next'; +import { isUnconfirmedLocalEcho } from './localEcho'; import { eventWithShortcode, factoryEventSentBy, getMxIdLocalPart } from '../../utils/matrix'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useVirtualPaginator, ItemRange } from '../../hooks/useVirtualPaginator'; @@ -389,6 +390,19 @@ const useLiveEventArrive = (room: Room, onArrive: (mEvent: MatrixEvent) => void) }, [room, onArrive]); }; +/** A local echo changes status (or becomes its remote echo) without a Timeline event: redraw. */ +const useLocalEchoRefresh = (room: Room, onUpdate: () => void) => { + useEffect(() => { + const handle: RoomEventHandlerMap[RoomEvent.LocalEchoUpdated] = (_evt, r) => { + if (r.roomId === room.roomId) onUpdate(); + }; + room.on(RoomEvent.LocalEchoUpdated, handle); + return () => { + room.removeListener(RoomEvent.LocalEchoUpdated, handle); + }; + }, [room, onUpdate]); +}; + const useLiveTimelineRefresh = (room: Room, onRefresh: () => void) => { useEffect(() => { const handleTimelineRefresh: RoomEventHandlerMap[RoomEvent.TimelineRefresh] = (r) => { @@ -648,6 +662,11 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli ) ); + useLocalEchoRefresh( + room, + useCallback(() => setTimeline((ct) => ({ ...ct })), []) + ); + const handleOpenEvent = useCallback( async ( evtId: string, @@ -1646,6 +1665,7 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli if (mEvent.isRedacted() && !showHiddenEvents) { return null; } + if (isUnconfirmedLocalEcho(mEvent)) return null; if (!newDivider && readUptoEventIdRef.current) { newDivider = prevEvent?.getId() === readUptoEventIdRef.current; diff --git a/src/app/features/room/localEcho.test.ts b/src/app/features/room/localEcho.test.ts new file mode 100644 index 00000000..fa44ac9b --- /dev/null +++ b/src/app/features/room/localEcho.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { EventStatus } from 'matrix-js-sdk'; +import { isUnconfirmedLocalEcho } from './localEcho'; + +describe('isUnconfirmedLocalEcho', () => { + it.each([ + EventStatus.ENCRYPTING, + EventStatus.QUEUED, + EventStatus.SENDING, + EventStatus.NOT_SENT, + EventStatus.CANCELLED, + ])('hides %s', (status) => { + expect(isUnconfirmedLocalEcho({ status })).toBe(true); + }); + + it('shows a server-confirmed event and one the server has accepted (SENT)', () => { + expect(isUnconfirmedLocalEcho({ status: null })).toBe(false); + expect(isUnconfirmedLocalEcho({ status: EventStatus.SENT })).toBe(false); + }); +}); diff --git a/src/app/features/room/localEcho.ts b/src/app/features/room/localEcho.ts new file mode 100644 index 00000000..4f827500 --- /dev/null +++ b/src/app/features/room/localEcho.ts @@ -0,0 +1,10 @@ +import { EventStatus, MatrixEvent } from 'matrix-js-sdk'; + +/** + * A local echo the server has not accepted: the SDK adds it to the live timeline the moment + * sendMessage is called, marks it NOT_SENT on failure, and drops it on reload. Rendered like + * any message it looks delivered and then vanishes. The outbox (RoomOutbox) is the one place + * that shows unconfirmed sends, so the timeline hides these until the status is SENT. + */ +export const isUnconfirmedLocalEcho = (mEvent: Pick): boolean => + mEvent.status !== null && mEvent.status !== undefined && mEvent.status !== EventStatus.SENT; From 77edebff9c49f9231b3688c188a9156de1f36844 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:31:29 +0000 Subject: [PATCH 07/49] client: watchdog, resend on reload, outbox for every send, no in-place client restart Sends have no timeout and sync polls do, so a dead connection leaves a send SENDING forever with no request reaching the server while its echo looked delivered. A watchdog (send pending past 20 s, sync ERROR/STOPPED for 30 s) calls a guarded reload; what the reload interrupts is sent again under its own txn id. Uploads and stickers go through the outbox. Resend takes a fresh txn id while the SDK still holds the failed echo. The resume-sync recovery no longer calls stopClient/startClient, which closes the rust crypto for good ("null pointer passed to rust" on later encrypted sends). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 11 +- src/app/features/room/RoomOutbox.test.ts | 71 ++++-------- src/app/features/room/RoomOutbox.tsx | 103 ++++++++++-------- src/app/features/room/RoomTimeline.tsx | 9 +- src/app/state/room/composerStore.test.ts | 10 +- src/app/state/room/composerStore.ts | 13 ++- src/app/state/room/outboxSend.test.ts | 132 +++++++++++++++++++++++ src/app/state/room/outboxSend.ts | 66 ++++++++++++ src/client/initMatrix.test.ts | 20 ++++ src/client/initMatrix.ts | 47 +++++++- src/client/resumeSync.test.ts | 35 ++---- src/client/resumeSync.ts | 30 +++--- src/client/watchdog.test.ts | 83 ++++++++++++++ src/client/watchdog.ts | 48 +++++++++ 14 files changed, 523 insertions(+), 155 deletions(-) create mode 100644 src/app/state/room/outboxSend.test.ts create mode 100644 src/app/state/room/outboxSend.ts create mode 100644 src/client/initMatrix.test.ts create mode 100644 src/client/watchdog.test.ts create mode 100644 src/client/watchdog.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index e92ae575..03926da9 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -28,7 +28,8 @@ import { config, toRem, } from 'folds'; -import { RoomOutbox, sendTracked } from './RoomOutbox'; +import { RoomOutbox } from './RoomOutbox'; +import { sendTracked } from '../../state/room/outboxSend'; import { clearDraft, loadDraft, saveDraft } from '../../state/room/composerStore'; import { useMatrixClient } from '../../hooks/useMatrixClient'; @@ -333,7 +334,7 @@ export const RoomInput = forwardRef( }); handleCancelUpload(uploads); const contents = fulfilledPromiseSettledResult(await Promise.allSettled(contentsPromises)); - contents.forEach((content) => mx.sendMessage(roomId, content as any)); + contents.forEach((content) => sendTracked(mx, roomId, content as Record)); }; const submit = useCallback(() => { @@ -492,11 +493,7 @@ export const RoomInput = forwardRef( await getImageUrlBlob(stickerUrl) ); - mx.sendEvent(roomId, EventType.Sticker, { - body: label, - url: mxc, - info, - }); + sendTracked(mx, roomId, { body: label, url: mxc, info }, EventType.Sticker); }; return ( diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index df22f9d4..b2c29b14 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -1,57 +1,24 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadOutbox } from '../../state/room/composerStore'; -import { sendTracked } from './RoomOutbox'; +import { describe, expect, it } from 'vitest'; +import { SLOW_SEND_MS, outboxView } from './RoomOutbox'; -const stubLocalStorage = () => { - const m = new Map(); - vi.stubGlobal('localStorage', { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }); -}; - -const fakeClient = (send: (...a: unknown[]) => Promise) => - ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendMessage: send } as never); - -describe('sendTracked', () => { - beforeEach(stubLocalStorage); - afterEach(() => { - vi.unstubAllGlobals(); +describe('outboxView', () => { + it('walks sending -> still sending (resendable) -> failed', () => { + expect(outboxView({ status: 'pending', ts: 100 }, 100, 0)).toMatchObject({ + label: 'Sending…', + canResend: false, + }); + expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({ + label: 'Still sending: slow connection', + canResend: true, + }); + expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({ + failed: true, + canResend: true, + }); }); - const content = { msgtype: 'm.text', body: 'typed before reload' }; - - it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { - let finish!: () => void; - const mx = fakeClient( - () => - new Promise((r) => { - finish = () => r({ event_id: '$e' }); - }) - ); - const done = sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); - finish(); - await done; - expect(loadOutbox('@a:x')).toEqual([]); - }); - - it('a failed send stays, marked failed, and is not cleared', async () => { - const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); - await sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([ - { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, - ]); - }); - - it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { - const send = vi.fn().mockResolvedValue({}); - await sendTracked(fakeClient(send), '!r:x', content); - expect(send).toHaveBeenCalledWith('!r:x', content, 'txn-1'); + it('an entry older than this page load reads as interrupted', () => { + expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/); }); }); + diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index cf164472..21c3e224 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -3,32 +3,40 @@ import { MatrixClient } from 'matrix-js-sdk'; import { Box, Chip, Text, color, config } from 'folds'; import { OutboxEntry, - enqueueOutbox, - failOutbox, loadOutbox, removeOutbox, - retryOutbox, subscribeOutbox, } from '../../state/room/composerStore'; +import { PAGE_START, resendTracked } from '../../state/room/outboxSend'; -const attempt = (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => - mx - .sendMessage(entry.roomId, entry.content as never, entry.txnId) - .then(() => removeOutbox(userId, entry.txnId)) - .catch((e: unknown) => - failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)) - ); +/** A send still unconfirmed after this long is offered Resend: the request may be hung. */ +export const SLOW_SEND_MS = 30_000; -/** Send a message, keeping it in the durable outbox until the server confirms it. */ -export const sendTracked = ( - mx: MatrixClient, - roomId: string, - content: Record -): Promise => { - const userId = mx.getUserId() ?? ''; - const entry = { roomId, content, txnId: mx.makeTxnId() }; - enqueueOutbox(userId, entry); - return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); +export type OutboxView = { label: string; failed: boolean; canResend: boolean }; + +export const outboxView = ( + entry: Pick, + now: number, + since: number = PAGE_START +): OutboxView => { + if (entry.status === 'failed') { + return { + label: `Not sent${entry.error ? `: ${entry.error}` : ''}`, + failed: true, + canResend: true, + }; + } + if (entry.ts < since) { + return { + label: 'Interrupted by a reload: may not have been sent', + failed: false, + canResend: true, + }; + } + if (now - entry.ts >= SLOW_SEND_MS) { + return { label: 'Still sending: slow connection', failed: false, canResend: true }; + } + return { label: 'Sending…', failed: false, canResend: false }; }; export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => { @@ -42,19 +50,28 @@ export const useRoomOutbox = (userId: string, roomId: string): OutboxEntry[] => return entries; }; -/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ -const pageStart = Date.now(); +/** Re-render every few seconds while anything is waiting, so a slow send turns resendable. */ +const useNow = (active: boolean): number => { + const [now, setNow] = useState(Date.now()); + useEffect(() => { + if (!active) return undefined; + setNow(Date.now()); + const id = setInterval(() => setNow(Date.now()), 5000); + return () => clearInterval(id); + }, [active]); + return now; +}; type RoomOutboxProps = { mx: MatrixClient; roomId: string }; /** - * Messages this client sent that the server has not confirmed: still sending, or failed. - * They also appear here after a reload, since the outbox is durable. Resending reuses the - * original txn id, so a send that did land the first time is not duplicated. + * Messages this client sent that the server has not confirmed: sending, slow, interrupted by a + * reload, or failed, each with Resend where one makes sense and Discard. */ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { const userId = mx.getUserId() ?? ''; const entries = useRoomOutbox(userId, roomId); + const now = useNow(entries.length > 0); if (entries.length === 0) return null; return ( @@ -64,40 +81,32 @@ export function RoomOutbox({ mx, roomId }: RoomOutboxProps) { style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > {entries.map((entry) => { - const failed = entry.status === 'failed'; - const interrupted = !failed && entry.ts < pageStart; - const canResend = failed || interrupted; + const view = outboxView(entry, now); return ( - {(() => { - if (failed) return `Not sent${entry.error ? `: ${entry.error}` : ''}`; - return interrupted ? 'Interrupted by a reload: may not have been sent' : 'Sending…'; - })()} + {view.label} {String(entry.content.body ?? '')} - {canResend && ( + {view.canResend && ( <> - { - retryOutbox(userId, entry.txnId); - attempt(mx, userId, entry); - }} - > - Resend - - removeOutbox(userId, entry.txnId)}> - Discard - + resendTracked(mx, entry)}> + Resend + + removeOutbox(userId, entry.txnId)} + > + Discard + )} diff --git a/src/app/features/room/RoomTimeline.tsx b/src/app/features/room/RoomTimeline.tsx index 37ad6f54..9bd776f5 100644 --- a/src/app/features/room/RoomTimeline.tsx +++ b/src/app/features/room/RoomTimeline.tsx @@ -664,7 +664,14 @@ export function RoomTimeline({ room, eventId, roomInputRef, editor }: RoomTimeli useLocalEchoRefresh( room, - useCallback(() => setTimeline((ct) => ({ ...ct })), []) + useCallback(() => { + // the echo was hidden while unconfirmed, so its arrival scrolled to nothing: scroll now + if (atBottomRef.current) { + scrollToBottomRef.current.count += 1; + scrollToBottomRef.current.smooth = true; + } + setTimeline((ct) => ({ ...ct })); + }, []) ); const handleOpenEvent = useCallback( diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index 92bdcdfd..0c4f8ac8 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -88,11 +88,19 @@ describe('outbox', () => { enqueueOutbox('@a:x', entry, 5, store); failOutbox('@a:x', 't1', 'offline', store); expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'failed', error: 'offline' }); - retryOutbox('@a:x', 't1', store); + retryOutbox('@a:x', 't1', 9, 't1', store); expect(loadOutbox('@a:x', store)[0]).toMatchObject({ status: 'pending', txnId: 't1' }); expect(loadOutbox('@a:x', store)[0].error).toBeUndefined(); }); + it('a resend can rekey the entry and restarts its clock', () => { + const store = memoryStore(); + enqueueOutbox('@a:x', entry, 5, store); + failOutbox('@a:x', 't1', 'offline', store); + retryOutbox('@a:x', 't1', 50, 't2', store); + expect(loadOutbox('@a:x', store)).toMatchObject([{ txnId: 't2', status: 'pending', ts: 50 }]); + }); + it('re-enqueueing the same txn id does not duplicate it; other accounts are separate', () => { const store = memoryStore(); enqueueOutbox('@a:x', entry, 5, store); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index 1cbe54aa..ca1f6bcb 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -15,6 +15,8 @@ export type OutboxStatus = 'pending' | 'failed'; export type OutboxEntry = { txnId: string; roomId: string; + /** Event type; absent means m.room.message. */ + type?: string; content: Record; status: OutboxStatus; error?: string; @@ -155,16 +157,23 @@ export const failOutbox = ( ); }; -/** Retry in flight: failed goes back to pending so it is not shown as dead while it is sent. */ +/** + * A resend begins: the entry is pending again from `now`, under `newTxnId` when the SDK still + * holds the failed attempt's echo under the old one (it refuses to add a second echo for a txn id). + */ export const retryOutbox = ( userId: string, txnId: string, + now: number = Date.now(), + newTxnId: string = txnId, store: Store | undefined = defaultStore() ): void => { saveOutbox( userId, loadOutbox(userId, store).map((e) => - e.txnId === txnId ? { ...e, status: 'pending', error: undefined } : e + e.txnId === txnId + ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now } + : e ), store ); diff --git a/src/app/state/room/outboxSend.test.ts b/src/app/state/room/outboxSend.test.ts new file mode 100644 index 00000000..8f1db8fd --- /dev/null +++ b/src/app/state/room/outboxSend.test.ts @@ -0,0 +1,132 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox, failOutbox, loadOutbox } from './composerStore'; +import { resendInterrupted, resendTracked, sendTracked } from './outboxSend'; + +const stubLocalStorage = () => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); +}; + +const fakeClient = (send: (...a: unknown[]) => Promise) => + ({ getUserId: () => '@a:x', makeTxnId: () => 'txn-1', sendEvent: send } as never); + +describe('sendTracked', () => { + beforeEach(stubLocalStorage); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const content = { msgtype: 'm.text', body: 'typed before reload' }; + + it('holds the message in the outbox while the send is in flight, so a reload keeps it', async () => { + let finish!: () => void; + const mx = fakeClient( + () => + new Promise((r) => { + finish = () => r({ event_id: '$e' }); + }) + ); + const done = sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending', content }]); + finish(); + await done; + expect(loadOutbox('@a:x')).toEqual([]); + }); + + it('a failed send stays, marked failed, and is not cleared', async () => { + const mx = fakeClient(() => Promise.reject(new Error('ECONNRESET'))); + await sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([ + { txnId: 'txn-1', status: 'failed', error: 'ECONNRESET', content }, + ]); + }); + + it('sends under the stored txn id, so a resend after an unknown outcome deduplicates', async () => { + const send = vi.fn().mockResolvedValue({}); + await sendTracked(fakeClient(send), '!r:x', content); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + }); +}); + +describe('resendTracked', () => { + beforeEach(stubLocalStorage); + afterEach(() => { + vi.unstubAllGlobals(); + }); + const entry = { + txnId: 'txn-old', + roomId: '!r:x', + content: { msgtype: 'm.text', body: 'b' }, + status: 'failed' as const, + ts: 1, + }; + + it('uses a fresh txn id while the SDK still holds the failed echo (it would throw on the old one)', async () => { + const send = vi.fn().mockResolvedValue({}); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + sendEvent: send, + getRoom: () => ({ getEventForTxnId: () => ({}) }), + } as never; + await resendTracked(mx, entry); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-new'); + }); + + it('reuses the txn id after a reload, when no echo exists, so the server deduplicates', async () => { + const send = vi.fn().mockResolvedValue({}); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + sendEvent: send, + getRoom: () => ({ getEventForTxnId: () => undefined }), + } as never; + await resendTracked(mx, entry); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', entry.content, 'txn-old'); + }); + + it('a synchronous throw from the SDK marks the entry failed instead of escaping', async () => { + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + sendEvent: () => { + throw new Error('addPendingEvent called on an event with known txnId'); + }, + getRoom: () => undefined, + } as never; + await sendTracked(mx, '!r:x', { body: 'x' }); + expect(loadOutbox('@a:x')[0]).toMatchObject({ status: 'failed' }); + }); +}); + +describe('resendInterrupted', () => { + beforeEach(stubLocalStorage); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('resends pending entries older than the page under their own txn id, and leaves failed ones', async () => { + const send = vi.fn().mockResolvedValue({}); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'fresh', + sendEvent: send, + getRoom: () => undefined, + } as never; + enqueueOutbox('@a:x', { txnId: 'old', roomId: '!r:x', content: { body: 'o' } }, 5); + enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'b' } }, 5); + failOutbox('@a:x', 'bad', 'nope'); + enqueueOutbox('@a:x', { txnId: 'new', roomId: '!r:x', content: { body: 'n' } }, 500); + await resendInterrupted(mx, 100); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'o' }, 'old'); + }); +}); diff --git a/src/app/state/room/outboxSend.ts b/src/app/state/room/outboxSend.ts new file mode 100644 index 00000000..2bea0de7 --- /dev/null +++ b/src/app/state/room/outboxSend.ts @@ -0,0 +1,66 @@ +import { MatrixClient } from 'matrix-js-sdk'; +import { + OutboxEntry, + enqueueOutbox, + failOutbox, + loadOutbox, + removeOutbox, + retryOutbox, +} from './composerStore'; + +/** Entries still 'pending' that predate this page load lost their promise in a reload: outcome unknown. */ +export const PAGE_START = Date.now(); + +const attempt = async (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => { + try { + await mx.sendEvent(entry.roomId, (entry.type ?? 'm.room.message') as never, entry.content as never, entry.txnId); + removeOutbox(userId, entry.txnId); + } catch (e) { + // sendEvent can also throw synchronously (e.g. a txn id the SDK already holds) + failOutbox(userId, entry.txnId, e instanceof Error ? e.message : String(e)); + } +}; + +/** + * Send, keeping the message in the durable outbox until the server confirms it. The timeline + * hides the SDK's local echo until then, so this is the only place an unconfirmed send shows. + */ +export const sendTracked = ( + mx: MatrixClient, + roomId: string, + content: Record, + type?: string +): Promise => { + const userId = mx.getUserId() ?? ''; + const entry = { roomId, content, txnId: mx.makeTxnId(), ...(type ? { type } : {}) }; + enqueueOutbox(userId, entry); + return attempt(mx, userId, { ...entry, status: 'pending', ts: Date.now() }); +}; + +/** + * Resend. An echo still held by the SDK under the old txn id (a failure in this session) makes + * it refuse the same id, so that attempt is abandoned under a fresh id. After a reload the echo + * is gone and the old id is reused, so a send that did land is deduplicated by the server. + */ +export const resendTracked = (mx: MatrixClient, entry: OutboxEntry): Promise => { + const userId = mx.getUserId() ?? ''; + const held = mx.getRoom(entry.roomId)?.getEventForTxnId(entry.txnId); + const txnId = held ? mx.makeTxnId() : entry.txnId; + retryOutbox(userId, entry.txnId, Date.now(), txnId); + return attempt(mx, userId, { ...entry, txnId, status: 'pending', ts: Date.now() }); +}; + + +/** + * After a reload, send again every entry the reload interrupted. They have no SDK echo, so each + * keeps its txn id and the server deduplicates one that did land. Failed entries are left for + * the user: they failed with an answer, these are only unknown. + */ +export const resendInterrupted = (mx: MatrixClient, since: number = PAGE_START): Promise => { + const userId = mx.getUserId() ?? ''; + return Promise.all( + loadOutbox(userId) + .filter((e) => e.status === 'pending' && e.ts < since) + .map((e) => resendTracked(mx, e)) + ); +}; diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts new file mode 100644 index 00000000..193aa0cb --- /dev/null +++ b/src/client/initMatrix.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it, vi } from 'vitest'; +import { RECOVER_RELOAD_MIN_GAP_MS, reloadToRecoverSync } from './initMatrix'; + +const memory = () => { + const m = new Map(); + return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => { + m.set(k, v); + } }; +}; + +describe('reloadToRecoverSync', () => { + it('reloads once, then refuses inside the gap so a down server cannot loop it', () => { + const store = memory(); + const reload = vi.fn(); + expect(reloadToRecoverSync(1_000_000, store, reload)).toBe(true); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe(false); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, store, reload)).toBe(true); + expect(reload).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 871a7625..2cb6a203 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -1,9 +1,19 @@ -import { createClient, MatrixClient, IndexedDBStore, IndexedDBCryptoStore } from 'matrix-js-sdk'; +import { + ClientEvent, + createClient, + MatrixClient, + IndexedDBStore, + IndexedDBCryptoStore, + SyncState, +} from 'matrix-js-sdk'; import { cryptoCallbacks } from './secretStorageKeys'; import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; import { startResumeSync } from './resumeSync'; +import { startWatchdog } from './watchdog'; +import { loadOutbox } from '../app/state/room/composerStore'; +import { resendInterrupted } from '../app/state/room/outboxSend'; type Session = { baseUrl: string; @@ -46,9 +56,42 @@ const startSync = (mx: MatrixClient) => lazyLoadMembers: true, }); +const RECOVER_RELOAD_KEY = 'ratatoskr.recoverReloadAt'; +export const RECOVER_RELOAD_MIN_GAP_MS = 60_000; + +/** Reload to bring a dead sync back, at most once a minute, so a server that is down cannot loop us. */ +export const reloadToRecoverSync = ( + now: number = Date.now(), + store: Pick = sessionStorage, + reload: () => void = () => window.location.reload() +): boolean => { + const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); + if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; + store.setItem(RECOVER_RELOAD_KEY, String(now)); + reload(); + return true; +}; + export const startClient = async (mx: MatrixClient) => { await startSync(mx); - startResumeSync(mx, { restart: startSync }); + const recover = () => reloadToRecoverSync(); + startResumeSync(mx, { recover }); + startWatchdog({ + getSyncState: () => mx.getSyncState(), + pendingAges: () => + loadOutbox(mx.getUserId() ?? '') + .filter((e) => e.status === 'pending') + .map((e) => Date.now() - e.ts), + isVisible: () => document.visibilityState === 'visible', + recover, + }); + // What a reload interrupted is sent again once sync is up. + const onSync = (state: SyncState) => { + if (state !== SyncState.Prepared && state !== SyncState.Syncing) return; + mx.off(ClientEvent.Sync, onSync); + resendInterrupted(mx); + }; + mx.on(ClientEvent.Sync, onSync); }; export const clearCacheAndReload = async (mx: MatrixClient) => { diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts index eada97af..22347a1f 100644 --- a/src/client/resumeSync.test.ts +++ b/src/client/resumeSync.test.ts @@ -12,11 +12,10 @@ const setup = (state: string | null = 'SYNCING') => { state, getSyncState: () => mx.state, retryImmediately: vi.fn(() => true), - stopClient: vi.fn(), }; - const restart = vi.fn(async () => {}); + const recover = vi.fn(); const stop = startResumeSync(mx, { - restart, + recover, doc: doc as never, win: win as never, }); @@ -28,7 +27,7 @@ const setup = (state: string | null = 'SYNCING') => { doc.visibilityState = 'visible'; doc.dispatchEvent(new Event('visibilitychange')); }; - return { doc, win, mx, restart, stop, hide, show }; + return { doc, win, mx, recover, stop, hide, show }; }; const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); @@ -88,34 +87,18 @@ describe('startResumeSync', () => { const t = setup(state); t.win.dispatchEvent(new Event('online')); await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.restart).not.toHaveBeenCalled(); + expect(t.recover).not.toHaveBeenCalled(); t.stop(); } }); - it('does one stop/start when sync is still unhealthy, never overlapping', async () => { + it('calls recover once when sync is still unhealthy, and never stops the client itself', async () => { const t = setup('RECONNECTING'); - let release!: () => void; - t.restart.mockImplementation( - () => - new Promise((r) => { - release = r; - }) - ); t.win.dispatchEvent(new Event('online')); await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.mx.stopClient).toHaveBeenCalledTimes(1); - expect(t.restart).toHaveBeenCalledTimes(1); - // a trigger while the restart is in flight does nothing - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - expect(t.restart).toHaveBeenCalledTimes(1); - release(); - await vi.advanceTimersByTimeAsync(0); - t.win.dispatchEvent(new Event('online')); - await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); + expect(t.recover).toHaveBeenCalledTimes(1); + expect(t.recover).toHaveBeenCalledWith(t.mx); + expect(Object.keys(t.mx)).not.toContain('stopClient'); }); it('removes listeners and cancels pending work on dispose', async () => { @@ -124,7 +107,7 @@ describe('startResumeSync', () => { await settle(); t.stop(); await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); - expect(t.restart).not.toHaveBeenCalled(); + expect(t.recover).not.toHaveBeenCalled(); t.win.dispatchEvent(new Event('online')); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts index 5f81547b..30fbab70 100644 --- a/src/client/resumeSync.ts +++ b/src/client/resumeSync.ts @@ -2,7 +2,6 @@ export type SyncHost = { getSyncState(): string | null; retryImmediately(): boolean; - stopClient(): void; }; // matrix-js-sdk SyncState values for a live sync loop. @@ -16,43 +15,40 @@ type ResumeSyncOptions = { /** Event sources and visibility; default to the browser globals. */ doc?: Pick; win?: Pick; - /** Restart the client after stopClient; must resolve once startClient has been issued. */ - restart: (mx: Host) => Promise; + /** + * Called once when sync is still unhealthy after the nudge. It must not stop and restart the + * client in place: stopClient() closes the rust crypto's OlmMachine for good and startClient() + * does not reopen it, so every later encrypted send fails with "null pointer passed to rust". + * A page reload is the only restart that brings crypto back. + */ + recover: (mx: Host) => void; }; /** * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. * On return (visible after a real absence, pageshow from bfcache, online) nudge the - * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, do one - * stop/start. Returns a disposer that removes every listener and timer. + * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, call recover once. Returns a disposer that removes every listener and timer. */ export const startResumeSync = ( mx: Host, - { restart, doc = document, win = window }: ResumeSyncOptions + { recover, doc = document, win = window }: ResumeSyncOptions ): (() => void) => { let hiddenAt: number | undefined; let debounce: ReturnType | undefined; let healthCheck: ReturnType | undefined; - let restarting = false; let disposed = false; - const check = async () => { + const check = () => { healthCheck = undefined; - if (disposed || restarting || HEALTHY.has(mx.getSyncState())) return; - restarting = true; - try { - mx.stopClient(); - await restart(mx); - } finally { - restarting = false; - } + if (disposed || HEALTHY.has(mx.getSyncState())) return; + recover(mx); }; const resume = () => { if (debounce !== undefined) clearTimeout(debounce); debounce = setTimeout(() => { debounce = undefined; - if (disposed || restarting || healthCheck !== undefined) return; + if (disposed || healthCheck !== undefined) return; mx.retryImmediately(); healthCheck = setTimeout(check, HEALTH_CHECK_MS); }, RESUME_DEBOUNCE_MS); diff --git a/src/client/watchdog.test.ts b/src/client/watchdog.test.ts new file mode 100644 index 00000000..4bf9cc2f --- /dev/null +++ b/src/client/watchdog.test.ts @@ -0,0 +1,83 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { SEND_STALL_MS, SYNC_BAD_TICKS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; + +const setup = (over: Partial[0]> = {}) => { + const recover = vi.fn(); + const o = { + getSyncState: () => 'SYNCING' as string | null, + pendingAges: () => [] as number[], + isVisible: () => true, + recover, + ...over, + }; + const stop = startWatchdog(o); + return { recover, stop }; +}; +const tick = (n: number) => vi.advanceTimersByTime(WATCHDOG_INTERVAL_MS * n); + +describe('watchdog', () => { + beforeEach(() => { + vi.useFakeTimers(); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('stays quiet while sync is healthy and nothing is pending', () => { + const t = setup(); + tick(100); + expect(t.recover).not.toHaveBeenCalled(); + t.stop(); + }); + + it('recovers when a send has been pending past the stall threshold', () => { + let age = 0; + const t = setup({ pendingAges: () => [age] }); + age = SEND_STALL_MS - 1; + tick(1); + expect(t.recover).not.toHaveBeenCalled(); + age = SEND_STALL_MS; + tick(1); + expect(t.recover).toHaveBeenCalledTimes(1); + t.stop(); + }); + + it.each(['ERROR', 'STOPPED'])('recovers when sync sits in %s for the bad-tick count, not before', (state) => { + const t = setup({ getSyncState: () => state }); + tick(SYNC_BAD_TICKS - 1); + expect(t.recover).not.toHaveBeenCalled(); + tick(1); + expect(t.recover).toHaveBeenCalledTimes(1); + t.stop(); + }); + + it('a recovered sync resets the count; RECONNECTING and a null state are js-sdk working, not bad', () => { + let state: string | null = 'ERROR'; + const t = setup({ getSyncState: () => state }); + tick(SYNC_BAD_TICKS - 1); + state = 'SYNCING'; + tick(1); + state = 'ERROR'; + tick(SYNC_BAD_TICKS - 1); + state = 'RECONNECTING'; + tick(50); + state = null; + tick(50); + expect(t.recover).not.toHaveBeenCalled(); + t.stop(); + }); + + it('does nothing while the page is hidden', () => { + const t = setup({ isVisible: () => false, pendingAges: () => [1e9], getSyncState: () => 'STOPPED' }); + tick(50); + expect(t.recover).not.toHaveBeenCalled(); + t.stop(); + }); + + it('stop() ends it', () => { + const t = setup({ pendingAges: () => [1e9] }); + t.stop(); + tick(10); + expect(t.recover).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client/watchdog.ts b/src/client/watchdog.ts new file mode 100644 index 00000000..f5ea86f8 --- /dev/null +++ b/src/client/watchdog.ts @@ -0,0 +1,48 @@ +/** + * The page can go on looking alive while its connection is dead: sync polls get cut by their + * own timeout, and a send has no timeout at all, so it hangs unsent while its echo sits in the + * composer's room. This notices the two signs and calls `recover` (a guarded reload; the + * outbox then resends what was interrupted). + * - a send pending past `stallMs` + * - sync stuck in ERROR or STOPPED for `syncBadTicks` consecutive ticks + * Nothing fires while the page is hidden: a frozen background tab is not a dead connection. + */ +export type WatchdogOptions = { + getSyncState: () => string | null; + /** Ages in ms of sends still pending. */ + pendingAges: () => number[]; + isVisible: () => boolean; + recover: () => void; + intervalMs?: number; + stallMs?: number; + syncBadTicks?: number; +}; + +export const WATCHDOG_INTERVAL_MS = 5000; +export const SEND_STALL_MS = 20_000; +export const SYNC_BAD_TICKS = 6; + +export const startWatchdog = ({ + getSyncState, + pendingAges, + isVisible, + recover, + intervalMs = WATCHDOG_INTERVAL_MS, + stallMs = SEND_STALL_MS, + syncBadTicks = SYNC_BAD_TICKS, +}: WatchdogOptions): (() => void) => { + let badTicks = 0; + const id = setInterval(() => { + if (!isVisible()) { + badTicks = 0; + return; + } + const state = getSyncState(); + badTicks = state === 'ERROR' || state === 'STOPPED' ? badTicks + 1 : 0; + if (badTicks >= syncBadTicks || pendingAges().some((age) => age >= stallMs)) { + badTicks = 0; + recover(); + } + }, intervalMs); + return () => clearInterval(id); +}; From 69d752ff0b5b6da7368dc45a6d0be781be849142 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:34:21 +0000 Subject: [PATCH 08/49] watchdog: send stall only, own-page entries, online only; resume sync nudges and never restarts Soul pass on 77edebff: the watchdog no longer counts interrupted entries from a previous page (reload loop at startup), no longer reloads on sync ERROR/STOPPED (js-sdk recovers offline by itself) or while offline, refuses instead of throwing when sessionStorage is unusable, startClient is idempotent, and the slow-send label says Resend may duplicate. resumeSync drops its health check and recover hook: nudge only. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.test.ts | 3 +- src/app/features/room/RoomOutbox.tsx | 6 ++- src/app/state/room/composerStore.test.ts | 3 +- src/app/state/room/composerStore.ts | 11 +++-- src/app/state/room/outboxSend.ts | 13 ++++-- src/app/state/room/roomInputDrafts.ts | 4 +- src/client/initMatrix.test.ts | 25 ++++++++++-- src/client/initMatrix.ts | 28 ++++++++----- src/client/resumeSync.test.ts | 50 +++++------------------ src/client/resumeSync.ts | 37 +++++------------ src/client/watchdog.test.ts | 51 +++++++----------------- src/client/watchdog.ts | 37 ++++++----------- 12 files changed, 112 insertions(+), 156 deletions(-) diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index b2c29b14..55597694 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -8,7 +8,7 @@ describe('outboxView', () => { canResend: false, }); expect(outboxView({ status: 'pending', ts: 100 }, 100 + SLOW_SEND_MS, 0)).toMatchObject({ - label: 'Still sending: slow connection', + label: expect.stringMatching(/^Still sending/), canResend: true, }); expect(outboxView({ status: 'failed', ts: 100, error: 'x' }, 100, 0)).toMatchObject({ @@ -21,4 +21,3 @@ describe('outboxView', () => { expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/); }); }); - diff --git a/src/app/features/room/RoomOutbox.tsx b/src/app/features/room/RoomOutbox.tsx index 21c3e224..1e77df43 100644 --- a/src/app/features/room/RoomOutbox.tsx +++ b/src/app/features/room/RoomOutbox.tsx @@ -34,7 +34,11 @@ export const outboxView = ( }; } if (now - entry.ts >= SLOW_SEND_MS) { - return { label: 'Still sending: slow connection', failed: false, canResend: true }; + return { + label: 'Still sending: slow connection (Resend may duplicate it if it lands later)', + failed: false, + canResend: true, + }; } return { label: 'Sending…', failed: false, canResend: false }; }; diff --git a/src/app/state/room/composerStore.test.ts b/src/app/state/room/composerStore.test.ts index 0c4f8ac8..f5a0f35b 100644 --- a/src/app/state/room/composerStore.test.ts +++ b/src/app/state/room/composerStore.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { Descendant } from 'slate'; import { + PAGE_ID, clearDraft, enqueueOutbox, failOutbox, @@ -77,7 +78,7 @@ describe('outbox', () => { const store = memoryStore(); enqueueOutbox('@a:x', entry, 5, store); const after = loadOutbox('@a:x', store); - expect(after).toEqual([{ ...entry, status: 'pending', ts: 5 }]); + expect(after).toEqual([{ ...entry, status: 'pending', ts: 5, tab: PAGE_ID }]); removeOutbox('@a:x', 't1', store); expect(loadOutbox('@a:x', store)).toEqual([]); expect(store.size()).toBe(0); diff --git a/src/app/state/room/composerStore.ts b/src/app/state/room/composerStore.ts index ca1f6bcb..91933d72 100644 --- a/src/app/state/room/composerStore.ts +++ b/src/app/state/room/composerStore.ts @@ -21,8 +21,12 @@ export type OutboxEntry = { status: OutboxStatus; error?: string; ts: number; + /** The page that last sent it: only that page's watchdog may judge it stalled. */ + tab?: string; }; +export const PAGE_ID = Math.random().toString(36).slice(2); + type Store = Pick; const defaultStore = (): Store | undefined => { @@ -92,7 +96,8 @@ export const saveDraft = ( removeKey(store, draftKey(userId, roomId)); return; } - if (!writeJson(store, draftKey(userId, roomId), draft)) removeKey(store, draftKey(userId, roomId)); + if (!writeJson(store, draftKey(userId, roomId), draft)) + removeKey(store, draftKey(userId, roomId)); }; export const clearDraft = ( @@ -139,7 +144,7 @@ export const enqueueOutbox = ( store: Store | undefined = defaultStore() ): void => { const rest = loadOutbox(userId, store).filter((e) => e.txnId !== entry.txnId); - saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now }], store); + saveOutbox(userId, [...rest, { ...entry, status: 'pending', ts: now, tab: PAGE_ID }], store); }; export const failOutbox = ( @@ -172,7 +177,7 @@ export const retryOutbox = ( userId, loadOutbox(userId, store).map((e) => e.txnId === txnId - ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now } + ? { ...e, txnId: newTxnId, status: 'pending', error: undefined, ts: now, tab: PAGE_ID } : e ), store diff --git a/src/app/state/room/outboxSend.ts b/src/app/state/room/outboxSend.ts index 2bea0de7..845e64a0 100644 --- a/src/app/state/room/outboxSend.ts +++ b/src/app/state/room/outboxSend.ts @@ -13,7 +13,12 @@ export const PAGE_START = Date.now(); const attempt = async (mx: MatrixClient, userId: string, entry: OutboxEntry): Promise => { try { - await mx.sendEvent(entry.roomId, (entry.type ?? 'm.room.message') as never, entry.content as never, entry.txnId); + await mx.sendEvent( + entry.roomId, + (entry.type ?? 'm.room.message') as never, + entry.content as never, + entry.txnId + ); removeOutbox(userId, entry.txnId); } catch (e) { // sendEvent can also throw synchronously (e.g. a txn id the SDK already holds) @@ -50,13 +55,15 @@ export const resendTracked = (mx: MatrixClient, entry: OutboxEntry): Promise => { +export const resendInterrupted = ( + mx: MatrixClient, + since: number = PAGE_START +): Promise => { const userId = mx.getUserId() ?? ''; return Promise.all( loadOutbox(userId) diff --git a/src/app/state/room/roomInputDrafts.ts b/src/app/state/room/roomInputDrafts.ts index 5d8ec8c7..f3bf9e1c 100644 --- a/src/app/state/room/roomInputDrafts.ts +++ b/src/app/state/room/roomInputDrafts.ts @@ -20,9 +20,7 @@ export type TUploadItem = { export type TUploadListAtom = ReturnType>; -export const roomIdToUploadItemsAtomFamily = atomFamily( - createListAtom -); +export const roomIdToUploadItemsAtomFamily = atomFamily(createListAtom); export const roomUploadAtomFamily = createUploadAtomFamily(); diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts index 193aa0cb..8db257ea 100644 --- a/src/client/initMatrix.test.ts +++ b/src/client/initMatrix.test.ts @@ -3,9 +3,12 @@ import { RECOVER_RELOAD_MIN_GAP_MS, reloadToRecoverSync } from './initMatrix'; const memory = () => { const m = new Map(); - return { getItem: (k: string) => m.get(k) ?? null, setItem: (k: string, v: string) => { + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { m.set(k, v); - } }; + }, + }; }; describe('reloadToRecoverSync', () => { @@ -13,8 +16,24 @@ describe('reloadToRecoverSync', () => { const store = memory(); const reload = vi.fn(); expect(reloadToRecoverSync(1_000_000, store, reload)).toBe(true); - expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe(false); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe( + false + ); expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, store, reload)).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); }); + +describe('reloadToRecoverSync with unusable storage', () => { + it('refuses instead of throwing when sessionStorage throws', () => { + const reload = vi.fn(); + const broken = { + getItem: () => { + throw new Error('denied'); + }, + setItem: () => undefined, + }; + expect(reloadToRecoverSync(1, broken, reload)).toBe(false); + expect(reload).not.toHaveBeenCalled(); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 2cb6a203..6c7886ac 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -12,7 +12,7 @@ import { clearNavToActivePathStore } from '../app/state/navToActivePath'; import { pushSessionToSW } from '../sw-session'; import { startResumeSync } from './resumeSync'; import { startWatchdog } from './watchdog'; -import { loadOutbox } from '../app/state/room/composerStore'; +import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore'; import { resendInterrupted } from '../app/state/room/outboxSend'; type Session = { @@ -59,31 +59,38 @@ const startSync = (mx: MatrixClient) => const RECOVER_RELOAD_KEY = 'ratatoskr.recoverReloadAt'; export const RECOVER_RELOAD_MIN_GAP_MS = 60_000; -/** Reload to bring a dead sync back, at most once a minute, so a server that is down cannot loop us. */ +/** Reload to bring a dead connection back, at most once a minute, so a server that is down cannot loop us. */ export const reloadToRecoverSync = ( now: number = Date.now(), store: Pick = sessionStorage, reload: () => void = () => window.location.reload() ): boolean => { - const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); - if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; - store.setItem(RECOVER_RELOAD_KEY, String(now)); + try { + const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); + if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; + store.setItem(RECOVER_RELOAD_KEY, String(now)); + } catch { + return false; + } reload(); return true; }; +const started = new WeakSet(); + export const startClient = async (mx: MatrixClient) => { await startSync(mx); - const recover = () => reloadToRecoverSync(); - startResumeSync(mx, { recover }); + if (started.has(mx)) return; + started.add(mx); + startResumeSync(mx); startWatchdog({ - getSyncState: () => mx.getSyncState(), pendingAges: () => loadOutbox(mx.getUserId() ?? '') - .filter((e) => e.status === 'pending') + .filter((e) => e.status === 'pending' && e.tab === PAGE_ID) .map((e) => Date.now() - e.ts), isVisible: () => document.visibilityState === 'visible', - recover, + isOnline: () => navigator.onLine, + recover: () => reloadToRecoverSync(), }); // What a reload interrupted is sent again once sync is up. const onSync = (state: SyncState) => { @@ -93,7 +100,6 @@ export const startClient = async (mx: MatrixClient) => { }; mx.on(ClientEvent.Sync, onSync); }; - export const clearCacheAndReload = async (mx: MatrixClient) => { mx.stopClient(); clearNavToActivePathStore(mx.getSafeUserId()); diff --git a/src/client/resumeSync.test.ts b/src/client/resumeSync.test.ts index 22347a1f..e90ac6a8 100644 --- a/src/client/resumeSync.test.ts +++ b/src/client/resumeSync.test.ts @@ -1,24 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { HEALTH_CHECK_MS, MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; +import { MIN_HIDDEN_MS, RESUME_DEBOUNCE_MS, startResumeSync } from './resumeSync'; class Target extends EventTarget { visibilityState: 'visible' | 'hidden' = 'visible'; } -const setup = (state: string | null = 'SYNCING') => { +const setup = () => { const doc = new Target(); const win = new Target(); - const mx = { - state, - getSyncState: () => mx.state, - retryImmediately: vi.fn(() => true), - }; - const recover = vi.fn(); - const stop = startResumeSync(mx, { - recover, - doc: doc as never, - win: win as never, - }); + const mx = { retryImmediately: vi.fn(() => true) }; + const stop = startResumeSync(mx, { doc: doc as never, win: win as never }); const hide = () => { doc.visibilityState = 'hidden'; doc.dispatchEvent(new Event('visibilitychange')); @@ -27,7 +18,7 @@ const setup = (state: string | null = 'SYNCING') => { doc.visibilityState = 'visible'; doc.dispatchEvent(new Event('visibilitychange')); }; - return { doc, win, mx, recover, stop, hide, show }; + return { doc, win, mx, stop, hide, show }; }; const settle = () => vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); @@ -55,7 +46,7 @@ describe('startResumeSync', () => { t.hide(); await vi.advanceTimersByTimeAsync(MIN_HIDDEN_MS - 1); t.show(); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); + await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS); expect(t.mx.retryImmediately).not.toHaveBeenCalled(); }); @@ -67,7 +58,6 @@ describe('startResumeSync', () => { t.win.dispatchEvent(Object.assign(new Event('pageshow'), { persisted: true })); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); - await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); t.win.dispatchEvent(new Event('online')); await settle(); expect(t.mx.retryImmediately).toHaveBeenCalledTimes(2); @@ -82,34 +72,14 @@ describe('startResumeSync', () => { expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); }); - it('does not restart when sync is healthy after the check window', async () => { - for (const state of ['PREPARED', 'SYNCING', 'CATCHUP']) { - const t = setup(state); - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.recover).not.toHaveBeenCalled(); - t.stop(); - } - }); - - it('calls recover once when sync is still unhealthy, and never stops the client itself', async () => { - const t = setup('RECONNECTING'); - t.win.dispatchEvent(new Event('online')); - await vi.advanceTimersByTimeAsync(RESUME_DEBOUNCE_MS + HEALTH_CHECK_MS); - expect(t.recover).toHaveBeenCalledTimes(1); - expect(t.recover).toHaveBeenCalledWith(t.mx); - expect(Object.keys(t.mx)).not.toContain('stopClient'); - }); - it('removes listeners and cancels pending work on dispose', async () => { - const t = setup('ERROR'); + const t = setup(); t.win.dispatchEvent(new Event('online')); - await settle(); t.stop(); - await vi.advanceTimersByTimeAsync(HEALTH_CHECK_MS); - expect(t.recover).not.toHaveBeenCalled(); + await settle(); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); t.win.dispatchEvent(new Event('online')); await settle(); - expect(t.mx.retryImmediately).toHaveBeenCalledTimes(1); + expect(t.mx.retryImmediately).not.toHaveBeenCalled(); }); }); diff --git a/src/client/resumeSync.ts b/src/client/resumeSync.ts index 30fbab70..a09f39f8 100644 --- a/src/client/resumeSync.ts +++ b/src/client/resumeSync.ts @@ -1,56 +1,38 @@ /** The slice of MatrixClient this module drives; a MatrixClient satisfies it. */ export type SyncHost = { - getSyncState(): string | null; retryImmediately(): boolean; }; -// matrix-js-sdk SyncState values for a live sync loop. -const HEALTHY: ReadonlySet = new Set(['PREPARED', 'SYNCING', 'CATCHUP']); - export const RESUME_DEBOUNCE_MS = 250; export const MIN_HIDDEN_MS = 5000; -export const HEALTH_CHECK_MS = 3000; -type ResumeSyncOptions = { +type ResumeSyncOptions = { /** Event sources and visibility; default to the browser globals. */ doc?: Pick; win?: Pick; - /** - * Called once when sync is still unhealthy after the nudge. It must not stop and restart the - * client in place: stopClient() closes the rust crypto's OlmMachine for good and startClient() - * does not reopen it, so every later encrypted send fails with "null pointer passed to rust". - * A page reload is the only restart that brings crypto back. - */ - recover: (mx: Host) => void; }; /** * A backgrounded tab or PWA leaves the sync long-poll dead until its 30 s timeout. * On return (visible after a real absence, pageshow from bfcache, online) nudge the - * poll with retryImmediately; if sync is still unhealthy after HEALTH_CHECK_MS, call recover once. Returns a disposer that removes every listener and timer. + * poll with retryImmediately. It never stops and restarts the client: stopClient() closes + * the rust crypto's OlmMachine for good and startClient() does not reopen it. A connection + * that stays dead is the watchdog's business. Returns a disposer that removes every + * listener and timer. */ -export const startResumeSync = ( - mx: Host, - { recover, doc = document, win = window }: ResumeSyncOptions +export const startResumeSync = ( + mx: SyncHost, + { doc = document, win = window }: ResumeSyncOptions = {} ): (() => void) => { let hiddenAt: number | undefined; let debounce: ReturnType | undefined; - let healthCheck: ReturnType | undefined; let disposed = false; - const check = () => { - healthCheck = undefined; - if (disposed || HEALTHY.has(mx.getSyncState())) return; - recover(mx); - }; - const resume = () => { if (debounce !== undefined) clearTimeout(debounce); debounce = setTimeout(() => { debounce = undefined; - if (disposed || healthCheck !== undefined) return; - mx.retryImmediately(); - healthCheck = setTimeout(check, HEALTH_CHECK_MS); + if (!disposed) mx.retryImmediately(); }, RESUME_DEBOUNCE_MS); }; @@ -74,7 +56,6 @@ export const startResumeSync = ( return () => { disposed = true; if (debounce !== undefined) clearTimeout(debounce); - if (healthCheck !== undefined) clearTimeout(healthCheck); doc.removeEventListener('visibilitychange', onVisibility); win.removeEventListener('pageshow', onPageShow); win.removeEventListener('online', resume); diff --git a/src/client/watchdog.test.ts b/src/client/watchdog.test.ts index 4bf9cc2f..7d80f099 100644 --- a/src/client/watchdog.test.ts +++ b/src/client/watchdog.test.ts @@ -1,16 +1,15 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { SEND_STALL_MS, SYNC_BAD_TICKS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; +import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; const setup = (over: Partial[0]> = {}) => { const recover = vi.fn(); - const o = { - getSyncState: () => 'SYNCING' as string | null, - pendingAges: () => [] as number[], + const stop = startWatchdog({ + pendingAges: () => [], isVisible: () => true, + isOnline: () => true, recover, ...over, - }; - const stop = startWatchdog(o); + }); return { recover, stop }; }; const tick = (n: number) => vi.advanceTimersByTime(WATCHDOG_INTERVAL_MS * n); @@ -23,14 +22,14 @@ describe('watchdog', () => { vi.useRealTimers(); }); - it('stays quiet while sync is healthy and nothing is pending', () => { + it('stays quiet while nothing is pending', () => { const t = setup(); tick(100); expect(t.recover).not.toHaveBeenCalled(); t.stop(); }); - it('recovers when a send has been pending past the stall threshold', () => { + it('recovers when a send has been pending past the stall threshold, not before', () => { let age = 0; const t = setup({ pendingAges: () => [age] }); age = SEND_STALL_MS - 1; @@ -42,36 +41,14 @@ describe('watchdog', () => { t.stop(); }); - it.each(['ERROR', 'STOPPED'])('recovers when sync sits in %s for the bad-tick count, not before', (state) => { - const t = setup({ getSyncState: () => state }); - tick(SYNC_BAD_TICKS - 1); - expect(t.recover).not.toHaveBeenCalled(); - tick(1); - expect(t.recover).toHaveBeenCalledTimes(1); - t.stop(); - }); - - it('a recovered sync resets the count; RECONNECTING and a null state are js-sdk working, not bad', () => { - let state: string | null = 'ERROR'; - const t = setup({ getSyncState: () => state }); - tick(SYNC_BAD_TICKS - 1); - state = 'SYNCING'; - tick(1); - state = 'ERROR'; - tick(SYNC_BAD_TICKS - 1); - state = 'RECONNECTING'; + it('does nothing while the page is hidden or offline', () => { + const hidden = setup({ isVisible: () => false, pendingAges: () => [1e9] }); + const offline = setup({ isOnline: () => false, pendingAges: () => [1e9] }); tick(50); - state = null; - tick(50); - expect(t.recover).not.toHaveBeenCalled(); - t.stop(); - }); - - it('does nothing while the page is hidden', () => { - const t = setup({ isVisible: () => false, pendingAges: () => [1e9], getSyncState: () => 'STOPPED' }); - tick(50); - expect(t.recover).not.toHaveBeenCalled(); - t.stop(); + expect(hidden.recover).not.toHaveBeenCalled(); + expect(offline.recover).not.toHaveBeenCalled(); + hidden.stop(); + offline.stop(); }); it('stop() ends it', () => { diff --git a/src/client/watchdog.ts b/src/client/watchdog.ts index f5ea86f8..6b97e8fc 100644 --- a/src/client/watchdog.ts +++ b/src/client/watchdog.ts @@ -1,48 +1,37 @@ /** - * The page can go on looking alive while its connection is dead: sync polls get cut by their - * own timeout, and a send has no timeout at all, so it hangs unsent while its echo sits in the - * composer's room. This notices the two signs and calls `recover` (a guarded reload; the - * outbox then resends what was interrupted). - * - a send pending past `stallMs` - * - sync stuck in ERROR or STOPPED for `syncBadTicks` consecutive ticks - * Nothing fires while the page is hidden: a frozen background tab is not a dead connection. + * The page can go on looking alive while its connection is dead: sync polls are cut by their + * own timeout and js-sdk retries them, but a send has no timeout at all, so on a half-open + * connection it hangs SENDING and no request reaches the server. A send pending past + * `stallMs` is the sign, and `recover` (a guarded reload) the answer; the outbox then sends + * again what the reload interrupted. Sync state is not watched: ERROR is also what offline + * looks like, and js-sdk recovers from that itself. + * Nothing fires while the page is hidden (a frozen tab is not a dead connection) or offline + * (a reload there only blanks the page). */ export type WatchdogOptions = { - getSyncState: () => string | null; - /** Ages in ms of sends still pending. */ + /** Ages in ms of this page's sends still pending. */ pendingAges: () => number[]; isVisible: () => boolean; + isOnline: () => boolean; recover: () => void; intervalMs?: number; stallMs?: number; - syncBadTicks?: number; }; export const WATCHDOG_INTERVAL_MS = 5000; export const SEND_STALL_MS = 20_000; -export const SYNC_BAD_TICKS = 6; export const startWatchdog = ({ - getSyncState, pendingAges, isVisible, + isOnline, recover, intervalMs = WATCHDOG_INTERVAL_MS, stallMs = SEND_STALL_MS, - syncBadTicks = SYNC_BAD_TICKS, }: WatchdogOptions): (() => void) => { - let badTicks = 0; const id = setInterval(() => { - if (!isVisible()) { - badTicks = 0; - return; - } - const state = getSyncState(); - badTicks = state === 'ERROR' || state === 'STOPPED' ? badTicks + 1 : 0; - if (badTicks >= syncBadTicks || pendingAges().some((age) => age >= stallMs)) { - badTicks = 0; - recover(); - } + if (!isVisible() || !isOnline()) return; + if (pendingAges().some((age) => age >= stallMs)) recover(); }, intervalMs); return () => clearInterval(id); }; From bb6391a70b5e56efad3351c5ad5dff2005a01a60 Mon Sep 17 00:00:00 2001 From: Void Date: Thu, 12 Nov 2026 21:35:46 +0000 Subject: [PATCH 09/49] initMatrix: resolve sessionStorage inside the guard's try; ownPendingAges tested Co-Authored-By: Claude Sonnet 5.5 --- src/client/initMatrix.test.ts | 47 +++++++++++++++++++++++++++-------- src/client/initMatrix.ts | 14 +++++++---- 2 files changed, 45 insertions(+), 16 deletions(-) diff --git a/src/client/initMatrix.test.ts b/src/client/initMatrix.test.ts index 8db257ea..95a8d966 100644 --- a/src/client/initMatrix.test.ts +++ b/src/client/initMatrix.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, vi } from 'vitest'; -import { RECOVER_RELOAD_MIN_GAP_MS, reloadToRecoverSync } from './initMatrix'; +import { PAGE_ID, enqueueOutbox, failOutbox } from '../app/state/room/composerStore'; +import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; const memory = () => { const m = new Map(); @@ -15,11 +16,13 @@ describe('reloadToRecoverSync', () => { it('reloads once, then refuses inside the gap so a down server cannot loop it', () => { const store = memory(); const reload = vi.fn(); - expect(reloadToRecoverSync(1_000_000, store, reload)).toBe(true); - expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, store, reload)).toBe( - false + expect(reloadToRecoverSync(1_000_000, () => store, reload)).toBe(true); + expect( + reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS - 1, () => store, reload) + ).toBe(false); + expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, () => store, reload)).toBe( + true ); - expect(reloadToRecoverSync(1_000_000 + RECOVER_RELOAD_MIN_GAP_MS, store, reload)).toBe(true); expect(reload).toHaveBeenCalledTimes(2); }); }); @@ -27,13 +30,35 @@ describe('reloadToRecoverSync', () => { describe('reloadToRecoverSync with unusable storage', () => { it('refuses instead of throwing when sessionStorage throws', () => { const reload = vi.fn(); - const broken = { - getItem: () => { - throw new Error('denied'); - }, - setItem: () => undefined, + const denied = () => { + throw new Error('SecurityError'); }; - expect(reloadToRecoverSync(1, broken, reload)).toBe(false); + expect(reloadToRecoverSync(1, denied, reload)).toBe(false); expect(reload).not.toHaveBeenCalled(); }); }); + +describe('ownPendingAges', () => { + it("counts only this page's pending sends, not other pages' or failed ones", () => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); + const e = (txnId: string) => ({ txnId, roomId: '!r:x', content: {} }); + enqueueOutbox('@a:x', e('mine'), 1000); + enqueueOutbox('@a:x', e('failed'), 1000); + failOutbox('@a:x', 'failed', 'x'); + enqueueOutbox('@a:x', e('theirs'), 1000); + const entries = JSON.parse(m.get('ratatoskr.outbox.@a:x') as string); + entries.find((x: { txnId: string }) => x.txnId === 'theirs').tab = `${PAGE_ID}-other`; + m.set('ratatoskr.outbox.@a:x', JSON.stringify(entries)); + expect(ownPendingAges('@a:x', 1500)).toEqual([500]); + vi.unstubAllGlobals(); + }); +}); diff --git a/src/client/initMatrix.ts b/src/client/initMatrix.ts index 6c7886ac..871c186a 100644 --- a/src/client/initMatrix.ts +++ b/src/client/initMatrix.ts @@ -62,10 +62,11 @@ export const RECOVER_RELOAD_MIN_GAP_MS = 60_000; /** Reload to bring a dead connection back, at most once a minute, so a server that is down cannot loop us. */ export const reloadToRecoverSync = ( now: number = Date.now(), - store: Pick = sessionStorage, + getStore: () => Pick = () => sessionStorage, reload: () => void = () => window.location.reload() ): boolean => { try { + const store = getStore(); const last = Number(store.getItem(RECOVER_RELOAD_KEY) ?? 0); if (now - last < RECOVER_RELOAD_MIN_GAP_MS) return false; store.setItem(RECOVER_RELOAD_KEY, String(now)); @@ -76,6 +77,12 @@ export const reloadToRecoverSync = ( return true; }; +/** Ages in ms of the sends this page made that are still pending: the only ones it may judge stalled. */ +export const ownPendingAges = (userId: string, now: number = Date.now()): number[] => + loadOutbox(userId) + .filter((e) => e.status === 'pending' && e.tab === PAGE_ID) + .map((e) => now - e.ts); + const started = new WeakSet(); export const startClient = async (mx: MatrixClient) => { @@ -84,10 +91,7 @@ export const startClient = async (mx: MatrixClient) => { started.add(mx); startResumeSync(mx); startWatchdog({ - pendingAges: () => - loadOutbox(mx.getUserId() ?? '') - .filter((e) => e.status === 'pending' && e.tab === PAGE_ID) - .map((e) => Date.now() - e.ts), + pendingAges: () => ownPendingAges(mx.getUserId() ?? ''), isVisible: () => document.visibilityState === 'visible', isOnline: () => navigator.onLine, recover: () => reloadToRecoverSync(), From ccb7a8ebb61b581ea8294502f597a83c09472d8a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:40:05 +0000 Subject: [PATCH 10/49] Build stamps its git sha into index.html as a build-sha meta tag Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 4 +++ scripts/buildSha.js | 27 ++++++++++++++++++ scripts/buildSha.test.js | 40 +++++++++++++++++++++++++++ vite.config.js | 2 ++ 4 files changed, 73 insertions(+) create mode 100644 scripts/buildSha.js create mode 100644 scripts/buildSha.test.js diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 33d3526f..d89541da 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -47,6 +47,10 @@ handoff states which config differs and the installer decides. From Yggdrasil th not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. +Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. The handoff +build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. +To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. Before handing off: diff --git a/scripts/buildSha.js b/scripts/buildSha.js new file mode 100644 index 00000000..741c5983 --- /dev/null +++ b/scripts/buildSha.js @@ -0,0 +1,27 @@ +// The commit a bundle was built from, stamped into its index.html as , so a built +// folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. +// BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, +// suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. +import { execFileSync } from 'child_process'; + +const git = (...args) => + execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + +export const readBuildSha = (env = process.env, run = git) => { + const fromEnv = (env.BUILD_SHA ?? '').trim(); + if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; + try { + const head = run('rev-parse', 'HEAD').trim(); + if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; + return run('status', '--porcelain').trim() === '' ? head : `${head}-dirty`; + } catch { + return 'unknown'; + } +}; + +export const buildShaPlugin = (sha = readBuildSha()) => ({ + name: 'ratatoskr-build-sha', + transformIndexHtml: () => [ + { tag: 'meta', attrs: { name: 'build-sha', content: sha }, injectTo: 'head-prepend' }, + ], +}); diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js new file mode 100644 index 00000000..e80f01b5 --- /dev/null +++ b/scripts/buildSha.test.js @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { buildShaPlugin, readBuildSha } from './buildSha'; + +const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; +const gitAt = + (head, status = '') => + (cmd) => + cmd === 'rev-parse' ? `${head}\n` : status; + +describe('readBuildSha', () => { + it('reads the checkout it is built from', () => { + expect(readBuildSha({}, gitAt(SHA))).toBe(SHA); + }); + it('marks a tree with uncommitted changes, so a stamp never vouches for code it does not hold', () => { + expect(readBuildSha({}, gitAt(SHA, ' M src/x.ts\n'))).toBe(`${SHA}-dirty`); + }); + it('lets BUILD_SHA stand in where there is no git, and ignores a value that is not a full sha', () => { + const noGit = () => { + throw new Error('not a git repository'); + }; + expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); + }); + it("says 'unknown' rather than failing the build when git is absent", () => { + expect( + readBuildSha({}, () => { + throw new Error('spawn git ENOENT'); + }) + ).toBe('unknown'); + }); +}); + +describe('buildShaPlugin', () => { + it("hands vite a meta tag for the page's head carrying the sha", () => { + const plugin = buildShaPlugin(SHA); + expect(plugin.transformIndexHtml()).toEqual([ + { tag: 'meta', attrs: { name: 'build-sha', content: SHA }, injectTo: 'head-prepend' }, + ]); + }); +}); diff --git a/vite.config.js b/vite.config.js index e9b67550..699c3148 100644 --- a/vite.config.js +++ b/vite.config.js @@ -10,6 +10,7 @@ import { VitePWA } from 'vite-plugin-pwa'; import fs from 'fs'; import path from 'path'; import buildConfig from './build.config'; +import { buildShaPlugin } from './scripts/buildSha'; const copyFiles = { targets: [ @@ -104,6 +105,7 @@ export default defineConfig({ }), viteStaticCopy(copyFiles), vanillaExtractPlugin(), + buildShaPlugin(), wasm(), react(), VitePWA({ From 46e96500e495483626bcfc5d708e94df845ed86d Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:20:59 +0000 Subject: [PATCH 11/49] buildSha: ignore vite's temp config file, run git in the repo dir Co-Authored-By: Claude Sonnet 5.5 --- scripts/buildSha.js | 18 ++++++++++++++---- scripts/buildSha.test.js | 27 ++++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/scripts/buildSha.js b/scripts/buildSha.js index 741c5983..d6c4870f 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -3,17 +3,27 @@ // BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, // suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. import { execFileSync } from 'child_process'; +import { dirname, resolve } from 'path'; +import { fileURLToPath } from 'url'; -const git = (...args) => - execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); +const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); -export const readBuildSha = (env = process.env, run = git) => { +// Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the +// build's own, not source, so it must not mark the tree dirty. Any other untracked file still does. +const VITE_TEMP = ':(exclude,glob)**/vite.config.*.timestamp-*.mjs'; + +export const gitIn = + (cwd) => + (...args) => + execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + +export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; try { const head = run('rev-parse', 'HEAD').trim(); if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; - return run('status', '--porcelain').trim() === '' ? head : `${head}-dirty`; + return run('status', '--porcelain', '--', '.', VITE_TEMP).trim() === '' ? head : `${head}-dirty`; } catch { return 'unknown'; } diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index e80f01b5..368cc960 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -1,5 +1,9 @@ +import { execFileSync } from 'child_process'; +import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; import { describe, expect, it } from 'vitest'; -import { buildShaPlugin, readBuildSha } from './buildSha'; +import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; const gitAt = @@ -30,6 +34,27 @@ describe('readBuildSha', () => { }); }); +describe('readBuildSha against a real repository', () => { + const repo = () => { + const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); + g('init', '-q'); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n'); + g('add', '.'); + g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'); + return dir; + }; + it("stays clean with vite's temp config file present, and is dirty with a stray source file", () => { + const dir = repo(); + const run = gitIn(dir); + writeFileSync(join(dir, 'vite.config.js.timestamp-1700000000-abc123.mjs'), ''); + expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}$/); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({}, run)).toMatch(/^[0-9a-f]{40}-dirty$/); + }); +}); + describe('buildShaPlugin', () => { it("hands vite a meta tag for the page's head carrying the sha", () => { const plugin = buildShaPlugin(SHA); From 917f9a0da2c36e4152775daa99bb2d5f87e2e0ec Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:22 +0000 Subject: [PATCH 12/49] buildSha test: remove the temp repos after each test Co-Authored-By: Claude Sonnet 5.5 --- scripts/buildSha.test.js | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index 368cc960..c45c8747 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -1,8 +1,8 @@ import { execFileSync } from 'child_process'; -import { mkdtempSync, writeFileSync, mkdirSync } from 'fs'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'fs'; import { tmpdir } from 'os'; import { join } from 'path'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it } from 'vitest'; import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; @@ -35,8 +35,13 @@ describe('readBuildSha', () => { }); describe('readBuildSha against a real repository', () => { + const made = []; + afterEach(() => { + made.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); + }); const repo = () => { const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + made.push(dir); const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); g('init', '-q'); mkdirSync(join(dir, 'src')); From f7395f12e3522077c74a111ec70fb902295e55c2 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:39:58 +0000 Subject: [PATCH 13/49] Phone: icon buttons, reaction chips and short sender names get a 44 px hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/message/Reaction.css.ts | 11 +++++ .../components/message/layout/layout.css.ts | 7 ++- .../password-input/PasswordInput.tsx | 2 + src/app/features/room/RoomInput.tsx | 14 +++++- src/app/features/room/RoomViewHeader.tsx | 20 +++++++-- src/app/features/room/message/Message.tsx | 7 +++ src/app/features/scene/SkyStateControl.tsx | 2 + .../pages/auth/login/PasswordLoginForm.tsx | 2 + src/app/phoneTapTargets.test.ts | 40 +++++++++++++++++ src/app/styles/PhoneHit.css.ts | 45 +++++++++++++++++++ 10 files changed, 144 insertions(+), 6 deletions(-) create mode 100644 src/app/styles/PhoneHit.css.ts diff --git a/src/app/components/message/Reaction.css.ts b/src/app/components/message/Reaction.css.ts index f020080e..08003f0e 100644 --- a/src/app/components/message/Reaction.css.ts +++ b/src/app/components/message/Reaction.css.ts @@ -1,6 +1,9 @@ import { createVar, style } from '@vanilla-extract/css'; import { DefaultReset, FocusOutline, color, config, toRem } from 'folds'; +import { MOBILE_BREAKPOINT } from '../../hooks/useScreenSize'; +import { PHONE_TAP } from '../../styles/PhoneHit.css'; +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const Container = createVar(); const ContainerHover = createVar(); const ContainerActive = createVar(); @@ -25,6 +28,14 @@ export const Reaction = style([ selectors: { 'button&': { cursor: 'pointer', + // Phone tap target: the chip is 26 px tall, so its hit area grows 9 px above and below (an ::after) and the + // same 9 px of margin opens the row, so the chips of one row and of the next never share a tap. + '@media': { [PHONE]: { position: 'relative', marginBlock: (PHONE_TAP - 26) / 2 } }, + }, + 'button&::after': { + '@media': { + [PHONE]: { content: '""', position: 'absolute', inset: `${-(PHONE_TAP - 26) / 2}px 0` }, + }, }, '&[aria-pressed=true]': { vars: { diff --git a/src/app/components/message/layout/layout.css.ts b/src/app/components/message/layout/layout.css.ts index 3d1a9c83..39a40aa8 100644 --- a/src/app/components/message/layout/layout.css.ts +++ b/src/app/components/message/layout/layout.css.ts @@ -299,7 +299,10 @@ export const BubbleLeftArrow = style({ }); // Phone tap target: a sender name is 23 px tall, so the button grows 11 px above and below (44+ px to tap) and the -// negative margin takes the growth back out of the layout. Hit area only: no background, border or outline moves. +// negative margin takes the growth back out of the layout. Sideways it cannot borrow from the layout the +// same way (a negative inline margin makes the button shrink inside its shrink-to-fit parent and clip the name), so +// a name under 44 px is 44 px wide, text kept at the start: real growth of at most 16 px, in a row with room for it. +// No background, border or outline moves. const SCREEN_PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; export const Username = style({ overflow: 'hidden', @@ -309,7 +312,7 @@ export const Username = style({ 'button&': { cursor: 'pointer', '@media': { - [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11 }, + [SCREEN_PHONE]: { paddingBlock: 11, marginBlock: -11, minWidth: 44, textAlign: 'start' }, }, }, 'button&:hover, button&:focus-visible': { diff --git a/src/app/components/password-input/PasswordInput.tsx b/src/app/components/password-input/PasswordInput.tsx index 4ffe0d83..6aa0d4f3 100644 --- a/src/app/components/password-input/PasswordInput.tsx +++ b/src/app/components/password-input/PasswordInput.tsx @@ -1,6 +1,7 @@ import React, { ComponentProps, forwardRef } from 'react'; import { Icon, IconButton, Input, config, Icons } from 'folds'; import { UseStateProvider } from '../UseStateProvider'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type PasswordInputProps = Omit, 'type' | 'size'> & { size: '400' | '500'; @@ -23,6 +24,7 @@ export const PasswordInput = forwardRef( <> {after} setVisible(!visible)} type="button" variant={visible ? 'Warning' : variant} diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 03926da9..41f67d03 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -116,6 +116,7 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; +import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; interface RoomInputProps { editor: Editor; @@ -605,6 +606,7 @@ export const RoomInput = forwardRef( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} variant="SurfaceVariant" size="300" @@ -638,6 +640,7 @@ export const RoomInput = forwardRef( } before={ pickFile('*')} variant="SurfaceVariant" size="300" @@ -649,6 +652,7 @@ export const RoomInput = forwardRef( after={ <> ( > {!hideStickerBtn && ( setEmojiBoardTab(EmojiBoardTab.Sticker)} variant="SurfaceVariant" @@ -704,6 +709,7 @@ export const RoomInput = forwardRef( )} ( )} - + diff --git a/src/app/features/room/RoomViewHeader.tsx b/src/app/features/room/RoomViewHeader.tsx index 8089b5ef..3132c77a 100644 --- a/src/app/features/room/RoomViewHeader.tsx +++ b/src/app/features/room/RoomViewHeader.tsx @@ -72,6 +72,7 @@ import { RoomSettingsPage } from '../../state/roomSettings'; import { useCallEmbed, useCallStart } from '../../hooks/useCallEmbed'; import { useLivekitSupport } from '../../hooks/useLivekitSupport'; import { webRTCSupported } from '../../utils/rtc'; +import { PhoneHitSpread40 } from '../../styles/PhoneHit.css'; type RoomMenuProps = { room: Room; @@ -336,6 +337,7 @@ function CallButton() { > {(triggerRef) => ( {(onBack) => ( - + @@ -531,7 +533,12 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { } > {(triggerRef) => ( - + )} @@ -548,6 +555,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( {(triggerRef) => ( - + )} @@ -631,6 +644,7 @@ export function RoomViewHeader({ callView }: { callView?: boolean }) { > {(triggerRef) => ( void; @@ -950,6 +951,7 @@ export const Message = as<'div', MessageProps>( } > ( )} ( {!isThreadedMessage && ( onReplyClick(ev, true)} data-event-id={mEvent.getId()} variant="SurfaceVariant" @@ -982,6 +986,7 @@ export const Message = as<'div', MessageProps>( )} {canEditEvent(mx, mEvent) && onEditId && ( onEditId(mEvent.getId())} variant="SurfaceVariant" size="300" @@ -1147,6 +1152,7 @@ export const Message = as<'div', MessageProps>( } > ( } > = { terrain: 'Terrain', ridge: 'Ridges' }; @@ -149,6 +150,7 @@ export function SkyStateControl() { } > (); @@ -94,6 +95,7 @@ function UsernameHint({ server }: { server: string }) { } > { expect(phone.match(/min-height: 2\.75rem/g)?.length).toBeGreaterThanOrEqual(2); }); }); + +describe('phone icon-button hit areas', () => { + const stripPhone = (css: string) => css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + + it('draw a 44 x 44 box around the button on a phone and nothing on any other screen', async () => { + const css = await emitted('styles/PhoneHit.css.ts'); + const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; + expect(phone.map((m) => m[1])).toEqual([PHONE]); + expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + expect(css).toMatch(/::after \{[^}]*position: absolute;/); + expect(stripPhone(css).trim()).toBe(''); + }); + + it('open a row up by half the shortfall, so neighbouring hit areas meet and do not overlap', async () => { + const css = await emitted('styles/PhoneHit.css.ts'); + const margins = (selector: RegExp) => + rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); + // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, + // the menu around them clips a hit area drawn outside its box) + expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2])); + expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); + }); + + it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { + const css = await emitted('components/message/Reaction.css.ts'); + const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE); + expect(chip['margin-block']).toBe((TAP - 26) / 2); + expect(css).toMatch(/::after \{[^}]*inset: -9px 0;/); + rulesFor(css, /^button\.Reaction_Reaction__\w+$/, undefined).forEach((d) => + expect(Object.keys(d)).not.toContain('margin-block') + ); + }); + + it('give a sender name a 44 px wide button too, text kept at its start', async () => { + const css = await emitted('components/message/layout/layout.css.ts'); + const [phone] = rulesFor(css, /^button\.layout_Username__/, PHONE); + expect(phone['min-width']).toBe(TAP); + expect(css).toMatch(/text-align: start;/); + }); +}); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts new file mode 100644 index 00000000..14ff375c --- /dev/null +++ b/src/app/styles/PhoneHit.css.ts @@ -0,0 +1,45 @@ +import { style } from '@vanilla-extract/css'; +import { MOBILE_BREAKPOINT } from '../hooks/useScreenSize'; + +// Phone tap target for an icon button: on a phone the button's hit area is 44 x 44 px centred on the drawn +// button (a ::after the button's own click reaches), so nothing drawn changes size. A button with neighbours +// 44 px closer than that would take their taps, so the `Spread` classes also give it horizontal margin of half +// the shortfall: the row grows by that, on phones only, and the hit areas meet instead of overlapping. +// Vertical room is cheaper than horizontal on a phone, so only the horizontal axis pays. +export const PHONE_TAP = 44; +const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; + +const hitArea = { + '@media': { + [PHONE]: { + content: '""', + position: 'absolute', + top: '50%', + left: '50%', + width: `max(100%, ${PHONE_TAP}px)`, + height: `max(100%, ${PHONE_TAP}px)`, + transform: 'translate(-50%, -50%)', + }, + }, +} as const; + +/** An icon button on its own: the hit area grows, nothing else moves. */ +export const PhoneHit = style({ + '@media': { [PHONE]: { position: 'relative' } }, + selectors: { '&::after': hitArea }, +}); + +/** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ +const spread = (visual: number) => + style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); + +const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread32 = spread(32); +export const PhoneHitSpread40 = spread(40); + +/** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row + * opens up on both axes, so the 44 px sits inside the container. */ +export const PhoneHitRoom26 = style([ + PhoneHitSpread26, + { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - 26) / 2 } } }, +]); From 856096e215c794aa29eb81cb1043082521563173 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:41:33 +0000 Subject: [PATCH 14/49] Desk back button: phone hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/desk/Desk.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/app/features/desk/Desk.tsx b/src/app/features/desk/Desk.tsx index f1695716..a09501e7 100644 --- a/src/app/features/desk/Desk.tsx +++ b/src/app/features/desk/Desk.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useMemo, useState } from 'react'; import { useNavigate, useParams } from 'react-router-dom'; import { Box, Icon, IconButton, Icons, Scroll, Text } from 'folds'; +import { PhoneHit } from '../../styles/PhoneHit.css'; import { Page, PageContent, PageContentCenter, PageHeader } from '../../components/page'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { getHomePath } from '../../pages/pathUtils'; @@ -33,7 +34,11 @@ export function Desk() { {phone && ( - navigate(getHomePath())}> + navigate(getHomePath())} + > )} From ee55c322146ddfc135e68772b12449e404e0bc1a Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:27:14 +0000 Subject: [PATCH 15/49] Phone: quick-reaction emoji buttons and delete/report dialog close buttons get a 44 px hit area Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/message/Message.tsx | 18 ++++++++++++++---- 1 file changed, 14 insertions(+), 4 deletions(-) diff --git a/src/app/features/room/message/Message.tsx b/src/app/features/room/message/Message.tsx index 8ad1e958..a1bc17c9 100644 --- a/src/app/features/room/message/Message.tsx +++ b/src/app/features/room/message/Message.tsx @@ -86,7 +86,7 @@ import colorMXID from '../../../../util/colorMXID'; import { useSkyInk } from '../../../hooks/useTheme'; import { userColorInk } from '../../scene/skyInk'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; -import { PhoneHitRoom26 } from '../../../styles/PhoneHit.css'; +import { PhoneHitRoom26, PhoneHitSpread32 } from '../../../styles/PhoneHit.css'; export type ReactionHandler = (keyOrMxc: string, shortcode: string) => void; @@ -112,7 +112,7 @@ export const MessageQuickReactions = as<'div', MessageQuickReactionsProps>( {recentEmojis.map((emoji) => ( Delete Message - + @@ -590,7 +595,12 @@ export const MessageReportItem = as< Report Message - + From 21ff16717711a0d576b04de4b67a94771a115511 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:09 +0000 Subject: [PATCH 16/49] phoneTapTargets: every IconButton in Message, RoomInput, RoomViewHeader carries a PhoneHit class Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 5dcf2958..b50ec02d 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,3 +1,4 @@ +import { readFileSync } from 'fs'; import { describe, expect, it } from 'vitest'; import zlib from 'zlib'; import { compile, processVanillaFile } from '@vanilla-extract/integration'; @@ -156,3 +157,32 @@ describe('phone icon-button hit areas', () => { expect(css).toMatch(/text-align: start;/); }); }); + +describe('icon buttons in the room view', () => { + // The opening tag of every outside any {} expression. + const iconButtonTags = (src: string) => { + const tags: string[] = []; + for (let i = src.indexOf('' && depth === 0) break; + } + tags.push(src.slice(i, j + 1)); + } + return tags; + }; + + it.each([ + 'features/room/message/Message.tsx', + 'features/room/RoomInput.tsx', + 'features/room/RoomViewHeader.tsx', + ])('%s: every IconButton carries a PhoneHit class', (file) => { + const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); + expect(tags.length).toBeGreaterThan(0); + const bare = tags.filter((t) => !/PhoneHit/.test(t)); + expect(bare).toEqual([]); + }); +}); From 2a1f2c6b172d63c569bae7260b1b9716204ed891 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:08 +0000 Subject: [PATCH 17/49] Consumer-side tests: RoomOutbox as rendered; hung send to watchdog reload to resend Co-Authored-By: Claude Sonnet 5.5 --- .../features/room/RoomOutbox.render.test.tsx | 64 +++++++++++++++++ src/client/stallRecovery.test.ts | 70 +++++++++++++++++++ 2 files changed, 134 insertions(+) create mode 100644 src/app/features/room/RoomOutbox.render.test.tsx create mode 100644 src/client/stallRecovery.test.ts diff --git a/src/app/features/room/RoomOutbox.render.test.tsx b/src/app/features/room/RoomOutbox.render.test.tsx new file mode 100644 index 00000000..e4c258df --- /dev/null +++ b/src/app/features/room/RoomOutbox.render.test.tsx @@ -0,0 +1,64 @@ +import React from 'react'; +import { renderToStaticMarkup } from 'react-dom/server'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox, failOutbox } from '../../state/room/composerStore'; +import { RoomOutbox } from './RoomOutbox'; + +// What the person sees under the timeline for sends the server has not confirmed. +const text = (html: string) => html.replace(/<[^>]*>/g, ' ').replace(/\s+/g, ' '); + +describe('RoomOutbox as rendered', () => { + beforeEach(() => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const mx = { getUserId: () => '@a:x' } as never; + const entry = (txnId: string, roomId: string, body: string) => ({ + txnId, + roomId, + content: { body }, + }); + + it('renders nothing when the room has no unconfirmed sends', () => { + enqueueOutbox('@a:x', entry('t1', '!other:x', 'elsewhere')); + expect(renderToStaticMarkup()).toBe(''); + }); + + it('shows a fresh send as Sending with its text and no Resend', () => { + enqueueOutbox('@a:x', entry('t1', '!r:x', 'on its way'), Date.now() + 1000); + const html = renderToStaticMarkup(); + expect(html).toContain('data-outbox-status="pending"'); + expect(text(html)).toContain('Sending…'); + expect(text(html)).toContain('on its way'); + expect(text(html)).not.toContain('Resend'); + }); + + it('shows a failed send with its error, Resend and Discard', () => { + enqueueOutbox('@a:x', entry('t2', '!r:x', 'did not go')); + failOutbox('@a:x', 't2', 'ECONNRESET'); + const html = renderToStaticMarkup(); + expect(html).toContain('data-outbox-status="failed"'); + expect(text(html)).toContain('Not sent: ECONNRESET'); + expect(text(html)).toContain('Resend'); + expect(text(html)).toContain('Discard'); + }); + + it('shows a send left over from before this page load as interrupted, resendable', () => { + enqueueOutbox('@a:x', entry('t3', '!r:x', 'lost to a reload'), 5); + const html = renderToStaticMarkup(); + expect(text(html)).toContain('Interrupted by a reload'); + expect(text(html)).toContain('Resend'); + }); +}); diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts new file mode 100644 index 00000000..bbeefd63 --- /dev/null +++ b/src/client/stallRecovery.test.ts @@ -0,0 +1,70 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { loadOutbox } from '../app/state/room/composerStore'; +import { resendInterrupted, sendTracked } from '../app/state/room/outboxSend'; +import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; +import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; + +// The dead-connection story from the user's side: a send hangs, the page notices and reloads +// itself once, and the message goes out again from the outbox under its own txn id. +const memoryStore = () => { + const m = new Map(); + return { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }; +}; + +describe('a send that hangs on a dead connection', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.stubGlobal('localStorage', memoryStore()); + }); + afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); + }); + + it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { + const hung = vi.fn(() => new Promise(() => {})); + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-1', + sendEvent: hung, + getRoom: () => undefined, + } as never; + const reload = vi.fn(); + const session = memoryStore(); + const content = { msgtype: 'm.text', body: 'hello from a dead connection' }; + + const stop = startWatchdog({ + pendingAges: () => ownPendingAges('@a:x'), + isVisible: () => true, + isOnline: () => true, + recover: () => reloadToRecoverSync(Date.now(), () => session, reload), + }); + // intentionally never settles: the request is hung + sendTracked(mx, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending' }]); + + vi.advanceTimersByTime(SEND_STALL_MS - WATCHDOG_INTERVAL_MS); + expect(reload).not.toHaveBeenCalled(); + vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS); + expect(reload).toHaveBeenCalledTimes(1); + // the stall persists across the reload guard window: no reload loop + vi.advanceTimersByTime(RECOVER_RELOAD_MIN_GAP_MS - 2 * WATCHDOG_INTERVAL_MS); + expect(reload).toHaveBeenCalledTimes(1); + stop(); + + // the reloaded page: the entry is older than the page, the network works again + const send = vi.fn().mockResolvedValue({ event_id: '$e' }); + const reloaded = { ...mx, sendEvent: send } as never; + await resendInterrupted(reloaded, Date.now() + 1); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + expect(loadOutbox('@a:x')).toEqual([]); + }); +}); From 76c1f56bd8ec1d3b7d3adb5d1e8f995e26d8d682 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:17 +0000 Subject: [PATCH 18/49] stallRecovery test: lint --- src/client/stallRecovery.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index bbeefd63..aae6f2fe 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -30,7 +30,7 @@ describe('a send that hangs on a dead connection', () => { }); it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn(() => new Promise(() => {})); + const hung = vi.fn(() => new Promise(() => undefined)); const mx = { getUserId: () => '@a:x', makeTxnId: () => 'txn-1', From 9d1d39922c81d999ec0cdeb698e4f1a42f0d8101 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 01:02:33 +0000 Subject: [PATCH 19/49] stallRecovery test: lint clean --- src/client/stallRecovery.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index aae6f2fe..f2f35bf0 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -30,7 +30,12 @@ describe('a send that hangs on a dead connection', () => { }); it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn(() => new Promise(() => undefined)); + const hung = vi.fn( + () => + new Promise(() => { + // never settles + }) + ); const mx = { getUserId: () => '@a:x', makeTxnId: () => 'txn-1', From 09b062d7be2b09a4ec3d0eca739369884b72c25e Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:25:35 +0000 Subject: [PATCH 20/49] stallRecovery test: distinct txn ids per send; pin visible/online guards and own-tab pending filter; fix TS2698 Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 121 ++++++++++++++++++++++--------- 1 file changed, 85 insertions(+), 36 deletions(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index f2f35bf0..39ebb2b8 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -1,60 +1,74 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { loadOutbox } from '../app/state/room/composerStore'; +import { PAGE_ID, loadOutbox } from '../app/state/room/composerStore'; import { resendInterrupted, sendTracked } from '../app/state/room/outboxSend'; import { RECOVER_RELOAD_MIN_GAP_MS, ownPendingAges, reloadToRecoverSync } from './initMatrix'; import { SEND_STALL_MS, WATCHDOG_INTERVAL_MS, startWatchdog } from './watchdog'; // The dead-connection story from the user's side: a send hangs, the page notices and reloads // itself once, and the message goes out again from the outbox under its own txn id. -const memoryStore = () => { - const m = new Map(); - return { - getItem: (k: string) => m.get(k) ?? null, - setItem: (k: string, v: string) => { - m.set(k, v); - }, - removeItem: (k: string) => { - m.delete(k); - }, - }; -}; +const memoryStore = (m = new Map()) => ({ + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, +}); + +const OUTBOX_KEY = 'ratatoskr.outbox.@a:x'; describe('a send that hangs on a dead connection', () => { + let storage: Map; beforeEach(() => { vi.useFakeTimers(); - vi.stubGlobal('localStorage', memoryStore()); + storage = new Map(); + vi.stubGlobal('localStorage', memoryStore(storage)); }); afterEach(() => { vi.useRealTimers(); vi.unstubAllGlobals(); }); - it('is kept in the outbox, reloads the page once after the stall, and is resent under the same txn id', async () => { - const hung = vi.fn( - () => - new Promise(() => { - // never settles - }) - ); - const mx = { + // Every send gets its own txn id, as the SDK's makeTxnId gives; a client that hands out one + // constant id could not tell a resend of the right message from a resend of the wrong one. + const hungClient = () => { + let n = 0; + return { getUserId: () => '@a:x', - makeTxnId: () => 'txn-1', - sendEvent: hung, + makeTxnId: () => { + n += 1; + return `txn-${n}`; + }, + sendEvent: vi.fn(() => new Promise(() => undefined)), // never settles: the request is hung getRoom: () => undefined, - } as never; + }; + }; + const watch = (env: { visible: boolean; online: boolean }) => { const reload = vi.fn(); const session = memoryStore(); - const content = { msgtype: 'm.text', body: 'hello from a dead connection' }; - const stop = startWatchdog({ pendingAges: () => ownPendingAges('@a:x'), - isVisible: () => true, - isOnline: () => true, + isVisible: () => env.visible, + isOnline: () => env.online, recover: () => reloadToRecoverSync(Date.now(), () => session, reload), }); - // intentionally never settles: the request is hung - sendTracked(mx, '!r:x', content); - expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1', status: 'pending' }]); + return { reload, stop }; + }; + const stalled = () => vi.advanceTimersByTime(SEND_STALL_MS + 2 * WATCHDOG_INTERVAL_MS); + + it('is kept in the outbox, reloads the page once after the stall, and each message is resent under its own txn id', async () => { + const mx = hungClient(); + const first = { msgtype: 'm.text', body: 'hello from a dead connection' }; + const second = { msgtype: 'm.text', body: 'and a second one' }; + const { reload, stop } = watch({ visible: true, online: true }); + + sendTracked(mx as never, '!r:x', first); + sendTracked(mx as never, '!q:x', second); + expect(loadOutbox('@a:x')).toMatchObject([ + { txnId: 'txn-1', status: 'pending' }, + { txnId: 'txn-2', status: 'pending' }, + ]); vi.advanceTimersByTime(SEND_STALL_MS - WATCHDOG_INTERVAL_MS); expect(reload).not.toHaveBeenCalled(); @@ -65,11 +79,46 @@ describe('a send that hangs on a dead connection', () => { expect(reload).toHaveBeenCalledTimes(1); stop(); - // the reloaded page: the entry is older than the page, the network works again + // the reloaded page: the entries are older than the page, the network works again const send = vi.fn().mockResolvedValue({ event_id: '$e' }); - const reloaded = { ...mx, sendEvent: send } as never; - await resendInterrupted(reloaded, Date.now() + 1); - expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-1'); + const reloaded = { ...mx, sendEvent: send }; + await resendInterrupted(reloaded as never, Date.now() + 1); + expect(send).toHaveBeenCalledTimes(2); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', first, 'txn-1'); + expect(send).toHaveBeenCalledWith('!q:x', 'm.room.message', second, 'txn-2'); expect(loadOutbox('@a:x')).toEqual([]); }); + + it.each([ + ['hidden', { visible: false, online: true }], + ['offline', { visible: true, online: false }], + ])('does not reload a stalled page while it is %s, and does once it is back', (_name, env) => { + const mx = hungClient(); + const state = { ...env }; + const { reload, stop } = watch(state); + sendTracked(mx as never, '!r:x', { body: 'x' }); + stalled(); + vi.advanceTimersByTime(10 * SEND_STALL_MS); + expect(reload).not.toHaveBeenCalled(); + state.visible = true; + state.online = true; + vi.advanceTimersByTime(2 * WATCHDOG_INTERVAL_MS); + expect(reload).toHaveBeenCalledTimes(1); + stop(); + }); + + it('does not reload for a send that belongs to another tab, or one that already failed', () => { + const mx = hungClient(); + const { reload, stop } = watch({ visible: true, online: true }); + sendTracked(mx as never, '!r:x', { body: 'theirs' }); + sendTracked(mx as never, '!r:x', { body: 'failed' }); + const entries = JSON.parse(storage.get(OUTBOX_KEY) as string); + entries[0].tab = `${PAGE_ID}-other`; + entries[1].status = 'failed'; + storage.set(OUTBOX_KEY, JSON.stringify(entries)); + stalled(); + vi.advanceTimersByTime(10 * SEND_STALL_MS); + expect(reload).not.toHaveBeenCalled(); + stop(); + }); }); From fab6a04894ceff2972ed5dbd75de7b72c94672bd Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:25:46 +0000 Subject: [PATCH 21/49] stallRecovery test: lint clean Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index 39ebb2b8..148ebb31 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -40,7 +40,12 @@ describe('a send that hangs on a dead connection', () => { n += 1; return `txn-${n}`; }, - sendEvent: vi.fn(() => new Promise(() => undefined)), // never settles: the request is hung + sendEvent: vi.fn( + () => + new Promise(() => { + // never settles: the request is hung + }) + ), getRoom: () => undefined, }; }; From e04c7c074f48a879122466cdec3851e294619f1c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 03:48:49 +0000 Subject: [PATCH 22/49] stallRecovery test: a held SDK echo makes the resend use a fresh txn id Co-Authored-By: Claude Sonnet 5.5 --- src/client/stallRecovery.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/src/client/stallRecovery.test.ts b/src/client/stallRecovery.test.ts index 148ebb31..ae1e5217 100644 --- a/src/client/stallRecovery.test.ts +++ b/src/client/stallRecovery.test.ts @@ -94,6 +94,25 @@ describe('a send that hangs on a dead connection', () => { expect(loadOutbox('@a:x')).toEqual([]); }); + it('resends under a fresh txn id when the SDK still holds an echo for the old one', async () => { + const mx = hungClient(); + const content = { msgtype: 'm.text', body: 'echo still held' }; + sendTracked(mx as never, '!r:x', content); + expect(loadOutbox('@a:x')).toMatchObject([{ txnId: 'txn-1' }]); + + const send = vi.fn().mockResolvedValue({ event_id: '$e' }); + const held = { + ...mx, + makeTxnId: () => 'txn-fresh', + sendEvent: send, + getRoom: () => ({ getEventForTxnId: (id: string) => (id === 'txn-1' ? {} : undefined) }), + }; + await resendInterrupted(held as never, Date.now() + 1); + expect(send).toHaveBeenCalledTimes(1); + expect(send).toHaveBeenCalledWith('!r:x', 'm.room.message', content, 'txn-fresh'); + expect(loadOutbox('@a:x')).toEqual([]); + }); + it.each([ ['hidden', { visible: false, online: true }], ['offline', { visible: true, online: false }], From 0e3115809ea05f64c327170e57e1ebabfa550f39 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:02:30 +0000 Subject: [PATCH 23/49] phoneTapTargets: pin the exact PhoneHit variant of each IconButton Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index b50ec02d..b190e643 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -175,14 +175,24 @@ describe('icon buttons in the room view', () => { return tags; }; - it.each([ - 'features/room/message/Message.tsx', - 'features/room/RoomInput.tsx', - 'features/room/RoomViewHeader.tsx', - ])('%s: every IconButton carries a PhoneHit class', (file) => { - const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); - expect(tags.length).toBeGreaterThan(0); - const bare = tags.filter((t) => !/PhoneHit/.test(t)); - expect(bare).toEqual([]); - }); + // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size + // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. + const expected: Record = { + 'features/room/message/Message.tsx': [ + 'PhoneHitSpread32', // quick-reaction emoji buttons + 'PhoneHitSpread32', // delete dialog close + 'PhoneHitSpread32', // report dialog close + ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu + ], + 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), + 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => { + const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); + expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want); + } + ); }); From da9ef70c03b336bd1e636ff9a5bb08220a8159a7 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:04:45 +0000 Subject: [PATCH 24/49] Consumer tests: startClient wiring, Resend/Discard handlers, the 30 s slow-send threshold Co-Authored-By: Claude Sonnet 5.5 --- .../features/room/RoomOutbox.actions.test.tsx | 94 ++++++++++++++ src/app/features/room/RoomOutbox.test.ts | 5 + src/client/startClient.test.ts | 115 ++++++++++++++++++ 3 files changed, 214 insertions(+) create mode 100644 src/app/features/room/RoomOutbox.actions.test.tsx create mode 100644 src/client/startClient.test.ts diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx new file mode 100644 index 00000000..c762220f --- /dev/null +++ b/src/app/features/room/RoomOutbox.actions.test.tsx @@ -0,0 +1,94 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox, failOutbox, loadOutbox } from '../../state/room/composerStore'; +import { RoomOutbox } from './RoomOutbox'; + +// The component's hooks are held still (state is its initial value, effects do not run) so the element tree +// it returns can be read and its click handlers called without a DOM. +vi.mock('react', async (importOriginal) => { + const actual = await importOriginal(); + const useState = (init: unknown) => [ + typeof init === 'function' ? (init as () => unknown)() : init, + () => undefined, + ]; + return { + ...actual, + default: { ...actual, useState, useEffect: () => undefined }, + useState, + useEffect: () => undefined, + }; +}); + +type El = { props?: { children?: unknown; onClick?: () => void } }; +const flatten = (node: unknown): El[] => { + if (Array.isArray(node)) return node.flatMap(flatten); + if (node && typeof node === 'object') { + const el = node as El; + return [el, ...flatten(el.props?.children)]; + } + return []; +}; +const textOf = (node: unknown): string => { + if (typeof node === 'string' || typeof node === 'number') return String(node); + if (Array.isArray(node)) return node.map(textOf).join(''); + if (node && typeof node === 'object') return textOf((node as El).props?.children); + return ''; +}; +/** The onClick of the chip whose label is `label`. */ +const clickOf = (tree: unknown, label: string) => { + const chip = flatten(tree).find((e) => e.props?.onClick && textOf(e) === label); + if (!chip?.props?.onClick) throw new Error(`no ${label} chip`); + return chip.props.onClick; +}; + +describe('RoomOutbox actions', () => { + beforeEach(() => { + const m = new Map(); + vi.stubGlobal('localStorage', { + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, + }); + enqueueOutbox('@a:x', { txnId: 'keep', roomId: '!r:x', content: { body: 'stay' } }); + enqueueOutbox('@a:x', { txnId: 'bad', roomId: '!r:x', content: { body: 'did not go' } }); + failOutbox('@a:x', 'bad', 'ECONNRESET'); + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const mount = () => { + const mx = { + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + getRoom: () => undefined, + sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }), + }; + const tree = (RoomOutbox as unknown as (p: unknown) => unknown)({ mx, roomId: '!r:x' }); + return { mx, tree }; + }; + + it('Discard removes that message from the outbox and no other', () => { + const { mx, tree } = mount(); + // both entries are shown; the failed one is the one with Discard + clickOf(tree, 'Discard')(); + expect(loadOutbox('@a:x').map((e) => e.txnId)).toEqual(['keep']); + expect(mx.sendEvent).not.toHaveBeenCalled(); + }); + + it('Resend sends that message again and does not discard it first', async () => { + const { mx, tree } = mount(); + clickOf(tree, 'Resend')(); + await Promise.resolve(); + expect(mx.sendEvent).toHaveBeenCalledTimes(1); + expect(mx.sendEvent).toHaveBeenCalledWith( + '!r:x', + 'm.room.message', + { body: 'did not go' }, + 'bad' + ); + }); +}); diff --git a/src/app/features/room/RoomOutbox.test.ts b/src/app/features/room/RoomOutbox.test.ts index 55597694..30d7c0bb 100644 --- a/src/app/features/room/RoomOutbox.test.ts +++ b/src/app/features/room/RoomOutbox.test.ts @@ -17,6 +17,11 @@ describe('outboxView', () => { }); }); + it('offers Resend to a pending send at 30 s and not a millisecond before', () => { + const at = (ms: number) => outboxView({ status: 'pending', ts: 100 }, 100 + ms, 0).canResend; + expect([at(29_999), at(30_000)]).toEqual([false, true]); + }); + it('an entry older than this page load reads as interrupted', () => { expect(outboxView({ status: 'pending', ts: 5 }, 6, 10).label).toMatch(/Interrupted/); }); diff --git a/src/client/startClient.test.ts b/src/client/startClient.test.ts new file mode 100644 index 00000000..11b488e3 --- /dev/null +++ b/src/client/startClient.test.ts @@ -0,0 +1,115 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { enqueueOutbox } from '../app/state/room/composerStore'; +import { PAGE_START } from '../app/state/room/outboxSend'; +import { startClient } from './initMatrix'; +import { startResumeSync } from './resumeSync'; +import { startWatchdog } from './watchdog'; + +vi.mock('./resumeSync', () => ({ startResumeSync: vi.fn() })); +vi.mock('./watchdog', async (importOriginal) => ({ + ...(await importOriginal()), + startWatchdog: vi.fn(() => () => undefined), +})); + +const memoryStore = (m = new Map()) => ({ + getItem: (k: string) => m.get(k) ?? null, + setItem: (k: string, v: string) => { + m.set(k, v); + }, + removeItem: (k: string) => { + m.delete(k); + }, +}); + +// startClient is the one place the dead-connection pieces are wired to the page: what the watchdog reads, +// when it may fire, what it does, and what runs after the reload. These tests call it and exercise that wiring. +describe('startClient wiring', () => { + let handlers: Array<(state: string) => void>; + let mx: Record & { + sendEvent: ReturnType; + off: ReturnType; + }; + beforeEach(() => { + vi.clearAllMocks(); + handlers = []; + vi.stubGlobal('localStorage', memoryStore()); + mx = { + startClient: vi.fn().mockResolvedValue(undefined), + getUserId: () => '@a:x', + makeTxnId: () => 'txn-new', + getRoom: () => undefined, + sendEvent: vi.fn().mockResolvedValue({ event_id: '$e' }), + on: (_ev: string, h: (state: string) => void) => { + handlers.push(h); + }, + off: vi.fn(), + }; + }); + afterEach(() => { + vi.unstubAllGlobals(); + }); + + const watchdogOptions = () => + (startWatchdog as unknown as ReturnType).mock.calls[0][0] as Parameters< + typeof startWatchdog + >[0]; + + it('starts sync, the resume nudge and the watchdog once per client', async () => { + await startClient(mx as never); + await startClient(mx as never); + expect(mx.startClient).toHaveBeenCalledTimes(2); + expect(startResumeSync).toHaveBeenCalledTimes(1); + expect(startResumeSync).toHaveBeenCalledWith(mx); + expect(startWatchdog).toHaveBeenCalledTimes(1); + }); + + it("feeds the watchdog this page's pending sends for this user", async () => { + await startClient(mx as never); + const { pendingAges } = watchdogOptions(); + expect(pendingAges()).toEqual([]); + enqueueOutbox('@a:x', { txnId: 't', roomId: '!r:x', content: {} }, Date.now() - 7000); + enqueueOutbox('@b:x', { txnId: 'u', roomId: '!r:x', content: {} }, Date.now() - 1000); + const ages = pendingAges(); + expect(ages).toHaveLength(1); + expect(ages[0]).toBeGreaterThanOrEqual(7000); + }); + + it('reads visibility and connectivity from the page', async () => { + const doc = { visibilityState: 'visible' }; + const nav = { onLine: true }; + vi.stubGlobal('document', doc); + vi.stubGlobal('navigator', nav); + await startClient(mx as never); + const { isVisible, isOnline } = watchdogOptions(); + expect([isVisible(), isOnline()]).toEqual([true, true]); + doc.visibilityState = 'hidden'; + nav.onLine = false; + expect([isVisible(), isOnline()]).toEqual([false, false]); + }); + + it('recovers by a guarded reload, at most once a minute', async () => { + const reload = vi.fn(); + vi.stubGlobal('window', { location: { reload } }); + vi.stubGlobal('sessionStorage', memoryStore()); + await startClient(mx as never); + const { recover } = watchdogOptions(); + recover(); + recover(); + expect(reload).toHaveBeenCalledTimes(1); + }); + + it('resends what a reload interrupted once sync is up, and only once', async () => { + enqueueOutbox( + '@a:x', + { txnId: 'old', roomId: '!r:x', content: { body: 'x' } }, + PAGE_START - 5000 + ); + await startClient(mx as never); + expect(handlers).toHaveLength(1); + handlers[0]('CONNECTING'); + expect(mx.sendEvent).not.toHaveBeenCalled(); + handlers[0]('SYNCING'); + expect(mx.off).toHaveBeenCalledWith('sync', handlers[0]); + expect(mx.sendEvent).toHaveBeenCalledWith('!r:x', 'm.room.message', { body: 'x' }, 'old'); + }); +}); From bd2d9800235054f27c995c15b377254b63b23d12 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:10:46 +0000 Subject: [PATCH 25/49] phoneTapTargets: pin top, left and transform of the hit-area box Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index b190e643..d4e31772 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -127,6 +127,10 @@ describe('phone icon-button hit areas', () => { expect(phone.map((m) => m[1])).toEqual([PHONE]); expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); expect(css).toMatch(/::after \{[^}]*position: absolute;/); + // centred on the button: top and left at half, pulled back by half its own size + expect(css).toMatch(/::after \{[^}]*top: 50%;/); + expect(css).toMatch(/::after \{[^}]*left: 50%;/); + expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); expect(stripPhone(css).trim()).toBe(''); }); From ae3760fcc79e8bed793e5a806ffb574ed44214e8 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 04:11:15 +0000 Subject: [PATCH 26/49] RoomOutbox actions test: Resend keeps the entry in the outbox, pending again Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomOutbox.actions.test.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/app/features/room/RoomOutbox.actions.test.tsx b/src/app/features/room/RoomOutbox.actions.test.tsx index c762220f..0dbd4f5d 100644 --- a/src/app/features/room/RoomOutbox.actions.test.tsx +++ b/src/app/features/room/RoomOutbox.actions.test.tsx @@ -81,6 +81,12 @@ describe('RoomOutbox actions', () => { it('Resend sends that message again and does not discard it first', async () => { const { mx, tree } = mount(); + // the server has not answered yet: the resend is in flight + mx.sendEvent.mockReturnValue( + new Promise(() => { + // never settles + }) + ); clickOf(tree, 'Resend')(); await Promise.resolve(); expect(mx.sendEvent).toHaveBeenCalledTimes(1); @@ -90,5 +96,7 @@ describe('RoomOutbox actions', () => { { body: 'did not go' }, 'bad' ); + // still in the outbox, pending again under the same id, until the server confirms it + expect(loadOutbox('@a:x').find((e) => e.txnId === 'bad')).toMatchObject({ status: 'pending' }); }); }); From fc92809eb91192b2f238c6e210840c17108295cc Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:40:21 +0000 Subject: [PATCH 27/49] Phone: 44 px hit areas for the chat path's remaining icon buttons; pin variant, import and ::after content MessageEditor, RoomNavItem, MembersDrawer, ReactionViewer, RoomPinMenu, RoomViewTyping, CallChatView and JumpToTime get PhoneHit (alone) or PhoneHitSpread32 (two neighbours in a row). phoneTapTargets.test.ts now lists them, plus PasswordInput, Desk, SkyStateControl and PasswordLoginForm, which had no test, checks each class is imported from styles/PhoneHit.css, and pins the ::after content (a mutant without it passed before). Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/RoomNavItem.tsx | 3 +++ src/app/features/room/CallChatView.tsx | 8 +++++- src/app/features/room/MembersDrawer.tsx | 3 +++ src/app/features/room/RoomViewTyping.tsx | 9 ++++++- .../features/room/jump-to-time/JumpToTime.tsx | 3 ++- .../features/room/message/MessageEditor.tsx | 3 +++ .../room/reaction-viewer/ReactionViewer.tsx | 3 ++- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++- src/app/phoneTapTargets.test.ts | 26 +++++++++++++++++-- 9 files changed, 55 insertions(+), 7 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index e8a349f0..842d7146 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,6 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; +import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -229,6 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -436,6 +438,7 @@ export function RoomNavItem({ } > {(triggerRef) => ( - + )} diff --git a/src/app/features/room/MembersDrawer.tsx b/src/app/features/room/MembersDrawer.tsx index d9205076..3162eff0 100644 --- a/src/app/features/room/MembersDrawer.tsx +++ b/src/app/features/room/MembersDrawer.tsx @@ -59,6 +59,7 @@ import { useSpaceOptionally } from '../../hooks/useSpace'; import { ContainerColor } from '../../styles/ContainerColor.css'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../hooks/useRoomCreators'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type MemberDrawerHeaderProps = { room: Room; @@ -87,6 +88,7 @@ function MemberDrawerHeader({ room }: MemberDrawerHeaderProps) { > {(triggerRef) => ( setPeopleDrawer(false)} @@ -359,6 +361,7 @@ export function MembersDrawer({ room, members }: MembersDrawerProps) { virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/room/RoomViewTyping.tsx b/src/app/features/room/RoomViewTyping.tsx index 1142a3a8..9e0266e9 100644 --- a/src/app/features/room/RoomViewTyping.tsx +++ b/src/app/features/room/RoomViewTyping.tsx @@ -10,6 +10,7 @@ import { getMxIdLocalPart } from '../../utils/matrix'; import * as css from './RoomViewTyping.css'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export type RoomViewTypingProps = { room: Room; @@ -111,7 +112,13 @@ export const RoomViewTyping = as<'div', RoomViewTypingProps>( )} - + diff --git a/src/app/features/room/jump-to-time/JumpToTime.tsx b/src/app/features/room/jump-to-time/JumpToTime.tsx index 223c6cf6..4b231856 100644 --- a/src/app/features/room/jump-to-time/JumpToTime.tsx +++ b/src/app/features/room/jump-to-time/JumpToTime.tsx @@ -31,6 +31,7 @@ import { getToday, getYesterday, timeDayMonthYear, timeHourMinute } from '../../ import { DatePicker, TimePicker } from '../../../components/time-date'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type JumpToTimeProps = { onCancel: () => void; @@ -108,7 +109,7 @@ export function JumpToTime({ onCancel, onSubmit }: JumpToTimeProps) { Jump to Time - + diff --git a/src/app/features/room/message/MessageEditor.tsx b/src/app/features/room/message/MessageEditor.tsx index 2d7f2097..45d79eb7 100644 --- a/src/app/features/room/message/MessageEditor.tsx +++ b/src/app/features/room/message/MessageEditor.tsx @@ -54,6 +54,7 @@ import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { getEditedEvent, getMentionContent, trimReplyFromFormattedBody } from '../../../utils/room'; import { mobileOrTablet } from '../../../utils/user-agent'; import { useComposingCheck } from '../../../hooks/useComposingCheck'; +import { PhoneHitSpread32 } from '../../../styles/PhoneHit.css'; type MessageEditorProps = { roomId: string; @@ -289,6 +290,7 @@ export const MessageEditor = as<'div', MessageEditorProps>( ( } > diff --git a/src/app/features/room/reaction-viewer/ReactionViewer.tsx b/src/app/features/room/reaction-viewer/ReactionViewer.tsx index 6c686bc5..b1a674d9 100644 --- a/src/app/features/room/reaction-viewer/ReactionViewer.tsx +++ b/src/app/features/room/reaction-viewer/ReactionViewer.tsx @@ -28,6 +28,7 @@ import { useMediaAuthentication } from '../../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../../hooks/useSpace'; import { getMouseEventCords } from '../../../utils/dom'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export type ReactionViewerProps = { room: Room; @@ -100,7 +101,7 @@ export const ReactionViewer = as<'div', ReactionViewerProps>( {`Reacted with :${selectedShortcode}:`} - + diff --git a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx index 9986849f..53f8d073 100644 --- a/src/app/features/room/room-pin-menu/RoomPinMenu.tsx +++ b/src/app/features/room/room-pin-menu/RoomPinMenu.tsx @@ -86,6 +86,7 @@ import { useGetMemberPowerTag, } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PinnedMessageProps = { room: Room; @@ -146,6 +147,7 @@ function PinnedMessage({ {canPinEvent && ( ( Pinned Messages - + diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index d4e31772..be4bceb6 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -126,6 +126,8 @@ describe('phone icon-button hit areas', () => { const phone = [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)]; expect(phone.map((m) => m[1])).toEqual([PHONE]); expect(css).toMatch(/::after \{[^}]*width: max\(100%, 44px\);[^}]*height: max\(100%, 44px\);/); + // no content, no box: the pseudo-element is not rendered at all + expect(css).toMatch(/::after \{[^}]*content: "";/); expect(css).toMatch(/::after \{[^}]*position: absolute;/); // centred on the button: top and left at half, pulled back by half its own size expect(css).toMatch(/::after \{[^}]*top: 50%;/); @@ -190,13 +192,33 @@ describe('icon buttons in the room view', () => { ], 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), + // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. + 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top + 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/RoomViewTyping.tsx': ['PhoneHit'], + 'features/room/CallChatView.tsx': ['PhoneHit'], + 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], + // Buttons that were fixed with #125 and had no test. + 'components/password-input/PasswordInput.tsx': ['PhoneHit'], + 'features/desk/Desk.tsx': ['PhoneHit'], + 'features/scene/SkyStateControl.tsx': ['PhoneHit'], + 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], }; it.each(Object.entries(expected))( '%s: each IconButton carries its own PhoneHit class', (file, want) => { - const tags = iconButtonTags(readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8')); - expect(tags.map((t) => t.match(/\bPhoneHit\w+/)?.[0] ?? null)).toEqual(want); + const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + // the name is the one PhoneHit.css.ts exports, not a local of the same name + [...new Set(want)].forEach((name) => + expect(src).toMatch( + new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + ) + ); } ); }); From 446306b1e72b8c5103c04c7ffc54df4266650008 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:46:21 +0000 Subject: [PATCH 28/49] PhoneHit: Spread24 for the nav row's 24 px buttons, Spread26 for the unpin button; tests pin position, ::after declarations, className and import alias Soul pass on fc92809e: RoomNavItem's buttons are 24 px, not 32 (36 px pitch, hit areas overlapped); the unpin button is 26 px beside the Open chip. Six mutants survived (position: relative, pointer-events, z-index, title for className, aliased import); all now killed. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room-nav/RoomNavItem.tsx | 6 ++--- .../room/room-pin-menu/RoomPinMenu.tsx | 4 ++-- src/app/phoneTapTargets.test.ts | 24 +++++++++++++++---- src/app/styles/PhoneHit.css.ts | 3 ++- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/app/features/room-nav/RoomNavItem.tsx b/src/app/features/room-nav/RoomNavItem.tsx index 842d7146..8d53ce7f 100644 --- a/src/app/features/room-nav/RoomNavItem.tsx +++ b/src/app/features/room-nav/RoomNavItem.tsx @@ -66,7 +66,7 @@ import { useAutoDiscoveryInfo } from '../../hooks/useAutoDiscoveryInfo'; import { livekitSupport } from '../../hooks/useLivekitSupport'; import { StateEvent } from '../../../types/matrix/room'; import { webRTCSupported } from '../../utils/rtc'; -import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; +import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type RoomNavItemMenuProps = { room: Room; @@ -230,7 +230,7 @@ function CallChatToggle() { return ( setChat(!chat)} aria-pressed={chat} aria-label="Toggle Chat" @@ -438,7 +438,7 @@ export function RoomNavItem({ } > {canPinEvent && ( { expect(css).toMatch(/::after \{[^}]*top: 50%;/); expect(css).toMatch(/::after \{[^}]*left: 50%;/); expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); + // the box anchors to its own button, receives the tap, and sits above nothing it should not + expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); + const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) => + m[1] + .split('\n') + .map((l) => l.trim().split(':')[0]) + .filter(Boolean) + ); + expect(afterDecls).not.toContain('pointer-events'); + expect(afterDecls).not.toContain('z-index'); expect(stripPhone(css).trim()).toBe(''); }); @@ -142,7 +152,7 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([9, 6, 2])); + expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -194,10 +204,10 @@ describe('icon buttons in the room view', () => { 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji - 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // chat toggle, options + 'features/room-nav/RoomNavItem.tsx': ['PhoneHitSpread24', 'PhoneHitSpread24'], // chat toggle, options: 16 px icon + 8 padding = 24 px, 0 gap 'features/room/MembersDrawer.tsx': ['PhoneHit', 'PhoneHit'], // close, scroll to top 'features/room/reaction-viewer/ReactionViewer.tsx': ['PhoneHit'], - 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHit', 'PhoneHit'], // unpin, close + 'features/room/room-pin-menu/RoomPinMenu.tsx': ['PhoneHitSpread26', 'PhoneHit'], // unpin (26 px, 8 px from the Open chip), close 'features/room/RoomViewTyping.tsx': ['PhoneHit'], 'features/room/CallChatView.tsx': ['PhoneHit'], 'features/room/jump-to-time/JumpToTime.tsx': ['PhoneHit'], @@ -212,11 +222,15 @@ describe('icon buttons in the room view', () => { '%s: each IconButton carries its own PhoneHit class', (file, want) => { const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - expect(iconButtonTags(src).map((t) => t.match(/\bPhoneHit\w*/)?.[0] ?? null)).toEqual(want); + expect( + iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null) + ).toEqual(want); // the name is the one PhoneHit.css.ts exports, not a local of the same name [...new Set(want)].forEach((name) => expect(src).toMatch( - new RegExp(`import \\{[^}]*\\b${name}\\b[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'`) + new RegExp( + `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + ) ) ); } diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 14ff375c..4ebc8370 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -33,7 +33,8 @@ export const PhoneHit = style({ const spread = (visual: number) => style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); -const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread24 = spread(24); +export const PhoneHitSpread26 = spread(26); export const PhoneHitSpread32 = spread(32); export const PhoneHitSpread40 = spread(40); From 0c8474231e778da10b2c6cdb0a258e91fffba902 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 05:50:10 +0000 Subject: [PATCH 29/49] phoneTapTargets: each Spread class owns its margin, ::after declaration set is exact, no phone rule moves position, import must not be aliased Soul re-gate of 446306b1: swapped Spread24/26 definitions, display:none or overflow on the ::after, position:static on a Spread rule and an aliased import all passed; all killed now. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneTapTargets.test.ts | 22 ++++++++++++++++++---- src/app/styles/PhoneHit.css.ts | 5 ++++- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index cf7a6b27..a6459ade 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -141,8 +141,20 @@ describe('phone icon-button hit areas', () => { .map((l) => l.trim().split(':')[0]) .filter(Boolean) ); - expect(afterDecls).not.toContain('pointer-events'); - expect(afterDecls).not.toContain('z-index'); + expect([...afterDecls].sort()).toEqual([ + 'content', + 'height', + 'left', + 'position', + 'top', + 'transform', + 'width', + ]); + // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box) + expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ + 'absolute', + 'relative', + ]); expect(stripPhone(css).trim()).toBe(''); }); @@ -152,7 +164,9 @@ describe('phone icon-button hit areas', () => { rulesFor(css, selector, PHONE).map((d) => d['margin-inline']); // 32 px composer buttons, 40 px header buttons, 26 px message-option buttons (which also open vertically, // the menu around them clips a hit area drawn outside its box) - expect(margins(/PhoneHit_spread__/)).toEqual(expect.arrayContaining([10, 9, 6, 2])); + // each class owns its own margin: (44 - drawn size) / 2 + const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); + expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]); expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); }); @@ -229,7 +243,7 @@ describe('icon buttons in the room view', () => { [...new Set(want)].forEach((name) => expect(src).toMatch( new RegExp( - `import \\{[^}]*\\b${name}\\b(?!\\s+as\\b)[^}]*\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` ) ) ); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 4ebc8370..8403c126 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -31,7 +31,10 @@ export const PhoneHit = style({ /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ const spread = (visual: number) => - style([PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }]); + style( + [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], + `PhoneHitSpread${visual}` + ); export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); From c45cdc7bd7bca9729c7d565b3d84ab8ec97c0521 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:52:32 +0000 Subject: [PATCH 30/49] buildSha: BUILD_SHA no longer masks a dirty tree; the vite temp-file exclusion is untracked-only The pathspec exclude hid a tracked file of that name; the status lines are filtered instead. Tests against a real repo: explicit sha plus stray file, stray file beside the temp file in root and subfolder, tracked file of the temp name, new folder. Six mutants of the logic each fail a test. Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 3 +- scripts/buildSha.js | 22 +++++++---- scripts/buildSha.test.js | 56 +++++++++++++++++++++++++++ 3 files changed, 72 insertions(+), 9 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index d89541da..03bd3adc 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,7 +48,8 @@ not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. The handoff +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. An explicit `BUILD_SHA` names the commit where there is no git; where git +sees a changed tree it is suffixed `-dirty` too, so it never vouches for code it does not hold. The handoff build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. diff --git a/scripts/buildSha.js b/scripts/buildSha.js index d6c4870f..fc025643 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -1,7 +1,8 @@ // The commit a bundle was built from, stamped into its index.html as , so a built // folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. -// BUILD_SHA wins when it is a full sha (a build from a source archive has no git); otherwise git's HEAD, -// suffixed -dirty when the tree has uncommitted changes; otherwise 'unknown'. +// BUILD_SHA names the commit when it is a full sha (a build from a source archive has no git), otherwise git's +// HEAD does; either way -dirty is appended when git can see uncommitted changes, so an explicit sha never +// vouches for a tree that differs from it. No git and no BUILD_SHA: 'unknown'. import { execFileSync } from 'child_process'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -9,8 +10,9 @@ import { fileURLToPath } from 'url'; const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); // Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the -// build's own, not source, so it must not mark the tree dirty. Any other untracked file still does. -const VITE_TEMP = ':(exclude,glob)**/vite.config.*.timestamp-*.mjs'; +// build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name, +// or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only. +const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[^/]*\.timestamp-[^/]*\.mjs$/; export const gitIn = (cwd) => @@ -19,13 +21,17 @@ export const gitIn = export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); - if (/^[0-9a-f]{40}$/.test(fromEnv)) return fromEnv; + const explicit = /^[0-9a-f]{40}$/.test(fromEnv) ? fromEnv : null; try { const head = run('rev-parse', 'HEAD').trim(); - if (!/^[0-9a-f]{40}$/.test(head)) return 'unknown'; - return run('status', '--porcelain', '--', '.', VITE_TEMP).trim() === '' ? head : `${head}-dirty`; + const sha = explicit ?? head; + if (!/^[0-9a-f]{40}$/.test(sha)) return 'unknown'; + const dirty = run('status', '--porcelain', '-uall', '--', '.') + .split('\n') + .some((l) => l.trim() !== '' && !VITE_TEMP.test(l)); + return dirty ? `${sha}-dirty` : sha; } catch { - return 'unknown'; + return explicit ?? 'unknown'; } }; diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index c45c8747..b7bd85e7 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -23,6 +23,8 @@ describe('readBuildSha', () => { throw new Error('not a git repository'); }; expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); + // an explicit sha with a clean tree is just itself + expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(SHA); expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); }); it("says 'unknown' rather than failing the build when git is absent", () => { @@ -60,6 +62,60 @@ describe('readBuildSha against a real repository', () => { }); }); +describe('readBuildSha dirty rules against a real repository', () => { + const made2 = []; + afterEach(() => { + made2.splice(0).forEach((d) => rmSync(d, { recursive: true, force: true })); + }); + const TEMP = 'vite.config.js.timestamp-1700000000-abc123.mjs'; + const repo2 = (tracked = []) => { + const dir = mkdtempSync(join(tmpdir(), 'buildsha-')); + made2.push(dir); + const g = (...a) => execFileSync('git', a, { cwd: dir, stdio: 'ignore' }); + g('init', '-q'); + mkdirSync(join(dir, 'src')); + writeFileSync(join(dir, 'src', 'a.ts'), 'export {};\n'); + tracked.forEach((f) => writeFileSync(join(dir, f), 'x\n')); + g('add', '.'); + g('-c', 'user.name=t', '-c', 'user.email=t@t', 'commit', '-qm', 'x'); + return { + dir, + head: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: dir, encoding: 'utf8' }).trim(), + }; + }; + it('does not let an explicit BUILD_SHA mask a dirty tree', () => { + const { dir } = repo2(); + const run = gitIn(dir); + expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(SHA); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(`${SHA}-dirty`); + }); + it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => { + const { dir, head } = repo2(); + const run = gitIn(dir); + writeFileSync(join(dir, TEMP), ''); + writeFileSync(join(dir, 'src', TEMP), ''); + expect(readBuildSha({}, run)).toBe(head); + writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); + expect(readBuildSha({}, run)).toBe(`${head}-dirty`); + }); + it('marks a tracked file of the temp name dirty once it changes', () => { + const { dir, head } = repo2([TEMP]); + const run = gitIn(dir); + expect(readBuildSha({}, run)).toBe(head); + writeFileSync(join(dir, TEMP), 'changed\n'); + expect(readBuildSha({}, run)).toBe(`${head}-dirty`); + }); + it('marks an untracked file in a new folder dirty (status lists files, not the folder)', () => { + const { dir, head } = repo2(); + mkdirSync(join(dir, 'new')); + writeFileSync(join(dir, 'new', TEMP), ''); + expect(readBuildSha({}, gitIn(dir))).toBe(head); + writeFileSync(join(dir, 'new', 'b.ts'), ''); + expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`); + }); +}); + describe('buildShaPlugin', () => { it("hands vite a meta tag for the page's head carrying the sha", () => { const plugin = buildShaPlugin(SHA); From c82193f0fb7613537938f9fdf3aced2d968cb411 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:56:27 +0000 Subject: [PATCH 31/49] buildSha: git speaks only for a tree it roots, mismatch and git failure fail closed, temp-file name matched exactly Soul found: explicit sha stamped clean over a different HEAD; status failure returned the bare explicit sha; an archive inside another repo got that repo's verdict; the temp regex let any untracked .mjs pass. Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 3 +- scripts/buildSha.js | 31 +++++++++----- scripts/buildSha.test.js | 58 +++++++++++++++++++++++---- 3 files changed, 74 insertions(+), 18 deletions(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index 03bd3adc..e2215d61 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,8 +48,7 @@ not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. An explicit `BUILD_SHA` names the commit where there is no git; where git -sees a changed tree it is suffixed `-dirty` too, so it never vouches for code it does not hold. The handoff +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive). The handoff build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. diff --git a/scripts/buildSha.js b/scripts/buildSha.js index fc025643..61a89445 100644 --- a/scripts/buildSha.js +++ b/scripts/buildSha.js @@ -1,8 +1,9 @@ // The commit a bundle was built from, stamped into its index.html as , so a built // folder says what it is without a build log: `grep -o 'build-sha" content="[^"]*' dist/index.html`. -// BUILD_SHA names the commit when it is a full sha (a build from a source archive has no git), otherwise git's -// HEAD does; either way -dirty is appended when git can see uncommitted changes, so an explicit sha never -// vouches for a tree that differs from it. No git and no BUILD_SHA: 'unknown'. +// Where git governs the tree (REPO_DIR is a repository root) git decides: HEAD, suffixed -dirty when the tree has +// uncommitted changes, or `-mismatch` when an explicit BUILD_SHA names a different commit, so a stamp +// never vouches for code it does not hold; a git failure there is 'unknown'. Where git does not govern (a source +// archive), a full-sha BUILD_SHA stands, else 'unknown'. import { execFileSync } from 'child_process'; import { dirname, resolve } from 'path'; import { fileURLToPath } from 'url'; @@ -12,26 +13,38 @@ const REPO_DIR = resolve(dirname(fileURLToPath(import.meta.url)), '..'); // Vite writes vite.config.js.timestamp-*.mjs next to the config while it loads it; that file is the // build's own, not source, so an UNTRACKED one must not mark the tree dirty. A tracked file of that name, // or any other change, still does. Filtering status lines (not a pathspec) is what keeps it untracked-only. -const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[^/]*\.timestamp-[^/]*\.mjs$/; +const VITE_TEMP = /^\?\? (?:.*\/)?vite\.config\.[a-z]+\.timestamp-\d+-[0-9a-f]+\.mjs$/; export const gitIn = (cwd) => (...args) => execFileSync('git', args, { cwd, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); +const FULL_SHA = /^[0-9a-f]{40}$/; + export const readBuildSha = (env = process.env, run = gitIn(REPO_DIR)) => { const fromEnv = (env.BUILD_SHA ?? '').trim(); - const explicit = /^[0-9a-f]{40}$/.test(fromEnv) ? fromEnv : null; + const explicit = FULL_SHA.test(fromEnv) ? fromEnv : null; + // Git governs this tree only when REPO_DIR is its root: a source archive unpacked inside some other + // repository has no git of its own, and that repository's HEAD says nothing about it. + let governed; + try { + governed = run('rev-parse', '--show-prefix').trim() === ''; + } catch { + governed = false; + } + if (!governed) return explicit ?? 'unknown'; + // Git governs and answers: from here a failure is 'unknown', never the bare explicit sha. try { const head = run('rev-parse', 'HEAD').trim(); - const sha = explicit ?? head; - if (!/^[0-9a-f]{40}$/.test(sha)) return 'unknown'; + if (!FULL_SHA.test(head)) return 'unknown'; + if (explicit && explicit !== head) return `${explicit}-mismatch`; const dirty = run('status', '--porcelain', '-uall', '--', '.') .split('\n') .some((l) => l.trim() !== '' && !VITE_TEMP.test(l)); - return dirty ? `${sha}-dirty` : sha; + return dirty ? `${head}-dirty` : head; } catch { - return explicit ?? 'unknown'; + return 'unknown'; } }; diff --git a/scripts/buildSha.test.js b/scripts/buildSha.test.js index b7bd85e7..4a270867 100644 --- a/scripts/buildSha.test.js +++ b/scripts/buildSha.test.js @@ -8,8 +8,8 @@ import { buildShaPlugin, gitIn, readBuildSha } from './buildSha'; const SHA = 'b6b1d522f1c0a9d3e2b4c5d6e7f8091a2b3c4d5e'; const gitAt = (head, status = '') => - (cmd) => - cmd === 'rev-parse' ? `${head}\n` : status; + (cmd, ...args) => + cmd === 'rev-parse' ? (args[0] === '--show-prefix' ? '\n' : `${head}\n`) : status; describe('readBuildSha', () => { it('reads the checkout it is built from', () => { @@ -23,8 +23,25 @@ describe('readBuildSha', () => { throw new Error('not a git repository'); }; expect(readBuildSha({ BUILD_SHA: SHA }, noGit)).toBe(SHA); - // an explicit sha with a clean tree is just itself - expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: SHA }, gitAt(SHA))).toBe(SHA); + // a different HEAD is a mismatch, not a clean stamp of the explicit sha + expect(readBuildSha({ BUILD_SHA: SHA }, gitAt('c'.repeat(40)))).toBe(`${SHA}-mismatch`); + // a value that is not a lowercase full sha is ignored + ['b6b1d52', SHA.toUpperCase(), `${SHA}0`].forEach((v) => + expect(readBuildSha({ BUILD_SHA: v }, noGit)).toBe('unknown') + ); + // git that answers but cannot say what the tree holds fails closed + const brokenStatus = (cmd, ...args) => { + if (cmd === 'status') throw new Error('index file smaller than expected'); + return args[0] === '--show-prefix' ? '\n' : `${SHA}\n`; + }; + expect(readBuildSha({ BUILD_SHA: SHA }, brokenStatus)).toBe('unknown'); + expect(readBuildSha({}, brokenStatus)).toBe('unknown'); + expect(readBuildSha({}, gitAt('nope'))).toBe('unknown'); + // inside another repository's subfolder git does not govern: the explicit sha stands + const nested = (cmd, ...args) => (args[0] === '--show-prefix' ? 'vendor/ratatoskr/\n' : SHA); + expect(readBuildSha({ BUILD_SHA: SHA }, nested)).toBe(SHA); + expect(readBuildSha({}, nested)).toBe('unknown'); expect(readBuildSha({ BUILD_SHA: 'main' }, noGit)).toBe('unknown'); }); it("says 'unknown' rather than failing the build when git is absent", () => { @@ -84,11 +101,38 @@ describe('readBuildSha dirty rules against a real repository', () => { }; }; it('does not let an explicit BUILD_SHA mask a dirty tree', () => { - const { dir } = repo2(); + const { dir, head } = repo2(); const run = gitIn(dir); - expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(SHA); + expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(head); writeFileSync(join(dir, 'src', 'zz-stray.ts'), ''); - expect(readBuildSha({ BUILD_SHA: SHA }, run)).toBe(`${SHA}-dirty`); + expect(readBuildSha({ BUILD_SHA: head }, run)).toBe(`${head}-dirty`); + }); + it('lets git speak only for a tree whose root it is: an archive unpacked inside another repository keeps its explicit sha', () => { + const { dir } = repo2(); + const inner = join(dir, 'vendor', 'ratatoskr'); + mkdirSync(inner, { recursive: true }); + writeFileSync(join(inner, 'x.ts'), ''); + expect(readBuildSha({ BUILD_SHA: SHA }, gitIn(inner))).toBe(SHA); + expect(readBuildSha({}, gitIn(inner))).toBe('unknown'); + }); + it("notices an untracked .mjs that is not vite's temp file", () => { + const { dir, head } = repo2(); + writeFileSync(join(dir, 'foo.mjs'), ''); + expect(readBuildSha({}, gitIn(dir))).toBe(`${head}-dirty`); + rmSync(join(dir, 'foo.mjs')); + // only vite's exact shape is its own: vite.config..timestamp--.mjs + [ + 'vite.config.js.timestamp-1-ab.mjs.bak', + 'vite.config.js.timestamp-x-ab.mjs', + 'vite.config.js.timestamp-1-zz.mjs', + 'vite.config.a.b.timestamp-1-ab.mjs', + 'vite.config.js.timestamp-1-ab-c.mjs', + ].forEach((name) => { + writeFileSync(join(dir, name), ''); + expect(readBuildSha({}, gitIn(dir)), name).toBe(`${head}-dirty`); + rmSync(join(dir, name)); + }); + expect(readBuildSha({}, gitIn(dir))).toBe(head); }); it('treats a stray source file as dirty even beside the vite temp file, in the root or a subfolder', () => { const { dir, head } = repo2(); From eff894a89c15bf347ac374476715bc3577296495 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:57:45 +0000 Subject: [PATCH 32/49] docs: BUILD_SHA also stands where git cannot be read Co-Authored-By: Claude Sonnet 5.5 --- docs/gamecult/client-build-install.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gamecult/client-build-install.md b/docs/gamecult/client-build-install.md index e2215d61..b2505fe1 100644 --- a/docs/gamecult/client-build-install.md +++ b/docs/gamecult/client-build-install.md @@ -48,7 +48,7 @@ not resolve: pass `LIVE_CURL_ARGS="--resolve chat.gamecult.org:443:127.0.0.1"`. The folder, with the three pins (`SHA256SUMS` sha256, `BUILD-INFO` sha256, lockfile sha256) and the node and npm versions, is what the install request names. Every build stamps its commit into `index.html` (`scripts/buildSha.js`): ``, -with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive). The handoff +with `-dirty` appended when the tree had uncommitted changes and `unknown` when there was no git. Where git governs the tree it decides: an explicit `BUILD_SHA` naming a different commit than HEAD stamps `-mismatch`, a git failure stamps `unknown`. A full-sha `BUILD_SHA` stands only for a tree git does not govern (a source archive) or cannot read (unusable git, for example a dubious-ownership refusal). The handoff build refuses a dist whose stamp differs from the sha it was asked for, and prints the stamp in its block. To read it from anywhere: `curl -s https://chat.gamecult.org/ | grep -o 'build-sha" content="[^"]*'`. From 2b019a430217898afb0cd82a82414186ab2ce6e0 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:58:48 +0000 Subject: [PATCH 33/49] Phone hit: Spread28, Spread36 and Room36 variants Co-Authored-By: Claude Sonnet 5.5 --- src/app/styles/PhoneHit.css.ts | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 8403c126..c388e5f6 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -38,12 +38,18 @@ const spread = (visual: number) => export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); +export const PhoneHitSpread28 = spread(28); export const PhoneHitSpread32 = spread(32); +export const PhoneHitSpread36 = spread(36); export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row * opens up on both axes, so the 44 px sits inside the container. */ -export const PhoneHitRoom26 = style([ - PhoneHitSpread26, - { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - 26) / 2 } } }, -]); +const room = (visual: number, spreadClass: string) => + style( + [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], + `PhoneHitRoom${visual}` + ); + +export const PhoneHitRoom26 = room(26, PhoneHitSpread26); +export const PhoneHitRoom36 = room(36, PhoneHitSpread36); From d917ccd3bba89d078ed9e5e9253fc2679c6231bf Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 06:59:50 +0000 Subject: [PATCH 34/49] Phone hit tests: shared kit, size tie, pinned new variants The helpers moved to phoneHitTestKit.ts so each later group adds its own phoneHit..test.ts. A new check ties PhoneHitSpread/Room to the px the button draws (IconButton size padding + Icon size); a size the source does not state needs an explicit allow-list entry. It found RoomInput's reply-cancel (16 px icon, 24 px button) carrying Spread32; it is Spread24 now. Room takes its inline margin from the Spread class of the same size, so the two axes cannot disagree. Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/room/RoomInput.tsx | 4 +- src/app/phoneHitTestKit.ts | 161 ++++++++++++++++++++++++++++ src/app/phoneTapTargets.test.ts | 100 +++++------------ src/app/styles/PhoneHit.css.ts | 23 ++-- 4 files changed, 205 insertions(+), 83 deletions(-) create mode 100644 src/app/phoneHitTestKit.ts diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 41f67d03..2d63cbf1 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -116,7 +116,7 @@ import { useTheme } from '../../hooks/useTheme'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { usePowerLevelTags } from '../../hooks/usePowerLevelTags'; import { useComposingCheck } from '../../hooks/useComposingCheck'; -import { PhoneHitSpread32 } from '../../styles/PhoneHit.css'; +import { PhoneHitSpread24, PhoneHitSpread32 } from '../../styles/PhoneHit.css'; interface RoomInputProps { editor: Editor; @@ -606,7 +606,7 @@ export const RoomInput = forwardRef( style={{ padding: `${config.space.S200} ${config.space.S300} 0` }} > setReplyDraft(undefined)} variant="SurfaceVariant" size="300" diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts new file mode 100644 index 00000000..bcd81540 --- /dev/null +++ b/src/app/phoneHitTestKit.ts @@ -0,0 +1,161 @@ +// Shared helpers for the phone hit-area tests (phoneTapTargets.test.ts and one phoneHit..test.ts per later +// group of IconButtons). Not a test file: it registers nothing, the test files call it. +import { readFileSync } from 'fs'; +import { expect } from 'vitest'; +import zlib from 'zlib'; +import { compile, processVanillaFile } from '@vanilla-extract/integration'; + +// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; +// vanilla-extract gzips the inlined source only when it is large, so both forms are read). +export const emitted = async (file: string) => { + const filePath = `${process.cwd()}/src/app/${file}`; + const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); + const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + // A file with recipes inlines one source per rule group; read them all. + return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] + .map((m) => { + const bytes = Buffer.from(m[1], 'base64'); + return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); + }) + .join('\n'); +}; + +export type Decls = Record; +const parse = (body: string): Decls => + Object.fromEntries( + body + .split('\n') + .map((l) => l.trim().replace(/;$/, '')) + .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) + .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) + ); + +/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ +export const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { + const scope = media + ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] + .filter((m) => m[1] === media) + .map((m) => m[2]) + .join('\n') + : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); + return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] + .filter((m) => selector.test(m[1])) + .map((m) => parse(m[2])); +}; + +/** The source of a file under src/app. */ +export const readSource = (file: string) => + readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + +/** The end of a JSX opening tag starting at `from`: its closing > outside any {} expression. */ +const tagEnd = (src: string, from: number) => { + let depth = 0; + let j = from; + for (; j < src.length; j += 1) { + if (src[j] === '{') depth += 1; + else if (src[j] === '}') depth -= 1; + else if (src[j] === '>' && depth === 0) break; + } + return j; +}; + +/** The opening tag of every outside any {} expression. */ +export const iconButtonTags = (src: string) => { + const tags: string[] = []; + for (let i = src.indexOf(' { + const out: { tag: string; body: string }[] = []; + for (let i = src.indexOf('', end); + out.push({ tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }); + } + return out; +}; + +const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; + +/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and + * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks + * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ +export const expectPhoneHitClasses = (file: string, want: string[]) => { + const src = readSource(file); + expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); + [...new Set(want)].forEach((name) => + expect(src).toMatch( + new RegExp( + `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` + ) + ) + ); +}; + +// IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400. +const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 }; +const ICON_PX: Record = { + '50': 16, + '100': 18, + '200': 20, + '300': 22, + '400': 24, + '500': 28, +}; + +/** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */ +const sizeProp = (tag: string) => { + const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/); + if (!m) return '400'; + return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1]; +}; + +/** The px an IconButton draws, from its size prop and the Icon inside; undefined with a reason when the source + * does not say (a variable size, no Icon, Icons of different sizes). */ +export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => { + const pad = BUTTON_PAD[sizeProp(tag) ?? '']; + if (pad === undefined) return { undetermined: 'size of the IconButton is not a literal' }; + const icons = [...body.matchAll(/ { + const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1); + return ICON_PX[sizeProp(t) ?? '']; + }); + if (icons.length === 0) return { undetermined: 'no inside' }; + if (icons.some((p) => p === undefined)) + return { undetermined: 'size of an Icon is not a literal' }; + if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' }; + return { px: icons[0] + 2 * pad }; +}; + +/** Per file, per IconButton index (source order): why its drawn size cannot be derived from the source. */ +export type SizeAllow = Record>; + +/** A button carrying PhoneHitSpread or PhoneHitRoom draws N px. Plain PhoneHit has no number to tie. An + * undetermined size passes only with an entry in `allow`, and an entry for a size that can be derived fails. */ +export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { + const allowed = allow[file] ?? {}; + const seen = new Set(); + iconButtons(readSource(file)).forEach(({ tag, body }, i) => { + const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); + if (!variant) { + expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); + return; + } + const drawn = drawnPx(tag, body); + if ('undetermined' in drawn) { + expect( + allowed[i], + `${file} #${i}: ${drawn.undetermined}; add it to the allow-list` + ).toBeDefined(); + seen.add(i); + return; + } + expect(allowed[i], `${file} #${i} is allow-listed but its size is derivable`).toBeUndefined(); + expect(Number(variant[1]), `${file} #${i} draws ${drawn.px} px`).toBe(drawn.px); + }); + expect(Object.keys(allowed).map(Number).sort()).toEqual([...seen].sort()); +}; diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index a6459ade..f2166cad 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,46 +1,12 @@ -import { readFileSync } from 'fs'; import { describe, expect, it } from 'vitest'; -import zlib from 'zlib'; -import { compile, processVanillaFile } from '@vanilla-extract/integration'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; - -// The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; -// vanilla-extract gzips the inlined source only when it is large, so both forms are read). -const emitted = async (file: string) => { - const filePath = `${process.cwd()}/src/app/${file}`; - const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); - // A file with recipes inlines one source per rule group; read them all. - return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] - .map((m) => { - const bytes = Buffer.from(m[1], 'base64'); - return (bytes[0] === 0x1f && bytes[1] === 0x8b ? zlib.gunzipSync(bytes) : bytes).toString(); - }) - .join('\n'); -}; - -type Decls = Record; -const parse = (body: string): Decls => - Object.fromEntries( - body - .split('\n') - .map((l) => l.trim().replace(/;$/, '')) - .filter((l) => /^[a-z-]+: -?\d+px$/.test(l)) - .map((l) => [l.split(':')[0], Number(l.split(': ')[1].replace('px', ''))]) - ); - -/** Pixel declarations of the rules whose selector matches, inside or outside a media query. */ -const rulesFor = (css: string, selector: RegExp, media: string | undefined): Decls[] => { - const scope = media - ? [...css.matchAll(/@media ([^{]+) \{\n([\s\S]*?\n)\}(?:\n|$)/g)] - .filter((m) => m[1] === media) - .map((m) => m[2]) - .join('\n') - : css.replace(/@media [^{]+ \{\n[\s\S]*?\n\}(?:\n|$)/g, ''); - return [...scope.matchAll(/^\s*([^{}\n]+) \{\n([^}]*)\}/gm)] - .filter((m) => selector.test(m[1])) - .map((m) => parse(m[2])); -}; +import { + emitted, + expectPhoneHitClasses, + expectSizeTie, + rulesFor, + type SizeAllow, +} from './phoneHitTestKit'; const PHONE = `screen and (max-width: ${MOBILE_BREAKPOINT}px)`; const TAP = 44; @@ -166,8 +132,11 @@ describe('phone icon-button hit areas', () => { // the menu around them clips a hit area drawn outside its box) // each class owns its own margin: (44 - drawn size) / 2 const own = (size: number) => margins(new RegExp(`PhoneHit_PhoneHitSpread${size}__`)); - expect([own(24), own(26), own(32), own(40)]).toEqual([[10], [9], [6], [2]]); - expect(rulesFor(css, /PhoneHit_PhoneHitRoom26__/, PHONE)).toEqual([{ 'margin-block': 9 }]); + expect([24, 26, 28, 32, 36, 40].map(own)).toEqual([[10], [9], [8], [6], [4], [2]]); + // the Room variants open the block axis by the same amount, on top of their Spread class + const room = (size: number) => + rulesFor(css, new RegExp(`PhoneHit_PhoneHitRoom${size}__`), PHONE); + expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]); }); it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { @@ -189,22 +158,6 @@ describe('phone icon-button hit areas', () => { }); describe('icon buttons in the room view', () => { - // The opening tag of every outside any {} expression. - const iconButtonTags = (src: string) => { - const tags: string[] = []; - for (let i = src.indexOf('' && depth === 0) break; - } - tags.push(src.slice(i, j + 1)); - } - return tags; - }; - // The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size // and whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. const expected: Record = { @@ -214,7 +167,7 @@ describe('icon buttons in the room view', () => { 'PhoneHitSpread32', // report dialog close ...Array(6).fill('PhoneHitRoom26'), // message options bar, inside a clipping menu ], - 'features/room/RoomInput.tsx': Array(6).fill('PhoneHitSpread32'), + 'features/room/RoomInput.tsx': ['PhoneHitSpread24', ...Array(5).fill('PhoneHitSpread32')], // reply cancel is a 16 px icon, the rest 24 px 'features/room/RoomViewHeader.tsx': Array(6).fill('PhoneHitSpread40'), // The chat path's other buttons. Two neighbours in a row: Spread32. One on its own: the plain PhoneHit. 'features/room/message/MessageEditor.tsx': ['PhoneHitSpread32', 'PhoneHitSpread32'], // toolbar, emoji @@ -232,21 +185,20 @@ describe('icon buttons in the room view', () => { 'pages/auth/login/PasswordLoginForm.tsx': ['PhoneHit'], }; + // IconButtons whose drawn size the source does not say (file -> index in source order -> why). Everything else + // is derived from the size props, so a Spread32 on a 24 px button fails. + const sizeAllow: SizeAllow = { + 'features/room/message/Message.tsx': { + 0: 'quick-reaction button wraps an emoji , no Icon; 32 px by its CSS class', + }, + }; + it.each(Object.entries(expected))( '%s: each IconButton carries its own PhoneHit class', - (file, want) => { - const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); - expect( - iconButtonTags(src).map((t) => t.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null) - ).toEqual(want); - // the name is the one PhoneHit.css.ts exports, not a local of the same name - [...new Set(want)].forEach((name) => - expect(src).toMatch( - new RegExp( - `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` - ) - ) - ); - } + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: each sized variant equals the px its button draws', (file) => + expectSizeTie(file, sizeAllow) ); }); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index c388e5f6..189e58d1 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -29,12 +29,17 @@ export const PhoneHit = style({ selectors: { '&::after': hitArea }, }); +const spreads = new Map(); + /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */ -const spread = (visual: number) => - style( +const spread = (visual: number) => { + const cls = style( [PhoneHit, { '@media': { [PHONE]: { marginInline: (PHONE_TAP - visual) / 2 } } }], `PhoneHitSpread${visual}` ); + spreads.set(visual, cls); + return cls; +}; export const PhoneHitSpread24 = spread(24); export const PhoneHitSpread26 = spread(26); @@ -44,12 +49,16 @@ export const PhoneHitSpread36 = spread(36); export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row - * opens up on both axes, so the 44 px sits inside the container. */ -const room = (visual: number, spreadClass: string) => + * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the + * block margin, so the two axes cannot disagree about the size. */ +const room = (visual: number) => style( - [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], + [ + spreads.get(visual) ?? '', + { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }, + ], `PhoneHitRoom${visual}` ); -export const PhoneHitRoom26 = room(26, PhoneHitSpread26); -export const PhoneHitRoom36 = room(36, PhoneHitSpread36); +export const PhoneHitRoom26 = room(26); +export const PhoneHitRoom36 = room(36); From fe12d8a4b11b9e0862b4c25031939a85c78c277c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:05:58 +0000 Subject: [PATCH 35/49] Phone hit kit: pin Room composition, spread props are undetermined, tighter tag match Room throws when no Spread of its size exists, and a test reads the compiled class lists to prove Room composes Spread and PhoneHit. Props spread into an IconButton or Icon make the size undetermined (allow-list entry required). or /. iconButtonTags derives from iconButtons. expectSizeTie fails on a file with no IconButton. Fixture tests live in phoneHitTestKit.test.ts. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHitTestKit.test.ts | 41 ++++++++++++++++++++++ src/app/phoneHitTestKit.ts | 60 +++++++++++++++++++-------------- src/app/phoneTapTargets.test.ts | 15 +++++++++ src/app/styles/PhoneHit.css.ts | 12 +++---- 4 files changed, 97 insertions(+), 31 deletions(-) create mode 100644 src/app/phoneHitTestKit.test.ts diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts new file mode 100644 index 00000000..92f1b23f --- /dev/null +++ b/src/app/phoneHitTestKit.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest'; +import { drawnPx, expectSizeTie, iconButtons } from './phoneHitTestKit'; + +describe('phone hit test kit', () => { + it('reads { + const src = + ''; + expect(iconButtons(src).map((b) => b.tag)).toEqual([ + '', + '', + ]); + }); + + it('derives a drawn size from literal size props, defaults being 400', () => { + expect(drawnPx('', '')).toEqual({ px: 24 }); + expect(drawnPx('', '')).toEqual({ px: 40 }); + }); + + it('reads { + expect( + drawnPx('', '') + ).toEqual({ + px: 24, + }); + }); + + it('calls a size undetermined when props are spread into the IconButton or the Icon', () => { + expect(drawnPx('', '')).toHaveProperty('undetermined'); + expect(drawnPx('', '')).toHaveProperty( + 'undetermined' + ); + expect(drawnPx('', '')).toHaveProperty( + 'undetermined' + ); + expect(drawnPx('', '')).toHaveProperty('undetermined'); + }); + + it('fails a file with no IconButton, which would otherwise pass vacuously', () => { + expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index bcd81540..a29dcc22 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -7,10 +7,23 @@ import { compile, processVanillaFile } from '@vanilla-extract/integration'; // The CSS a .css.ts file emits, read back from vanilla-extract's own compile (as pages/auth/footerBand.test.ts does; // vanilla-extract gzips the inlined source only when it is large, so both forms are read). -export const emitted = async (file: string) => { +const compiled = async (file: string) => { const filePath = `${process.cwd()}/src/app/${file}`; const { source } = await compile({ filePath, cwd: process.cwd(), identOption: 'debug' }); - const code = await processVanillaFile({ source, filePath, identOption: 'debug' }); + return processVanillaFile({ source, filePath, identOption: 'debug' }); +}; + +/** The class list each export of a .css.ts file resolves to (a composed class lists the classes it composes). */ +export const classLists = async (file: string): Promise> => + Object.fromEntries( + [...(await compiled(file)).matchAll(/^export var (\w+) = '([^']*)';$/gm)].map((m) => [ + m[1], + m[2].split(' '), + ]) + ); + +export const emitted = async (file: string) => { + const code = await compiled(file); // A file with recipes inlines one source per rule group; read them all. return [...code.matchAll(/source=#?([A-Za-z0-9+/=]+)/g)] .map((m) => { @@ -59,26 +72,18 @@ const tagEnd = (src: string, from: number) => { return j; }; -/** The opening tag of every outside any {} expression. */ -export const iconButtonTags = (src: string) => { - const tags: string[] = []; - for (let i = src.indexOf(' { - const out: { tag: string; body: string }[] = []; - for (let i = src.indexOf(' outside any {} expression, and + * what it wraps (empty when self-closing). */ +export const iconButtons = (src: string) => + [...src.matchAll(//])/g)].map((m) => { + const start = m.index ?? 0; + const end = tagEnd(src, start); + const tag = src.slice(start, end + 1); const close = src.indexOf('', end); - out.push({ tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }); - } - return out; -}; + return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }; + }); + +export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; @@ -110,6 +115,8 @@ const ICON_PX: Record = { /** The size prop of a tag: its literal value, the default when absent, or undefined when it is an expression. */ const sizeProp = (tag: string) => { + // props spread in may carry a size of their own + if (tag.includes('{...')) return undefined; const m = tag.match(/\ssize=(?:"(\w+)"|\{([^}]*)\})/); if (!m) return '400'; return m[1] ?? m[2].match(/^'(\w+)'$/)?.[1]; @@ -119,14 +126,15 @@ const sizeProp = (tag: string) => { * does not say (a variable size, no Icon, Icons of different sizes). */ export const drawnPx = (tag: string, body: string): { px: number } | { undetermined: string } => { const pad = BUTTON_PAD[sizeProp(tag) ?? '']; - if (pad === undefined) return { undetermined: 'size of the IconButton is not a literal' }; - const icons = [...body.matchAll(/ { + if (pad === undefined) + return { undetermined: 'size of the IconButton is not a literal (or props are spread in)' }; + const icons = [...body.matchAll(//])/g)].map((m) => { const t = body.slice(m.index, tagEnd(body, m.index ?? 0) + 1); return ICON_PX[sizeProp(t) ?? '']; }); if (icons.length === 0) return { undetermined: 'no inside' }; if (icons.some((p) => p === undefined)) - return { undetermined: 'size of an Icon is not a literal' }; + return { undetermined: 'size of an Icon is not a literal (or props are spread in)' }; if (new Set(icons).size > 1) return { undetermined: 'Icons of different sizes inside' }; return { px: icons[0] + 2 * pad }; }; @@ -139,7 +147,9 @@ export type SizeAllow = Record>; export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { const allowed = allow[file] ?? {}; const seen = new Set(); - iconButtons(readSource(file)).forEach(({ tag, body }, i) => { + const buttons = iconButtons(readSource(file)); + expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0); + buttons.forEach(({ tag, body }, i) => { const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); if (!variant) { expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index f2166cad..5a5394e7 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; import { + classLists, emitted, expectPhoneHitClasses, expectSizeTie, @@ -139,6 +140,20 @@ describe('phone icon-button hit areas', () => { expect([room(26), room(36)]).toEqual([[{ 'margin-block': 9 }], [{ 'margin-block': 4 }]]); }); + it('compose a Room variant from the Spread class of the same size, which composes PhoneHit', async () => { + const lists = await classLists('styles/PhoneHit.css.ts'); + const named = (list: string[], name: string) => list.filter((c) => c.includes(`_${name}__`)); + [26, 36].forEach((size) => { + const room = lists[`PhoneHitRoom${size}`]; + expect( + named(room, `PhoneHitSpread${size}`), + `Room${size} composes Spread${size}` + ).toHaveLength(1); + expect(named(room, 'PhoneHit'), `Room${size} composes PhoneHit`).toHaveLength(1); + }); + expect(lists.PhoneHitRoom36.some((c) => c.includes('_PhoneHitSpread26__'))).toBe(false); + }); + it("make a message's reaction chip 44 px to tap by opening 9 px above and below it", async () => { const css = await emitted('components/message/Reaction.css.ts'); const [chip] = rulesFor(css, /^button\.Reaction_Reaction__\w+$/, PHONE); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 189e58d1..9863851b 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -51,14 +51,14 @@ export const PhoneHitSpread40 = spread(40); /** For a button in a clipping container (a Menu's overflow hidden eats a hit area drawn outside the box): the row * opens up on both axes, so the 44 px sits inside the container. It is the Spread class of the same size plus the * block margin, so the two axes cannot disagree about the size. */ -const room = (visual: number) => - style( - [ - spreads.get(visual) ?? '', - { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }, - ], +const room = (visual: number) => { + const spreadClass = spreads.get(visual); + if (!spreadClass) throw new Error(`PhoneHitRoom${visual} needs a PhoneHitSpread${visual}`); + return style( + [spreadClass, { '@media': { [PHONE]: { marginBlock: (PHONE_TAP - visual) / 2 } } }], `PhoneHitRoom${visual}` ); +}; export const PhoneHitRoom26 = room(26); export const PhoneHitRoom36 = room(36); From 351d1bc74539201221ea428b8aeb975af2111d14 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:10 +0000 Subject: [PATCH 36/49] Phone hit: call controls get their hit-area variants, with a test pinning them Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/call-status/CallControl.tsx | 5 +++++ src/app/features/call/CallControls.tsx | 2 ++ src/app/features/call/Controls.tsx | 6 ++++++ src/app/phoneHit.call.test.ts | 15 +++++++++++++++ 4 files changed, 28 insertions(+) create mode 100644 src/app/phoneHit.call.test.ts diff --git a/src/app/features/call-status/CallControl.tsx b/src/app/features/call-status/CallControl.tsx index 3cf38b1f..d63f8709 100644 --- a/src/app/features/call-status/CallControl.tsx +++ b/src/app/features/call-status/CallControl.tsx @@ -5,6 +5,7 @@ import { StatusDivider } from './components'; import { CallEmbed, useCallControlState } from '../../plugins/call'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { callEmbedAtom } from '../../state/callEmbed'; +import { PhoneHitSpread26 } from '../../styles/PhoneHit.css'; type MicrophoneButtonProps = { enabled: boolean; @@ -26,6 +27,7 @@ function MicrophoneButton({ enabled, onToggle, disabled }: MicrophoneButtonProps > {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( {(anchorRef) => ( { + it.each([ + ['features/call/Controls.tsx', Array(5).fill('PhoneHitSpread40')], + ['features/call/CallControls.tsx', ['PhoneHitSpread40']], + ['features/call-status/CallControl.tsx', Array(4).fill('PhoneHitSpread26')], + ])('%s', (file, want) => { + expectPhoneHitClasses(file as string, want as string[]); + expectSizeTie(file as string); + }); +}); From b8bef8c1751bf744190a1b788f92c980c166b45e Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:25 +0000 Subject: [PATCH 37/49] Phone hit: settings A icon buttons get PhoneHit, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/settings/Settings.tsx | 3 +- src/app/features/settings/about/About.tsx | 3 +- src/app/features/settings/account/Account.tsx | 3 +- .../settings/developer-tools/DevelopTools.tsx | 3 +- src/app/features/settings/devices/Devices.tsx | 3 +- .../emojis-stickers/EmojisStickers.tsx | 3 +- .../settings/emojis-stickers/GlobalPacks.tsx | 3 ++ src/app/features/settings/general/General.tsx | 5 +++- .../notifications/KeywordMessages.tsx | 11 ++++++- .../settings/notifications/Notifications.tsx | 3 +- src/app/phoneHit.settingsA.test.ts | 29 +++++++++++++++++++ 11 files changed, 60 insertions(+), 9 deletions(-) create mode 100644 src/app/phoneHit.settingsA.test.ts diff --git a/src/app/features/settings/Settings.tsx b/src/app/features/settings/Settings.tsx index 5e1a20f4..6c91bc9d 100644 --- a/src/app/features/settings/Settings.tsx +++ b/src/app/features/settings/Settings.tsx @@ -33,6 +33,7 @@ import { About } from './about'; import { UseStateProvider } from '../../components/UseStateProvider'; import { stopPropagation } from '../../utils/keyboard'; import { LogoutDialog } from '../../components/LogoutDialog'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export enum SettingsPages { GeneralPage, @@ -141,7 +142,7 @@ export function Settings({ initialPage, requestClose }: SettingsProps) { {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/settings/about/About.tsx b/src/app/features/settings/about/About.tsx index 9ec74305..19407bdf 100644 --- a/src/app/features/settings/about/About.tsx +++ b/src/app/features/settings/about/About.tsx @@ -8,6 +8,7 @@ import { LogoMark } from '../../../components/logo/logoMark.css'; import RatatoskrSVG from '../../../../../public/res/svg/ratatoskr.svg'; import { clearCacheAndReload } from '../../../../client/initMatrix'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type AboutProps = { requestClose: () => void; @@ -25,7 +26,7 @@ export function About({ requestClose }: AboutProps) { - + diff --git a/src/app/features/settings/account/Account.tsx b/src/app/features/settings/account/Account.tsx index c4b56e47..77ee8671 100644 --- a/src/app/features/settings/account/Account.tsx +++ b/src/app/features/settings/account/Account.tsx @@ -5,6 +5,7 @@ import { MatrixId } from './MatrixId'; import { Profile } from './Profile'; import { ContactInformation } from './ContactInfo'; import { IgnoredUserList } from './IgnoredUserList'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type AccountProps = { requestClose: () => void; @@ -20,7 +21,7 @@ export function Account({ requestClose }: AccountProps) { - + diff --git a/src/app/features/settings/developer-tools/DevelopTools.tsx b/src/app/features/settings/developer-tools/DevelopTools.tsx index a3f04567..7203fdc5 100644 --- a/src/app/features/settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/settings/developer-tools/DevelopTools.tsx @@ -13,6 +13,7 @@ import { } from '../../../components/AccountDataEditor'; import { copyToClipboard } from '../../../utils/dom'; import { AccountData } from './AccountData'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type DeveloperToolsProps = { requestClose: () => void; @@ -51,7 +52,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { - + diff --git a/src/app/features/settings/devices/Devices.tsx b/src/app/features/settings/devices/Devices.tsx index c957ab31..9e5821ba 100644 --- a/src/app/features/settings/devices/Devices.tsx +++ b/src/app/features/settings/devices/Devices.tsx @@ -26,6 +26,7 @@ import { } from '../../../hooks/useSecretStorage'; import { useCrossSigningActive } from '../../../hooks/useCrossSigning'; import { BackupRestoreTile } from '../../../components/BackupRestore'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function DevicesPlaceholder() { return ( @@ -74,7 +75,7 @@ export function Devices({ requestClose }: DevicesProps) { - + diff --git a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx index 93715120..2c2193a9 100644 --- a/src/app/features/settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/settings/emojis-stickers/EmojisStickers.tsx @@ -5,6 +5,7 @@ import { GlobalPacks } from './GlobalPacks'; import { UserPack } from './UserPack'; import { ImagePack } from '../../../plugins/custom-emoji'; import { ImagePackView } from '../../../components/image-pack-view'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type EmojisStickersProps = { requestClose: () => void; @@ -30,7 +31,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + diff --git a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx index a9288728..38647b13 100644 --- a/src/app/features/settings/emojis-stickers/GlobalPacks.tsx +++ b/src/app/features/settings/emojis-stickers/GlobalPacks.tsx @@ -43,6 +43,7 @@ import { allRoomsAtom } from '../../../state/room-list/roomList'; import { AccountDataEvent } from '../../../../types/matrix/accountData'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { stopPropagation } from '../../../utils/keyboard'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function GlobalPackSelector({ packs, @@ -368,6 +369,7 @@ export function GlobalPacks({ onViewPack }: GlobalPacksProps) { {removed ? ( ) : ( ; @@ -497,6 +498,7 @@ function DateHint({ hasChanges, handleReset }: DateHintProps) { > {hasChanges ? ( ) : ( - + diff --git a/src/app/features/settings/notifications/KeywordMessages.tsx b/src/app/features/settings/notifications/KeywordMessages.tsx index 7f84d607..b77cae21 100644 --- a/src/app/features/settings/notifications/KeywordMessages.tsx +++ b/src/app/features/settings/notifications/KeywordMessages.tsx @@ -15,6 +15,7 @@ import { } from '../../../hooks/useNotificationMode'; import { NotificationModeSwitcher } from './NotificationModeSwitcher'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const NOTIFY_MODE_OPS: NotificationModeOptions = { highlight: true, @@ -75,6 +76,7 @@ function KeywordInput() { keyword && !addingKeyword && ( + {removing ? : } ); diff --git a/src/app/features/settings/notifications/Notifications.tsx b/src/app/features/settings/notifications/Notifications.tsx index 095a9bba..61af1a09 100644 --- a/src/app/features/settings/notifications/Notifications.tsx +++ b/src/app/features/settings/notifications/Notifications.tsx @@ -8,6 +8,7 @@ import { KeywordMessagesNotifications } from './KeywordMessages'; import { SequenceCard } from '../../../components/sequence-card'; import { SequenceCardStyle } from '../styles.css'; import { SettingTile } from '../../../components/setting-tile'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type NotificationsProps = { requestClose: () => void; @@ -23,7 +24,7 @@ export function Notifications({ requestClose }: NotificationsProps) { - + diff --git a/src/app/phoneHit.settingsA.test.ts b/src/app/phoneHit.settingsA.test.ts new file mode 100644 index 00000000..37f1f3c2 --- /dev/null +++ b/src/app/phoneHit.settingsA.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in settings A', () => { + // The class each IconButton must carry, in source order. Every one is alone in its row (a header close next to + // non-tappable title text, an input slot, or a list tile beside a non-tappable Avatar), so the plain PhoneHit. + const expected: Record = { + 'features/settings/developer-tools/DevelopTools.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/notifications/Notifications.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/notifications/KeywordMessages.tsx': ['PhoneHit', 'PhoneHit'], // input reset, remove: 26 px, alone + 'features/settings/emojis-stickers/EmojisStickers.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/emojis-stickers/GlobalPacks.tsx': ['PhoneHit', 'PhoneHit'], // undo / remove, a ternary pair, 26 px + 'features/settings/Settings.tsx': ['PhoneHit'], // mobile-only header close, 40 px + 'features/settings/about/About.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/general/General.tsx': ['PhoneHit', 'PhoneHit', 'PhoneHit'], // reset / info (ternary), header close + 'features/settings/account/Account.tsx': ['PhoneHit'], // header close, 40 px + 'features/settings/devices/Devices.tsx': ['PhoneHit'], // header close, 40 px + }; + + it.each(Object.entries(expected))('%s carries the right hit-area class', (file, want) => { + expectPhoneHitClasses(file, want); + }); + + // Plain PhoneHit has no size to tie, so nothing is allow-listed; the check still fails an allow-list entry + // or a sized variant that disagrees with the drawn size if one is added later. + it.each(Object.keys(expected))('%s ties a sized variant to the drawn size', (file) => { + expectSizeTie(file); + }); +}); From 0b40f4c66fbb45cee915d432479edbca3ae4b2bf Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:15:44 +0000 Subject: [PATCH 38/49] Phone hit: PhoneHit on the ten dialog and prompt close buttons, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- .../components/event-readers/EventReaders.tsx | 3 +- .../invite-user-prompt/InviteUserPrompt.tsx | 3 +- .../join-address-prompt/JoinAddressPrompt.tsx | 3 +- .../leave-room-prompt/LeaveRoomPrompt.tsx | 3 +- .../leave-space-prompt/LeaveSpacePrompt.tsx | 3 +- .../room-topic-viewer/RoomTopicViewer.tsx | 3 +- src/app/components/user-profile/PowerChip.tsx | 5 ++-- src/app/features/add-existing/AddExisting.tsx | 3 +- .../features/create-room/CreateRoomModal.tsx | 3 +- .../create-space/CreateSpaceModal.tsx | 3 +- src/app/phoneHit.dialogs.test.ts | 28 +++++++++++++++++++ 11 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 src/app/phoneHit.dialogs.test.ts diff --git a/src/app/components/event-readers/EventReaders.tsx b/src/app/components/event-readers/EventReaders.tsx index c7900237..72b188ce 100644 --- a/src/app/components/event-readers/EventReaders.tsx +++ b/src/app/components/event-readers/EventReaders.tsx @@ -24,6 +24,7 @@ import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; import { useOpenUserRoomProfile } from '../../state/hooks/userRoomProfile'; import { useSpaceOptionally } from '../../hooks/useSpace'; import { getMouseEventCords } from '../../utils/dom'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export type EventReadersProps = { room: Room; @@ -52,7 +53,7 @@ export const EventReaders = as<'div', EventReadersProps>( Seen by - + diff --git a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx index ddac0576..a503fe13 100644 --- a/src/app/components/invite-user-prompt/InviteUserPrompt.tsx +++ b/src/app/components/invite-user-prompt/InviteUserPrompt.tsx @@ -42,6 +42,7 @@ import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { BreakWord } from '../../styles/Text.css'; import { useAlive } from '../../hooks/useAlive'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -173,7 +174,7 @@ export function InviteUserPrompt({ room, requestClose }: InviteUserProps) { - + diff --git a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx index 50a89418..ffd5241c 100644 --- a/src/app/components/join-address-prompt/JoinAddressPrompt.tsx +++ b/src/app/components/join-address-prompt/JoinAddressPrompt.tsx @@ -20,6 +20,7 @@ import { stopPropagation } from '../../utils/keyboard'; import { isRoomAlias, isRoomId } from '../../utils/matrix'; import { parseMatrixToRoom, parseMatrixToRoomEvent, testMatrixTo } from '../../plugins/matrix-to'; import { tryDecodeURIComponent } from '../../utils/dom'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type JoinAddressProps = { onOpen: (roomIdOrAlias: string, via?: string[], eventId?: string) => void; @@ -82,7 +83,7 @@ export function JoinAddressPrompt({ onOpen, onCancel }: JoinAddressProps) { Join with Address - + diff --git a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx index 217491e6..832dd59d 100644 --- a/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx +++ b/src/app/components/leave-room-prompt/LeaveRoomPrompt.tsx @@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type LeaveRoomPromptProps = { roomId: string; @@ -68,7 +69,7 @@ export function LeaveRoomPrompt({ roomId, onDone, onCancel }: LeaveRoomPromptPro Leave Room - + diff --git a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx index 8709b942..6c75ef99 100644 --- a/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx +++ b/src/app/components/leave-space-prompt/LeaveSpacePrompt.tsx @@ -20,6 +20,7 @@ import { MatrixError } from 'matrix-js-sdk'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../hooks/useAsyncCallback'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type LeaveSpacePromptProps = { roomId: string; @@ -68,7 +69,7 @@ export function LeaveSpacePrompt({ roomId, onDone, onCancel }: LeaveSpacePromptP Leave Space - + diff --git a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx index 52128772..0d091e7e 100644 --- a/src/app/components/room-topic-viewer/RoomTopicViewer.tsx +++ b/src/app/components/room-topic-viewer/RoomTopicViewer.tsx @@ -4,6 +4,7 @@ import classNames from 'classnames'; import Linkify from 'linkify-react'; import * as css from './style.css'; import { LINKIFY_OPTS, scaleSystemEmoji } from '../../plugins/react-custom-html-parser'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export const RoomTopicViewer = as< 'div', @@ -26,7 +27,7 @@ export const RoomTopicViewer = as< {name} - + diff --git a/src/app/components/user-profile/PowerChip.tsx b/src/app/components/user-profile/PowerChip.tsx index a6758300..d3e9cf28 100644 --- a/src/app/components/user-profile/PowerChip.tsx +++ b/src/app/components/user-profile/PowerChip.tsx @@ -43,6 +43,7 @@ import { getPowerTagIconSrc, useGetMemberPowerTag } from '../../hooks/useMemberP import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { useMemberPowerCompare } from '../../hooks/useMemberPowerCompare'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type SelfDemoteAlertProps = { power: number; @@ -70,7 +71,7 @@ function SelfDemoteAlert({ power, onCancel, onChange }: SelfDemoteAlertProps) { Self Demotion - + @@ -120,7 +121,7 @@ function SharedPowerAlert({ power, onCancel, onChange }: SharedPowerAlertProps) Shared Power - + diff --git a/src/app/features/add-existing/AddExisting.tsx b/src/app/features/add-existing/AddExisting.tsx index d100096b..a57a6b10 100644 --- a/src/app/features/add-existing/AddExisting.tsx +++ b/src/app/features/add-existing/AddExisting.tsx @@ -54,6 +54,7 @@ import { StateEvent } from '../../../types/matrix/room'; import { getViaServers } from '../../plugins/via-servers'; import { rateLimitedActions } from '../../utils/matrix'; import { useAlive } from '../../hooks/useAlive'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const SEARCH_OPTS: UseAsyncSearchOptions = { limit: 500, @@ -201,7 +202,7 @@ export function AddExistingModal({ parentId, space, requestClose }: AddExistingM Add Existing - + diff --git a/src/app/features/create-room/CreateRoomModal.tsx b/src/app/features/create-room/CreateRoomModal.tsx index d5d8c92d..9332f35c 100644 --- a/src/app/features/create-room/CreateRoomModal.tsx +++ b/src/app/features/create-room/CreateRoomModal.tsx @@ -24,6 +24,7 @@ import { import { CreateRoomModalState } from '../../state/createRoomModal'; import { stopPropagation } from '../../utils/keyboard'; import { CreateRoomType } from '../../components/create-room/types'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type CreateRoomModalProps = { state: CreateRoomModalState; @@ -63,7 +64,7 @@ function CreateRoomModal({ state }: CreateRoomModalProps) { - + diff --git a/src/app/features/create-space/CreateSpaceModal.tsx b/src/app/features/create-space/CreateSpaceModal.tsx index c1bc6898..faa53b25 100644 --- a/src/app/features/create-space/CreateSpaceModal.tsx +++ b/src/app/features/create-space/CreateSpaceModal.tsx @@ -23,6 +23,7 @@ import { } from '../../state/hooks/createSpaceModal'; import { CreateSpaceModalState } from '../../state/createSpaceModal'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type CreateSpaceModalProps = { state: CreateSpaceModalState; @@ -61,7 +62,7 @@ function CreateSpaceModal({ state }: CreateSpaceModalProps) { New Space - + diff --git a/src/app/phoneHit.dialogs.test.ts b/src/app/phoneHit.dialogs.test.ts new file mode 100644 index 00000000..8b3416a5 --- /dev/null +++ b/src/app/phoneHit.dialogs.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Dialog and prompt close buttons: each is a 32 px button (24 px Icon + 8 px padding) alone at the end of a +// header whose title is not tappable, so the plain PhoneHit; no neighbour to spread from, no clipping container. +describe('icon buttons in dialogs and prompts', () => { + const expected: Record = { + 'features/create-space/CreateSpaceModal.tsx': ['PhoneHit'], + 'features/create-room/CreateRoomModal.tsx': ['PhoneHit'], + 'features/add-existing/AddExisting.tsx': ['PhoneHit'], + 'components/room-topic-viewer/RoomTopicViewer.tsx': ['PhoneHit'], + 'components/leave-room-prompt/LeaveRoomPrompt.tsx': ['PhoneHit'], + 'components/leave-space-prompt/LeaveSpacePrompt.tsx': ['PhoneHit'], + 'components/event-readers/EventReaders.tsx': ['PhoneHit'], + 'components/invite-user-prompt/InviteUserPrompt.tsx': ['PhoneHit'], + 'components/join-address-prompt/JoinAddressPrompt.tsx': ['PhoneHit'], // join cancel + 'components/user-profile/PowerChip.tsx': ['PhoneHit', 'PhoneHit'], // self demotion, shared power + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); From cd90a044ed8524e4e38e59cfa2418fdf250a500c Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:02 +0000 Subject: [PATCH 39/49] Phone hit: page back buttons and Lobby/Notifications lone buttons Co-Authored-By: Claude Sonnet 5.5 --- .../JoinBeforeNavigate.tsx | 3 +- src/app/features/lobby/LobbyHeader.tsx | 4 ++- src/app/pages/client/direct/DirectCreate.tsx | 3 +- src/app/pages/client/explore/Featured.tsx | 3 +- src/app/pages/client/explore/Server.tsx | 3 +- src/app/pages/client/home/CreateRoom.tsx | 3 +- src/app/pages/client/home/Search.tsx | 3 +- src/app/pages/client/inbox/Invites.tsx | 3 +- src/app/pages/client/inbox/Notifications.tsx | 4 ++- src/app/pages/client/space/Search.tsx | 3 +- src/app/phoneHit.pages.test.ts | 29 +++++++++++++++++++ src/app/phoneHitTestKit.ts | 4 +-- 12 files changed, 53 insertions(+), 12 deletions(-) create mode 100644 src/app/phoneHit.pages.test.ts diff --git a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx index 028cd560..43576cff 100644 --- a/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx +++ b/src/app/features/join-before-navigate/JoinBeforeNavigate.tsx @@ -10,6 +10,7 @@ import { useMatrixClient } from '../../hooks/useMatrixClient'; import { allRoomsAtom } from '../../state/room-list/roomList'; import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize'; import { BackRouteHandler } from '../../components/BackRouteHandler'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type JoinBeforeNavigateProps = { roomIdOrAlias: string; eventId?: string; viaServers?: string[] }; export function JoinBeforeNavigate({ @@ -38,7 +39,7 @@ export function JoinBeforeNavigate({ {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/features/lobby/LobbyHeader.tsx b/src/app/features/lobby/LobbyHeader.tsx index ed526c68..ce248fe5 100644 --- a/src/app/features/lobby/LobbyHeader.tsx +++ b/src/app/features/lobby/LobbyHeader.tsx @@ -38,6 +38,7 @@ import { useOpenSpaceSettings } from '../../state/hooks/spaceSettings'; import { useRoomCreators } from '../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type LobbyMenuProps = { powerLevels: IPowerLevels; @@ -165,7 +166,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { {(onBack) => ( - + )} @@ -240,6 +241,7 @@ export function LobbyHeader({ showProfile, powerLevels }: LobbyHeaderProps) { > {(triggerRef) => ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Featured.tsx b/src/app/pages/client/explore/Featured.tsx index f056cbb5..c28fad0a 100644 --- a/src/app/pages/client/explore/Featured.tsx +++ b/src/app/pages/client/explore/Featured.tsx @@ -18,6 +18,7 @@ import * as css from './style.css'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function FeaturedRooms() { const { featuredCommunities } = useClientConfig(); @@ -33,7 +34,7 @@ export function FeaturedRooms() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/explore/Server.tsx b/src/app/pages/client/explore/Server.tsx index 48f267cc..036c6488 100644 --- a/src/app/pages/client/explore/Server.tsx +++ b/src/app/pages/client/explore/Server.tsx @@ -45,6 +45,7 @@ import { getMxIdServer } from '../../../utils/matrix'; import { stopPropagation } from '../../../utils/keyboard'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const useServerSearchParams = (searchParams: URLSearchParams): ExploreServerPathSearchParams => useMemo( @@ -499,7 +500,7 @@ export function PublicRooms() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/CreateRoom.tsx b/src/app/pages/client/home/CreateRoom.tsx index fddd75aa..8e277889 100644 --- a/src/app/pages/client/home/CreateRoom.tsx +++ b/src/app/pages/client/home/CreateRoom.tsx @@ -12,6 +12,7 @@ import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; import { CreateRoomForm } from '../../../features/create-room'; import { useRoomNavigate } from '../../../hooks/useRoomNavigate'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeCreateRoom() { const screenSize = useScreenSizeContext(); @@ -25,7 +26,7 @@ export function HomeCreateRoom() { {(onBack) => ( - + )} diff --git a/src/app/pages/client/home/Search.tsx b/src/app/pages/client/home/Search.tsx index d5ddfb77..aa454d2c 100644 --- a/src/app/pages/client/home/Search.tsx +++ b/src/app/pages/client/home/Search.tsx @@ -5,6 +5,7 @@ import { MessageSearch } from '../../../features/message-search'; import { useHomeRooms } from './useHomeRooms'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function HomeSearch() { const scrollRef = useRef(null); @@ -19,7 +20,7 @@ export function HomeSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Invites.tsx b/src/app/pages/client/inbox/Invites.tsx index ca26bac3..4d1580af 100644 --- a/src/app/pages/client/inbox/Invites.tsx +++ b/src/app/pages/client/inbox/Invites.tsx @@ -63,6 +63,7 @@ import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useReportRoomSupported } from '../../../hooks/useReportRoomSupported'; import { useSetting } from '../../../state/hooks/settings'; import { settingsAtom } from '../../../state/settings'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const COMPACT_CARD_WIDTH = 548; @@ -745,7 +746,7 @@ export function Invites() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/pages/client/inbox/Notifications.tsx b/src/app/pages/client/inbox/Notifications.tsx index 9592224f..8fddba44 100644 --- a/src/app/pages/client/inbox/Notifications.tsx +++ b/src/app/pages/client/inbox/Notifications.tsx @@ -97,6 +97,7 @@ import { } from '../../../hooks/useMemberPowerTag'; import { useRoomCreatorsTag } from '../../../hooks/useRoomCreatorsTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type RoomNotificationsGroup = { roomId: string; @@ -642,7 +643,7 @@ export function Notifications() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} @@ -694,6 +695,7 @@ export function Notifications() { onVisibilityChange={handleScrollTopVisibility} > virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/pages/client/space/Search.tsx b/src/app/pages/client/space/Search.tsx index 017262b5..a2849ab8 100644 --- a/src/app/pages/client/space/Search.tsx +++ b/src/app/pages/client/space/Search.tsx @@ -11,6 +11,7 @@ import { roomToParentsAtom } from '../../../state/room/roomToParents'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { ScreenSize, useScreenSizeContext } from '../../../hooks/useScreenSize'; import { BackRouteHandler } from '../../../components/BackRouteHandler'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function SpaceSearch() { const mx = useMatrixClient(); @@ -34,7 +35,7 @@ export function SpaceSearch() { {screenSize === ScreenSize.Mobile && ( {(onBack) => ( - + )} diff --git a/src/app/phoneHit.pages.test.ts b/src/app/phoneHit.pages.test.ts new file mode 100644 index 00000000..97eb77b8 --- /dev/null +++ b/src/app/phoneHit.pages.test.ts @@ -0,0 +1,29 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Page back buttons (and two lone buttons beside them). Every one is alone in its row, 40 px drawn (default +// IconButton and Icon), so plain PhoneHit; the vertical stacks of the page have no variant. +// null = deliberately without: LobbyHeader's Members button renders only when screenSize !== Mobile. +const files: Record = { + 'features/lobby/LobbyHeader.tsx': ['PhoneHit', null, 'PhoneHit'], + 'features/join-before-navigate/JoinBeforeNavigate.tsx': ['PhoneHit'], + 'pages/client/space/Search.tsx': ['PhoneHit'], + 'pages/client/explore/Server.tsx': ['PhoneHit'], + 'pages/client/explore/Featured.tsx': ['PhoneHit'], + 'pages/client/home/CreateRoom.tsx': ['PhoneHit'], + 'pages/client/home/Search.tsx': ['PhoneHit'], + // the second is the 30 px scroll-to-top pill, floating alone at the top centre + 'pages/client/inbox/Notifications.tsx': ['PhoneHit', 'PhoneHit'], + 'pages/client/inbox/Invites.tsx': ['PhoneHit'], + 'pages/client/direct/DirectCreate.tsx': ['PhoneHit'], +}; + +describe.each(Object.entries(files))('phone hit area: %s', (file, want) => { + it('gives each IconButton its variant', () => { + expectPhoneHitClasses(file, want); + }); + // plain PhoneHit has no size to tie; this fails when a sized variant appears whose number is not the drawn px + it('ties any sized variant to the drawn size', () => { + expectSizeTie(file); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index a29dcc22..46963024 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -90,10 +90,10 @@ const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*) /** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ -export const expectPhoneHitClasses = (file: string, want: string[]) => { +export const expectPhoneHitClasses = (file: string, want: (string | null)[]) => { const src = readSource(file); expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); - [...new Set(want)].forEach((name) => + [...new Set(want.filter((w): w is string => w !== null))].forEach((name) => expect(src).toMatch( new RegExp( `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` From e209688de10cce5c91575de6a3410d8bcf7d2901 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:02 +0000 Subject: [PATCH 40/49] Phone hit: common-settings IconButtons get PhoneHit, with a pinning test Co-Authored-By: Claude Sonnet 5.5 --- .../developer-tools/DevelopTools.tsx | 3 ++- .../developer-tools/SendRoomEvent.tsx | 3 ++- .../developer-tools/StateEventEditor.tsx | 3 ++- .../emojis-stickers/EmojisStickers.tsx | 3 ++- .../emojis-stickers/RoomPacks.tsx | 3 +++ .../general/RoomEncryption.tsx | 8 +++++- .../common-settings/general/RoomUpgrade.tsx | 3 ++- .../common-settings/members/Members.tsx | 4 ++- .../permissions/PowersEditor.tsx | 3 ++- src/app/phoneHit.commonSettings.test.ts | 26 +++++++++++++++++++ 10 files changed, 51 insertions(+), 8 deletions(-) create mode 100644 src/app/phoneHit.commonSettings.test.ts diff --git a/src/app/features/common-settings/developer-tools/DevelopTools.tsx b/src/app/features/common-settings/developer-tools/DevelopTools.tsx index 29b6aa51..61e05a62 100644 --- a/src/app/features/common-settings/developer-tools/DevelopTools.tsx +++ b/src/app/features/common-settings/developer-tools/DevelopTools.tsx @@ -30,6 +30,7 @@ import { AccountDataSubmitCallback, } from '../../../components/AccountDataEditor'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type DeveloperToolsProps = { requestClose: () => void; @@ -92,7 +93,7 @@ export function DeveloperTools({ requestClose }: DeveloperToolsProps) { - + diff --git a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx index f25ba7c3..a6e60a7b 100644 --- a/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx +++ b/src/app/features/common-settings/developer-tools/SendRoomEvent.tsx @@ -22,6 +22,7 @@ import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { syntaxErrorPosition } from '../../../utils/dom'; import { Cursor } from '../../../plugins/text-area'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const EDITOR_INTENT_SPACE_COUNT = 2; @@ -118,7 +119,7 @@ export function SendRoomEvent({ type, stateKey, requestClose }: SendRoomEventPro - + diff --git a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx index 0ea96901..0f9f4034 100644 --- a/src/app/features/common-settings/developer-tools/StateEventEditor.tsx +++ b/src/app/features/common-settings/developer-tools/StateEventEditor.tsx @@ -31,6 +31,7 @@ import { usePowerLevels } from '../../../hooks/usePowerLevels'; import { useTextAreaCodeEditor } from '../../../hooks/useTextAreaCodeEditor'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const EDITOR_INTENT_SPACE_COUNT = 2; @@ -275,7 +276,7 @@ export function StateEventEditor({ type, stateKey, requestClose }: StateEventEdi - + diff --git a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx index ad8ffae6..f7e901d5 100644 --- a/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx +++ b/src/app/features/common-settings/emojis-stickers/EmojisStickers.tsx @@ -4,6 +4,7 @@ import { Page, PageContent, PageHeader } from '../../../components/page'; import { ImagePack } from '../../../plugins/custom-emoji'; import { ImagePackView } from '../../../components/image-pack-view'; import { RoomPacks } from './RoomPacks'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type EmojisStickersProps = { requestClose: () => void; @@ -29,7 +30,7 @@ export function EmojisStickers({ requestClose }: EmojisStickersProps) { - + diff --git a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx index fdbe546e..7be8de35 100644 --- a/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx +++ b/src/app/features/common-settings/emojis-stickers/RoomPacks.tsx @@ -40,6 +40,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useAlive } from '../../../hooks/useAlive'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type CreatePackTileProps = { packs: ImagePack[]; @@ -215,6 +216,7 @@ export function RoomPacks({ onViewPack }: RoomPacksProps) { {canEdit && (removed ? ( ) : ( Enable Encryption - setPrompt(false)} radii="300"> + setPrompt(false)} + radii="300" + > diff --git a/src/app/features/common-settings/general/RoomUpgrade.tsx b/src/app/features/common-settings/general/RoomUpgrade.tsx index 45a480aa..2971d565 100644 --- a/src/app/features/common-settings/general/RoomUpgrade.tsx +++ b/src/app/features/common-settings/general/RoomUpgrade.tsx @@ -39,6 +39,7 @@ import { useAlive } from '../../../hooks/useAlive'; import { creatorsSupported } from '../../../utils/matrix'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { BreakWord } from '../../../styles/Text.css'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { const mx = useMatrixClient(); @@ -105,7 +106,7 @@ function RoomUpgradeDialog({ requestClose }: { requestClose: () => void }) { {room.isSpaceRoom() ? 'Space Upgrade' : 'Room Upgrade'} - + diff --git a/src/app/features/common-settings/members/Members.tsx b/src/app/features/common-settings/members/Members.tsx index 15cd1d08..a1dddb54 100644 --- a/src/app/features/common-settings/members/Members.tsx +++ b/src/app/features/common-settings/members/Members.tsx @@ -56,6 +56,7 @@ import { useSpaceOptionally } from '../../../hooks/useSpace'; import { useFlattenPowerTagMembers, useGetMemberPowerTag } from '../../../hooks/useMemberPowerTag'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { getMouseEventCords } from '../../../utils/dom'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; const SEARCH_OPTIONS: UseAsyncSearchOptions = { limit: 1000, @@ -161,7 +162,7 @@ export function Members({ requestClose }: MembersProps) { - + @@ -277,6 +278,7 @@ export function Members({ requestClose }: MembersProps) { anchorRef={scrollTopAnchorRef} > virtualizer.scrollToOffset(0)} variant="Surface" radii="Pill" diff --git a/src/app/features/common-settings/permissions/PowersEditor.tsx b/src/app/features/common-settings/permissions/PowersEditor.tsx index 97305946..0684a48a 100644 --- a/src/app/features/common-settings/permissions/PowersEditor.tsx +++ b/src/app/features/common-settings/permissions/PowersEditor.tsx @@ -49,6 +49,7 @@ import { useAlive } from '../../../hooks/useAlive'; import { BetaNoticeBadge } from '../../../components/BetaNoticeBadge'; import { getPowerTagIconSrc } from '../../../hooks/useMemberPowerTag'; import { creatorsSupported } from '../../../utils/matrix'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type EditPowerProps = { maxPower: number; @@ -369,7 +370,7 @@ export function PowersEditor({ powerLevels, requestClose }: PowersEditorProps) { - + diff --git a/src/app/phoneHit.commonSettings.test.ts b/src/app/phoneHit.commonSettings.test.ts new file mode 100644 index 00000000..fdb2c4e0 --- /dev/null +++ b/src/app/phoneHit.commonSettings.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Every IconButton under features/common-settings, in source order. Header close buttons are alone at the +// header end (the title beside them is text), the dialog close buttons are alone in their Header, the +// scroll-to-top button floats alone, and the RoomPacks pair is a ternary (only one renders): all PhoneHit. +const files: [string, string[]][] = [ + ['features/common-settings/members/Members.tsx', ['PhoneHit', 'PhoneHit']], + ['features/common-settings/developer-tools/SendRoomEvent.tsx', ['PhoneHit']], + ['features/common-settings/developer-tools/StateEventEditor.tsx', ['PhoneHit']], + ['features/common-settings/developer-tools/DevelopTools.tsx', ['PhoneHit']], + ['features/common-settings/permissions/PowersEditor.tsx', ['PhoneHit']], + ['features/common-settings/emojis-stickers/EmojisStickers.tsx', ['PhoneHit']], + ['features/common-settings/emojis-stickers/RoomPacks.tsx', ['PhoneHit', 'PhoneHit']], + ['features/common-settings/general/RoomEncryption.tsx', ['PhoneHit']], + ['features/common-settings/general/RoomUpgrade.tsx', ['PhoneHit']], +]; + +describe.each(files)('phone hit area: %s', (file, want) => { + it('carries the variant its drawn size and neighbours call for', () => { + expectPhoneHitClasses(file, want); + }); + it('names a size that matches what it draws', () => { + expectSizeTie(file); + }); +}); From 200cf8fdb86e9cf26d8dd2f4be08738dfa6bdd21 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:07 +0000 Subject: [PATCH 41/49] Phone hit: viewer headers and UIA stages Back/close buttons beside non-tappable titles take PhoneHit; the zoom out/in buttons beside the zoom Chip take PhoneHitSpread24. Test pins each variant and ties the sized ones to the drawn size. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/Pdf-viewer/PdfViewer.tsx | 5 +++- src/app/components/UIAFlowOverlay.tsx | 2 ++ .../components/image-editor/ImageEditor.tsx | 3 ++- .../components/image-viewer/ImageViewer.tsx | 5 +++- src/app/components/text-viewer/TextViewer.tsx | 3 ++- .../components/uia-stages/PasswordStage.tsx | 3 ++- src/app/components/uia-stages/SSOStage.tsx | 3 ++- src/app/phoneHit.viewers.test.ts | 27 +++++++++++++++++++ 8 files changed, 45 insertions(+), 6 deletions(-) create mode 100644 src/app/phoneHit.viewers.test.ts diff --git a/src/app/components/Pdf-viewer/PdfViewer.tsx b/src/app/components/Pdf-viewer/PdfViewer.tsx index 9c7fd980..d0e5f715 100644 --- a/src/app/components/Pdf-viewer/PdfViewer.tsx +++ b/src/app/components/Pdf-viewer/PdfViewer.tsx @@ -27,6 +27,7 @@ import { AsyncStatus } from '../../hooks/useAsyncCallback'; import { useZoom } from '../../hooks/useZoom'; import { createPage, usePdfDocumentLoader, usePdfJSLoader } from '../../plugins/pdfjs-dist'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css'; export type PdfViewerProps = { name: string; @@ -108,7 +109,7 @@ export const PdfViewer = as<'div', PdfViewerProps>(
- + @@ -117,6 +118,7 @@ export const PdfViewer = as<'div', PdfViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/UIAFlowOverlay.tsx b/src/app/components/UIAFlowOverlay.tsx index a2bdd1b8..49e1d821 100644 --- a/src/app/components/UIAFlowOverlay.tsx +++ b/src/app/components/UIAFlowOverlay.tsx @@ -13,6 +13,7 @@ import { IconButton, } from 'folds'; import FocusTrap from 'focus-trap-react'; +import { PhoneHit } from '../styles/PhoneHit.css'; export type UIAFlowOverlayProps = { currentStep: number; @@ -53,6 +54,7 @@ export function UIAFlowOverlay({ > {(anchorRef) => ( ( >
- + diff --git a/src/app/components/image-viewer/ImageViewer.tsx b/src/app/components/image-viewer/ImageViewer.tsx index 4956a7b6..4d2d7694 100644 --- a/src/app/components/image-viewer/ImageViewer.tsx +++ b/src/app/components/image-viewer/ImageViewer.tsx @@ -7,6 +7,7 @@ import * as css from './ImageViewer.css'; import { useZoom } from '../../hooks/useZoom'; import { usePan } from '../../hooks/usePan'; import { downloadMedia } from '../../utils/matrix'; +import { PhoneHit, PhoneHitSpread24 } from '../../styles/PhoneHit.css'; export type ImageViewerProps = { alt: string; @@ -33,7 +34,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( >
- + @@ -42,6 +43,7 @@ export const ImageViewer = as<'div', ImageViewerProps>( ( {Math.round(zoom * 100)}% 1 ? 'Success' : 'SurfaceVariant'} outlined={zoom > 1} size="300" diff --git a/src/app/components/text-viewer/TextViewer.tsx b/src/app/components/text-viewer/TextViewer.tsx index ec4ed0a5..e9e150b5 100644 --- a/src/app/components/text-viewer/TextViewer.tsx +++ b/src/app/components/text-viewer/TextViewer.tsx @@ -5,6 +5,7 @@ import { Box, Chip, Header, Icon, IconButton, Icons, Scroll, Text, as } from 'fo import { ErrorBoundary } from 'react-error-boundary'; import * as css from './TextViewer.css'; import { copyToClipboard } from '../../utils/dom'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const ReactPrism = lazy(() => import('../../plugins/react-prism/ReactPrism')); @@ -53,7 +54,7 @@ export const TextViewer = as<'div', TextViewerProps>( >
- + diff --git a/src/app/components/uia-stages/PasswordStage.tsx b/src/app/components/uia-stages/PasswordStage.tsx index 0164cae6..a7ca2d06 100644 --- a/src/app/components/uia-stages/PasswordStage.tsx +++ b/src/app/components/uia-stages/PasswordStage.tsx @@ -4,6 +4,7 @@ import { AuthType } from 'matrix-js-sdk'; import { StageComponentProps } from './types'; import { ErrorCode } from '../../cs-errorcode'; import { PasswordInput } from '../password-input'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export function PasswordStage({ stageData, @@ -45,7 +46,7 @@ export function PasswordStage({ Account Password - +
diff --git a/src/app/components/uia-stages/SSOStage.tsx b/src/app/components/uia-stages/SSOStage.tsx index e3d6126a..20decb6c 100644 --- a/src/app/components/uia-stages/SSOStage.tsx +++ b/src/app/components/uia-stages/SSOStage.tsx @@ -1,6 +1,7 @@ import { Box, Button, color, config, Dialog, Header, Icon, IconButton, Icons, Text } from 'folds'; import React, { useCallback, useEffect, useState } from 'react'; import { StageComponentProps } from './types'; +import { PhoneHit } from '../../styles/PhoneHit.css'; export function SSOStage({ ssoRedirectURL, @@ -56,7 +57,7 @@ export function SSOStage({ SSO Login - +
diff --git a/src/app/phoneHit.viewers.test.ts b/src/app/phoneHit.viewers.test.ts new file mode 100644 index 00000000..3e579f4c --- /dev/null +++ b/src/app/phoneHit.viewers.test.ts @@ -0,0 +1,27 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +// Viewer headers and the UIA stages. The back/close button sits beside a title that is not tappable, so it is a +// plain PhoneHit; the zoom out/in buttons sit either side of a tappable zoom Chip, so they spread at their drawn +// 24 px (IconButton size 300 around a 16 px Icon). +// Not covered: the two scroll arrows of components/url-preview/UrlPreviewCard.tsx. They are position: absolute and +// PhoneHit sets position: relative on phones, which would pull them into the flow; they need a variant that keeps +// the position, which PhoneHit.css.ts does not have yet. +const files: [string, string[]][] = [ + ['components/uia-stages/PasswordStage.tsx', ['PhoneHit']], + ['components/uia-stages/SSOStage.tsx', ['PhoneHit']], + ['components/UIAFlowOverlay.tsx', ['PhoneHit']], + ['components/text-viewer/TextViewer.tsx', ['PhoneHit']], + ['components/image-editor/ImageEditor.tsx', ['PhoneHit']], + ['components/image-viewer/ImageViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], + ['components/Pdf-viewer/PdfViewer.tsx', ['PhoneHit', 'PhoneHitSpread24', 'PhoneHitSpread24']], +]; + +describe.each(files)('phone hit area: %s', (file, want) => { + it('gives each icon button the variant of its surroundings', () => { + expectPhoneHitClasses(file, want); + }); + it('ties each sized variant to the size the button draws', () => { + expectSizeTie(file); + }); +}); From 32dc46c21f465e36928f5ba0f717d4ed4dfe16e9 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:11 +0000 Subject: [PATCH 42/49] Phone hit: PhoneHit on the settings B, auth and verification icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/BackupRestore.tsx | 2 ++ src/app/components/DeviceVerification.tsx | 3 ++- .../components/DeviceVerificationSetup.tsx | 5 +++-- .../settings/account/IgnoredUserList.tsx | 2 ++ src/app/features/settings/account/Profile.tsx | 9 +++++++- .../features/settings/devices/DeviceTile.tsx | 2 ++ .../settings/devices/Verification.tsx | 2 ++ src/app/pages/auth/ServerPicker.tsx | 2 ++ src/app/phoneHit.settingsB.test.ts | 21 +++++++++++++++++++ 9 files changed, 44 insertions(+), 4 deletions(-) create mode 100644 src/app/phoneHit.settingsB.test.ts diff --git a/src/app/components/BackupRestore.tsx b/src/app/components/BackupRestore.tsx index 068f437b..57a18674 100644 --- a/src/app/components/BackupRestore.tsx +++ b/src/app/components/BackupRestore.tsx @@ -30,6 +30,7 @@ import { } from '../hooks/useKeyBackup'; import { stopPropagation } from '../utils/keyboard'; import { useRestoreBackupOnVerification } from '../hooks/useRestoreBackupOnVerification'; +import { PhoneHit } from '../styles/PhoneHit.css'; type BackupStatusProps = { enabled: boolean; @@ -177,6 +178,7 @@ export function BackupRestoreTile({ crypto }: BackupRestoreTileProps) { )} Device Verification
- +
diff --git a/src/app/components/DeviceVerificationSetup.tsx b/src/app/components/DeviceVerificationSetup.tsx index 433fa6a1..68f42221 100644 --- a/src/app/components/DeviceVerificationSetup.tsx +++ b/src/app/components/DeviceVerificationSetup.tsx @@ -25,6 +25,7 @@ import { ActionUIA, ActionUIAFlowsLoader } from './ActionUIA'; import { useMatrixClient } from '../hooks/useMatrixClient'; import { useAlive } from '../hooks/useAlive'; import { UseStateProvider } from './UseStateProvider'; +import { PhoneHit } from '../styles/PhoneHit.css'; type UIACallback = ( authDict: AuthDict | null @@ -301,7 +302,7 @@ export const DeviceVerificationSetup = forwardRef Setup Device Verification
- +
@@ -336,7 +337,7 @@ export const DeviceVerificationReset = forwardRef Reset Device Verification
- + diff --git a/src/app/features/settings/account/IgnoredUserList.tsx b/src/app/features/settings/account/IgnoredUserList.tsx index 98db9459..791a407b 100644 --- a/src/app/features/settings/account/IgnoredUserList.tsx +++ b/src/app/features/settings/account/IgnoredUserList.tsx @@ -8,6 +8,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { isUserId } from '../../../utils/matrix'; import { useIgnoredUsers } from '../../../hooks/useIgnoredUsers'; import { useAlive } from '../../../hooks/useAlive'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; function IgnoreUserInput({ userList }: { userList: string[] }) { const mx = useMatrixClient(); @@ -67,6 +68,7 @@ function IgnoreUserInput({ userList }: { userList: string[] }) { userId && !ignoring && ( Remove Avatar - setAlertRemove(false)} radii="300"> + setAlertRemove(false)} + radii="300" + > @@ -273,6 +279,7 @@ function ProfileDisplayName({ profile, userId }: ProfileProps) { hasChanges && !changingDisplayName && ( { + it('gives each icon button its variant', () => expectPhoneHitClasses(file, want)); + it('ties each sized variant to the size it draws', () => expectSizeTie(file)); +}); From 84ee800e3f4fddfb31e4491c9616b7f92d777b74 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:31 +0000 Subject: [PATCH 43/49] Phone hit: room/space settings, lobby, message search, explore icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/features/lobby/HierarchyItemMenu.tsx | 2 ++ src/app/features/lobby/Lobby.tsx | 2 ++ .../features/message-search/MessageSearch.tsx | 2 ++ .../features/room-settings/RoomSettings.tsx | 3 +- .../room-settings/general/General.tsx | 3 +- .../room-settings/permissions/Permissions.tsx | 3 +- .../features/space-settings/SpaceSettings.tsx | 3 +- .../space-settings/general/General.tsx | 3 +- .../permissions/Permissions.tsx | 3 +- src/app/pages/client/explore/Explore.tsx | 8 +++++- src/app/phoneHit.roomSpace.test.ts | 28 +++++++++++++++++++ 11 files changed, 53 insertions(+), 7 deletions(-) create mode 100644 src/app/phoneHit.roomSpace.test.ts diff --git a/src/app/features/lobby/HierarchyItemMenu.tsx b/src/app/features/lobby/HierarchyItemMenu.tsx index 1802e3b0..cdfa2d8a 100644 --- a/src/app/features/lobby/HierarchyItemMenu.tsx +++ b/src/app/features/lobby/HierarchyItemMenu.tsx @@ -30,6 +30,7 @@ import { IPowerLevels } from '../../hooks/usePowerLevels'; import { getRoomCreatorsForRoomId } from '../../hooks/useRoomCreators'; import { getRoomPermissionsAPI } from '../../hooks/useRoomPermissions'; import { InviteUserPrompt } from '../../components/invite-user-prompt'; +import { PhoneHitSpread24 } from '../../styles/PhoneHit.css'; type HierarchyItemWithParent = HierarchyItem & { parentId: string; @@ -231,6 +232,7 @@ export function HierarchyItemMenu({ return ( virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/message-search/MessageSearch.tsx b/src/app/features/message-search/MessageSearch.tsx index 26085b5f..a1759ef9 100644 --- a/src/app/features/message-search/MessageSearch.tsx +++ b/src/app/features/message-search/MessageSearch.tsx @@ -23,6 +23,7 @@ import { SearchResultGroup } from './SearchResultGroup'; import { SearchInput } from './SearchInput'; import { SearchFilters } from './SearchFilters'; import { VirtualTile } from '../../components/virtualizer'; +import { PhoneHit } from '../../styles/PhoneHit.css'; const useSearchPathSearchParams = (searchParams: URLSearchParams): _SearchPathSearchParams => useMemo( @@ -193,6 +194,7 @@ export function MessageSearch({ virtualizer.scrollToOffset(0)} variant="SurfaceVariant" radii="Pill" diff --git a/src/app/features/room-settings/RoomSettings.tsx b/src/app/features/room-settings/RoomSettings.tsx index 8601c365..8f96bd7c 100644 --- a/src/app/features/room-settings/RoomSettings.tsx +++ b/src/app/features/room-settings/RoomSettings.tsx @@ -17,6 +17,7 @@ import { Permissions } from './permissions'; import { RoomSettingsPage } from '../../state/roomSettings'; import { useRoom } from '../../hooks/useRoom'; import { DeveloperTools } from '../common-settings/developer-tools'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type RoomSettingsMenuItem = { page: RoomSettingsPage; @@ -117,7 +118,7 @@ export function RoomSettings({ initialPage, requestClose }: RoomSettingsProps) { {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/room-settings/general/General.tsx b/src/app/features/room-settings/general/General.tsx index 15157304..e29fe2da 100644 --- a/src/app/features/room-settings/general/General.tsx +++ b/src/app/features/room-settings/general/General.tsx @@ -15,6 +15,7 @@ import { } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type GeneralProps = { requestClose: () => void; @@ -35,7 +36,7 @@ export function General({ requestClose }: GeneralProps) { - + diff --git a/src/app/features/room-settings/permissions/Permissions.tsx b/src/app/features/room-settings/permissions/Permissions.tsx index 7572a71b..d513dda9 100644 --- a/src/app/features/room-settings/permissions/Permissions.tsx +++ b/src/app/features/room-settings/permissions/Permissions.tsx @@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PermissionsProps = { requestClose: () => void; @@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) { - + diff --git a/src/app/features/space-settings/SpaceSettings.tsx b/src/app/features/space-settings/SpaceSettings.tsx index b5fefc93..1030972f 100644 --- a/src/app/features/space-settings/SpaceSettings.tsx +++ b/src/app/features/space-settings/SpaceSettings.tsx @@ -17,6 +17,7 @@ import { Members } from '../common-settings/members'; import { DeveloperTools } from '../common-settings/developer-tools'; import { General } from './general'; import { Permissions } from './permissions'; +import { PhoneHit } from '../../styles/PhoneHit.css'; type SpaceSettingsMenuItem = { page: SpaceSettingsPage; @@ -117,7 +118,7 @@ export function SpaceSettings({ initialPage, requestClose }: SpaceSettingsProps) {screenSize === ScreenSize.Mobile && ( - + )} diff --git a/src/app/features/space-settings/general/General.tsx b/src/app/features/space-settings/general/General.tsx index 72040632..e99a4906 100644 --- a/src/app/features/space-settings/general/General.tsx +++ b/src/app/features/space-settings/general/General.tsx @@ -13,6 +13,7 @@ import { } from '../../common-settings/general'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type GeneralProps = { requestClose: () => void; @@ -33,7 +34,7 @@ export function General({ requestClose }: GeneralProps) { - + diff --git a/src/app/features/space-settings/permissions/Permissions.tsx b/src/app/features/space-settings/permissions/Permissions.tsx index 7572a71b..d513dda9 100644 --- a/src/app/features/space-settings/permissions/Permissions.tsx +++ b/src/app/features/space-settings/permissions/Permissions.tsx @@ -9,6 +9,7 @@ import { usePermissionGroups } from './usePermissionItems'; import { PermissionGroups, Powers, PowersEditor } from '../../common-settings/permissions'; import { useRoomCreators } from '../../../hooks/useRoomCreators'; import { useRoomPermissions } from '../../../hooks/useRoomPermissions'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; type PermissionsProps = { requestClose: () => void; @@ -45,7 +46,7 @@ export function Permissions({ requestClose }: PermissionsProps) { - + diff --git a/src/app/pages/client/explore/Explore.tsx b/src/app/pages/client/explore/Explore.tsx index dae83166..6944c9f7 100644 --- a/src/app/pages/client/explore/Explore.tsx +++ b/src/app/pages/client/explore/Explore.tsx @@ -37,6 +37,7 @@ import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { useNavToActivePathMapper } from '../../../hooks/useNavToActivePathMapper'; import { PageNav, PageNavContent, PageNavHeader } from '../../../components/page'; import { stopPropagation } from '../../../utils/keyboard'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function AddServer() { const mx = useMatrixClient(); @@ -96,7 +97,12 @@ export function AddServer() { Add Server - setDialog(false)} radii="300"> + setDialog(false)} + radii="300" + > diff --git a/src/app/phoneHit.roomSpace.test.ts b/src/app/phoneHit.roomSpace.test.ts new file mode 100644 index 00000000..c98c2b09 --- /dev/null +++ b/src/app/phoneHit.roomSpace.test.ts @@ -0,0 +1,28 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in room settings, space settings, lobby, search and explore', () => { + // The class each IconButton must carry, in source order. Vertical stacks and lone buttons: the plain PhoneHit. + const expected: Record = { + 'features/room-settings/RoomSettings.tsx': ['PhoneHit'], // close, phone only, alone at the header end + 'features/room-settings/general/General.tsx': ['PhoneHit'], // close, 40 px + 'features/room-settings/permissions/Permissions.tsx': ['PhoneHit'], + 'features/space-settings/SpaceSettings.tsx': ['PhoneHit'], + 'features/space-settings/general/General.tsx': ['PhoneHit'], + 'features/space-settings/permissions/Permissions.tsx': ['PhoneHit'], + // 16 px icon + 8 padding = 24 px, 8-12 px from the Open / Add chips beside it in a hierarchy card + 'features/lobby/HierarchyItemMenu.tsx': ['PhoneHitSpread24'], + 'features/lobby/Lobby.tsx': ['PhoneHit'], // floating scroll-to-top, alone + 'features/message-search/MessageSearch.tsx': ['PhoneHit'], // floating scroll-to-top, alone + 'pages/client/explore/Explore.tsx': ['PhoneHit'], // Add Server dialog close + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); From a0d047c139f7727f1d9e67780670ff9d713d0c71 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:41 +0000 Subject: [PATCH 44/49] Phone hit: message content and data editor icon buttons Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/AccountDataEditor.tsx | 3 ++- .../image-pack-view/ImagePackView.tsx | 3 ++- src/app/components/message/FileHeader.tsx | 2 ++ .../message/content/AudioContent.tsx | 2 ++ src/app/phoneHit.content.test.ts | 26 +++++++++++++++++++ src/app/plugins/react-custom-html-parser.tsx | 2 ++ 6 files changed, 36 insertions(+), 2 deletions(-) create mode 100644 src/app/phoneHit.content.test.ts diff --git a/src/app/components/AccountDataEditor.tsx b/src/app/components/AccountDataEditor.tsx index 2dbaf1f1..8c28b935 100644 --- a/src/app/components/AccountDataEditor.tsx +++ b/src/app/components/AccountDataEditor.tsx @@ -16,6 +16,7 @@ import { } from 'folds'; import { MatrixError } from 'matrix-js-sdk'; import { Cursor } from '../plugins/text-area'; +import { PhoneHit } from '../styles/PhoneHit.css'; import { syntaxErrorPosition } from '../utils/dom'; import { AsyncStatus, useAsyncCallback } from '../hooks/useAsyncCallback'; import { Page, PageHeader } from './page'; @@ -294,7 +295,7 @@ export function AccountDataEditor({ - + diff --git a/src/app/components/image-pack-view/ImagePackView.tsx b/src/app/components/image-pack-view/ImagePackView.tsx index ab81d503..15b1cd08 100644 --- a/src/app/components/image-pack-view/ImagePackView.tsx +++ b/src/app/components/image-pack-view/ImagePackView.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { Box, IconButton, Text, Icon, Icons, Scroll, Chip } from 'folds'; import { PackAddress } from '../../plugins/custom-emoji'; +import { PhoneHit } from '../../styles/PhoneHit.css'; import { Page, PageHeader, PageContent } from '../page'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { RoomImagePack } from './RoomImagePack'; @@ -29,7 +30,7 @@ export function ImagePackView({ address, requestClose }: ImagePackViewProps) { - + diff --git a/src/app/components/message/FileHeader.tsx b/src/app/components/message/FileHeader.tsx index 2ffc9ec4..41cef5f4 100644 --- a/src/app/components/message/FileHeader.tsx +++ b/src/app/components/message/FileHeader.tsx @@ -2,6 +2,7 @@ import { Badge, Box, Icon, IconButton, Icons, Spinner, Text, as, toRem } from 'f import React, { ReactNode, useCallback } from 'react'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import FileSaver from 'file-saver'; +import { PhoneHit } from '../../styles/PhoneHit.css'; import { mimeTypeToExt } from '../../utils/mimeTypes'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -48,6 +49,7 @@ export function FileDownloadButton({ filename, url, mimeType, encInfo }: FileDow variant={hasError ? 'Critical' : 'SurfaceVariant'} size="300" radii="300" + className={PhoneHit} > {downloading ? ( diff --git a/src/app/components/message/content/AudioContent.tsx b/src/app/components/message/content/AudioContent.tsx index 478486a4..94132cea 100644 --- a/src/app/components/message/content/AudioContent.tsx +++ b/src/app/components/message/content/AudioContent.tsx @@ -3,6 +3,7 @@ import React, { ReactNode, useCallback, useRef, useState } from 'react'; import { Badge, Chip, Icon, IconButton, Icons, ProgressBar, Spinner, Text, toRem } from 'folds'; import { EncryptedAttachmentInfo } from 'browser-encrypt-attachment'; import { Range } from 'react-range'; +import { PhoneHitSpread24 } from '../../../styles/PhoneHit.css'; import { useMatrixClient } from '../../../hooks/useMatrixClient'; import { AsyncStatus, useAsyncCallback } from '../../../hooks/useAsyncCallback'; import { IAudioInfo } from '../../../../types/matrix/common'; @@ -159,6 +160,7 @@ export function AudioContent({ variant="SurfaceVariant" size="300" radii="Pill" + className={PhoneHitSpread24} onClick={() => setMute(!mute)} aria-pressed={mute} > diff --git a/src/app/phoneHit.content.test.ts b/src/app/phoneHit.content.test.ts new file mode 100644 index 00000000..741a5828 --- /dev/null +++ b/src/app/phoneHit.content.test.ts @@ -0,0 +1,26 @@ +import { describe, it } from 'vitest'; +import { expectPhoneHitClasses, expectSizeTie } from './phoneHitTestKit'; + +describe('icon buttons in message content and data editors', () => { + // The class each IconButton must carry, in source order. + const expected: Record = { + // mute toggle, 16 px icon + 8 padding = 24 px, 8 px from the volume slider (tappable) + 'components/message/content/AudioContent.tsx': ['PhoneHitSpread24'], + // download, alone at the end of the header row (26 px) + 'components/message/FileHeader.tsx': ['PhoneHit'], + // code block expand/collapse, 24 px, 8 px from the Copy chip + 'plugins/react-custom-html-parser.tsx': ['PhoneHitSpread24'], + // close at the header end, alone + 'components/AccountDataEditor.tsx': ['PhoneHit'], + 'components/image-pack-view/ImagePackView.tsx': ['PhoneHit'], + }; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: a sized variant matches the drawn size', (file) => + expectSizeTie(file) + ); +}); diff --git a/src/app/plugins/react-custom-html-parser.tsx b/src/app/plugins/react-custom-html-parser.tsx index 961131d9..2398a4c9 100644 --- a/src/app/plugins/react-custom-html-parser.tsx +++ b/src/app/plugins/react-custom-html-parser.tsx @@ -22,6 +22,7 @@ import Linkify from 'linkify-react'; import { ErrorBoundary } from 'react-error-boundary'; import { ChildNode } from 'domhandler'; import * as css from '../styles/CustomHtml.css'; +import { PhoneHitSpread24 } from '../styles/PhoneHit.css'; import { getMxIdLocalPart, getCanonicalAliasRoomId, @@ -282,6 +283,7 @@ export function CodeBlock({ variant="SurfaceVariant" outlined radii="300" + className={PhoneHitSpread24} onClick={toggleExpand} aria-label={expanded ? 'Collapse' : 'Expand'} > From da05210db29eb55d2c27f7c0fb490c10f07f36fa Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:16:46 +0000 Subject: [PATCH 45/49] Phone hit: editor toolbar, emoji sidebar, upload cards, client root Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Toolbar.tsx | 9 +++ .../emoji-board/components/Sidebar.tsx | 2 + .../upload-card/CompactUploadCardRenderer.tsx | 2 + .../upload-card/UploadCardRenderer.tsx | 2 + src/app/pages/client/ClientRoot.tsx | 2 + src/app/phoneHit.editor.test.ts | 61 +++++++++++++++++++ 6 files changed, 78 insertions(+) create mode 100644 src/app/phoneHit.editor.test.ts diff --git a/src/app/components/editor/Toolbar.tsx b/src/app/components/editor/Toolbar.tsx index 7d701c42..3721b909 100644 --- a/src/app/components/editor/Toolbar.tsx +++ b/src/app/components/editor/Toolbar.tsx @@ -36,6 +36,7 @@ import { KeySymbol } from '../../utils/key-symbol'; import { useSetting } from '../../state/hooks/settings'; import { settingsAtom } from '../../state/settings'; import { stopPropagation } from '../../utils/keyboard'; +import { PhoneHit, PhoneHitRoom36, PhoneHitSpread36 } from '../../styles/PhoneHit.css'; function BtnTooltip({ text, shortCode }: { text: string; shortCode?: string }) { return ( @@ -72,6 +73,7 @@ export function MarkButton({ format, icon, tooltip }: MarkButtonProps) { {(triggerRef) => ( {(triggerRef) => ( {(triggerRef) => ( handleMenuSelect(1)} size="400" @@ -178,6 +182,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(2)} size="400" @@ -193,6 +198,7 @@ export function HeadingBlockButton() { > {(triggerRef) => ( handleMenuSelect(3)} size="400" @@ -208,6 +214,7 @@ export function HeadingBlockButton() { } > {(triggerRef) => ( {(triggerRef) => ( setIsMarkdown(!isMarkdown)} diff --git a/src/app/components/emoji-board/components/Sidebar.tsx b/src/app/components/emoji-board/components/Sidebar.tsx index de22b483..d2d5f197 100644 --- a/src/app/components/emoji-board/components/Sidebar.tsx +++ b/src/app/components/emoji-board/components/Sidebar.tsx @@ -14,6 +14,7 @@ import { } from 'folds'; import classNames from 'classnames'; import * as css from './styles.css'; +import { PhoneHit } from '../../../styles/PhoneHit.css'; export function Sidebar({ children }: { children: ReactNode }) { return ( @@ -68,6 +69,7 @@ function SidebarBtn({ > {(ref) => ( )} )} { + // The class each IconButton must carry, in source order. + const expected: Record = { + 'components/editor/Toolbar.tsx': [ + 'PhoneHitSpread36', // mark button: size 400 + 20 px icon, in a row 4 px apart + 'PhoneHitSpread36', // block button, same row + 'PhoneHitRoom36', // heading 1, inside the heading Menu (overflow hidden) + 'PhoneHitRoom36', // heading 2 + 'PhoneHitRoom36', // heading 3 + 'PhoneHit', // heading opener: two 20 px icons, 56 px wide, only its height is short + 'PhoneHit', // Exit chip: text label, wider than 44 px + 'PhoneHit', // markdown toggle, alone at the end of the toolbar + ], + // A column of 40 px buttons: there is no vertical variant, so only PhoneHit; neighbours overlap by their 2 px gap. + 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'], + // Both cards put a Retry chip beside the cancel button when the upload failed. + 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'], + 'components/upload-card/CompactUploadCardRenderer.tsx': ['PhoneHitSpread28'], + 'pages/client/ClientRoot.tsx': ['PhoneHit'], // alone, absolute in the corner + }; + + const sizeAllow: SizeAllow = {}; + + it.each(Object.entries(expected))( + '%s: each IconButton carries its own PhoneHit class', + (file, want) => expectPhoneHitClasses(file, want) + ); + + it.each(Object.keys(expected))('%s: Spread and Room variants name the size they draw', (file) => + expectSizeTie(file, sizeAllow) + ); +}); + +describe('the estate navigator find button', () => { + // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because + // the Targets class sets a min size, and the button sits under that class. + it('is 44 px by the Targets class instead', async () => { + const src = readSource('features/estate/EstateNavigator.tsx'); + const tags = iconButtonTags(src); + expect(tags).toHaveLength(1); + expect(tags[0]).not.toMatch(/PhoneHit/); + const header = src.slice(src.indexOf('')); + expect(header.indexOf('className={css.Targets}')).toBeGreaterThan(-1); + expect(header.indexOf('className={css.Targets}')).toBeLessThan(header.indexOf(' Date: Fri, 13 Nov 2026 07:21:00 +0000 Subject: [PATCH 46/49] Phone hit kit: read the className expression, not its source text A PhoneHit name inside a string, built by concatenation, behind ?:/&&/||/??, or followed by a props spread that can override className no longer satisfies the pin. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHitTestKit.test.ts | 47 ++++++++++++++++++++++++++++++++- src/app/phoneHitTestKit.ts | 43 +++++++++++++++++++++++++++++- 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts index 92f1b23f..bd5f8bcd 100644 --- a/src/app/phoneHitTestKit.test.ts +++ b/src/app/phoneHitTestKit.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { drawnPx, expectSizeTie, iconButtons } from './phoneHitTestKit'; +import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit'; describe('phone hit test kit', () => { it('reads { @@ -38,4 +38,49 @@ describe('phone hit test kit', () => { it('fails a file with no IconButton, which would otherwise pass vacuously', () => { expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); }); + + describe('phoneHitOf reads the imported class, applied unconditionally', () => { + const tag = (expr: string) => ``; + + it('accepts the bare identifier and merges that always include it', () => { + expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26'); + expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36'); + expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit'); + expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit'); + expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit'); + }); + + it('rejects the name as a string literal', () => { + expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull(); + expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull(); + }); + + it('rejects a name built by concatenation', () => { + expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull(); + expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull(); + }); + + it('rejects a conditionally applied name', () => { + expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull(); + expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull(); + expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull(); + }); + + it('rejects a member of another object and a tag without className', () => { + expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + }); + + it('rejects a spread after className, which can override it, and accepts one before', () => { + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBe('PhoneHit'); + }); + + it('reads a className past braces inside strings', () => { + expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit'); + }); + }); }); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index a29dcc22..d86f23f5 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -85,7 +85,48 @@ export const iconButtons = (src: string) => export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); -const phoneHitOf = (tag: string) => tag.match(/className=\{[^}]*?\b(PhoneHit\w*)\b/)?.[1] ?? null; +/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace + * (strings and templates may hold braces); undefined when the tag has none. */ +const classNameExpr = (tag: string) => { + const m = /\sclassName=\{/.exec(tag); + if (!m) return undefined; + const from = m.index + m[0].length; + let depth = 1; + let quote = ''; + for (let j = from; j < tag.length; j += 1) { + const c = tag[j]; + if (quote) { + if (c === '\\') j += 1; + else if (c === quote) quote = ''; + } else if (c === '"' || c === "'" || c === '`') quote = c; + else if (c === '{') depth += 1; + else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j }; + } + return undefined; +}; + +/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its + * expression), so only identifiers and operators remain. */ +const withoutLiterals = (expr: string) => + expr + .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ') + .replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) => + [...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ') + ); + +/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A + * name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the + * imported class (or not always applied), so it reads as null. */ +export const phoneHitOf = (tag: string) => { + const found = classNameExpr(tag); + if (found === undefined) return null; + // props spread in after className can override it at runtime; before it they cannot + if (tag.slice(found.end).includes('{...')) return null; + const bare = withoutLiterals(found.expr); + if (/\?|&&|\|\|/.test(bare)) return null; + const names = [...bare.matchAll(/(? m[1]); + return names.length === 1 ? names[0] : null; +}; /** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks From 761d13fac5ca6b1d267eaba32bdc96c437d75b5f Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:21:57 +0000 Subject: [PATCH 47/49] Phone hit editor tests: pin estate Targets nesting and selector, ClientRoot inline absolute, sidebar gap Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHit.editor.test.ts | 60 +++++++++++++++++++++++++++++---- 1 file changed, 53 insertions(+), 7 deletions(-) diff --git a/src/app/phoneHit.editor.test.ts b/src/app/phoneHit.editor.test.ts index 98bfc051..ef016b59 100644 --- a/src/app/phoneHit.editor.test.ts +++ b/src/app/phoneHit.editor.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest'; import { MOBILE_BREAKPOINT } from './hooks/useScreenSize'; import { + classLists, emitted, expectPhoneHitClasses, expectSizeTie, @@ -23,7 +24,8 @@ describe('icon buttons in the editor, emoji board, upload cards and client root' 'PhoneHit', // Exit chip: text label, wider than 44 px 'PhoneHit', // markdown toggle, alone at the end of the toolbar ], - // A column of 40 px buttons: there is no vertical variant, so only PhoneHit; neighbours overlap by their 2 px gap. + // A column of 40 px buttons 4 px apart (the stack's gap="100", pinned below): there is no vertical variant, so only + // PhoneHit; the 44 px areas then touch with no overlap. 'components/emoji-board/components/Sidebar.tsx': ['PhoneHit'], // Both cards put a Retry chip beside the cancel button when the upload failed. 'components/upload-card/UploadCardRenderer.tsx': ['PhoneHitSpread28'], @@ -43,19 +45,63 @@ describe('icon buttons in the editor, emoji board, upload cards and client root' ); }); +/** The JSX element opened at `open` (index of its ``: nesting is counted, not guessed (no self-closing Box here). */ +const boxSpan = (src: string, open: number) => { + const marks = [...src.slice(open).matchAll(/])|<\/Box>/g)]; + const depths = marks.map((m, i) => + marks.slice(0, i + 1).reduce((d, x) => d + (x[0] === '' ? -1 : 1), 0) + ); + const end = depths.indexOf(0); + if (end === -1) throw new Error('unbalanced '); + return [open, open + (marks[end].index ?? 0) + marks[end][0].length] as const; +}; + describe('the estate navigator find button', () => { // The only IconButton there needs no PhoneHit: every button in the navigator header is 44 px on a phone because - // the Targets class sets a min size, and the button sits under that class. + // the Targets class sets a min size, and the button sits inside the Box that carries that class. it('is 44 px by the Targets class instead', async () => { const src = readSource('features/estate/EstateNavigator.tsx'); const tags = iconButtonTags(src); expect(tags).toHaveLength(1); expect(tags[0]).not.toMatch(/PhoneHit/); - const header = src.slice(src.indexOf('')); - expect(header.indexOf('className={css.Targets}')).toBeGreaterThan(-1); - expect(header.indexOf('className={css.Targets}')).toBeLessThan(header.indexOf(''); + const boxStart = src.indexOf('', boxStart))).toMatch(/className=\{css\.Targets\}/); + const button = src.indexOf(' { + // PhoneHit makes the button position: relative on a phone; the inline style wins over a class, so the corner + // button stays absolute and its ::after anchors to it. + it('ClientRoot keeps its corner button absolutely positioned inline', () => { + const [tag] = iconButtonTags(readSource('pages/client/ClientRoot.tsx')); + expect(tag).toMatch(/style=\{\{[^}]*position:\s*'absolute'/); + }); + + // A 40 px button with a 44 px area, in a column 4 px apart: the areas touch and do not overlap. A smaller gap + // overlaps them, a larger one leaves a dead strip. + it('the emoji sidebar stack keeps its 4 px gap', () => { + const src = readSource('components/emoji-board/components/Sidebar.tsx'); + const stack = src.slice( + src.indexOf('export const SidebarStack'), + src.indexOf('export function SidebarDivider') + ); + expect(stack).toMatch(/direction="Column"/); + expect(stack).toMatch(/gap="100"/); }); }); From 4eaa6f9c55469b4c2e2ff7e4a45d3fc679737080 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 07:28:37 +0000 Subject: [PATCH 48/49] Phone hit kit: read IconButton className off the TypeScript AST className must appear once, with no spread after it, as the imported PhoneHit* identifier bare, a classNames/clsx/cx direct argument, or a template span, and the identifier must occur nowhere else in the expression. Tag enumeration uses the same AST. expectPhoneHitClasses takes (string|null)[] (null = no class), as the phone-hit-pages branch does, and is exercised by a test on a temp source. Co-Authored-By: Claude Sonnet 5.5 --- src/app/phoneHitTestKit.test.ts | 114 ++++++++++++++++----- src/app/phoneHitTestKit.ts | 171 ++++++++++++++++++++------------ 2 files changed, 196 insertions(+), 89 deletions(-) diff --git a/src/app/phoneHitTestKit.test.ts b/src/app/phoneHitTestKit.test.ts index bd5f8bcd..a77a2e27 100644 --- a/src/app/phoneHitTestKit.test.ts +++ b/src/app/phoneHitTestKit.test.ts @@ -1,5 +1,14 @@ import { describe, expect, it } from 'vitest'; -import { drawnPx, expectSizeTie, iconButtons, phoneHitOf } from './phoneHitTestKit'; +import { mkdtempSync, rmSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { relative } from 'path'; +import { + drawnPx, + expectPhoneHitClasses, + expectSizeTie, + iconButtons, + phoneHitOf, +} from './phoneHitTestKit'; describe('phone hit test kit', () => { it('reads { @@ -39,38 +48,60 @@ describe('phone hit test kit', () => { expect(() => expectSizeTie('styles/PhoneHit.css.ts')).toThrow(/no IconButton/); }); - describe('phoneHitOf reads the imported class, applied unconditionally', () => { + describe('phoneHitOf reads the imported class off the AST, applied unconditionally', () => { const tag = (expr: string) => ``; + const ok = (expr: string, name: string) => expect(phoneHitOf(tag(expr))).toBe(name); + const no = (expr: string) => expect(phoneHitOf(tag(expr))).toBeNull(); it('accepts the bare identifier and merges that always include it', () => { - expect(phoneHitOf(tag('PhoneHitSpread26'))).toBe('PhoneHitSpread26'); - expect(phoneHitOf(tag('classNames(PhoneHitRoom36, css.other)'))).toBe('PhoneHitRoom36'); - expect(phoneHitOf(tag('`${PhoneHit} ${css.other}`'))).toBe('PhoneHit'); - expect(phoneHitOf(tag("[PhoneHit, 'a b'].join(' ')"))).toBe('PhoneHit'); - expect(phoneHitOf(tag('classNames(css.a, { [css.b]: on }, PhoneHit)'))).toBe('PhoneHit'); + ok('PhoneHitSpread26', 'PhoneHitSpread26'); + ok('classNames(PhoneHitRoom36, css.other)', 'PhoneHitRoom36'); + ok('clsx(css.a, { [css.b]: on }, PhoneHit)', 'PhoneHit'); + ok('cx(PhoneHit)', 'PhoneHit'); + ok('`${PhoneHit} ${css.other}`', 'PhoneHit'); + ok('classNames(/* PhoneHit */ PhoneHit)', 'PhoneHit'); }); - it('rejects the name as a string literal', () => { - expect(phoneHitOf(tag("'PhoneHitSpread26'"))).toBeNull(); - expect(phoneHitOf(tag('`PhoneHitSpread26`'))).toBeNull(); - }); - - it('rejects a name built by concatenation', () => { - expect(phoneHitOf(tag('"PhoneHitSpread26" + "x"'))).toBeNull(); - expect(phoneHitOf(tag("'PhoneHit' + 'Spread26'"))).toBeNull(); + it('rejects the name as a string, a concatenation or a comment', () => { + no("'PhoneHitSpread26'"); + no('`PhoneHitSpread26`'); + no('"PhoneHitSpread26" + "x"'); + no("'PhoneHit' + 'Spread26'"); + no('/* PhoneHit */ undefined'); + no('css.a // PhoneHit'); }); it('rejects a conditionally applied name', () => { - expect(phoneHitOf(tag('cond ? PhoneHitSpread26 : undefined'))).toBeNull(); - expect(phoneHitOf(tag('cond && PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag('cond || PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag('maybe ?? PhoneHitSpread26'))).toBeNull(); - expect(phoneHitOf(tag("classNames(cond ? PhoneHit : '', css.a)"))).toBeNull(); + no('cond ? PhoneHitSpread26 : undefined'); + no('cond && PhoneHitSpread26'); + no('cond || PhoneHitSpread26'); + no('maybe ?? PhoneHitSpread26'); + no("classNames(cond ? PhoneHit : '', css.a)"); + no('classNames(PhoneHit, cond && PhoneHit)'); }); - it('rejects a member of another object and a tag without className', () => { - expect(phoneHitOf(tag('other.PhoneHit'))).toBeNull(); + it('rejects every other expression shape the name can hide in', () => { + no('classNames({ PhoneHit: on })'); + no('clsx({ [PhoneHit]: on })'); + no('`${`${PhoneHit}`}`'); + no('typeof PhoneHit'); + no("(PhoneHit = 'x')"); + no('(PhoneHit, css.a)'); + no('(PhoneHit)'); + no('() => PhoneHit'); + no('PhoneHit.foo'); + no('other.PhoneHit'); + no('classNames(css.a, [PhoneHit])'); + no('wrap(PhoneHit)'); + no('PhoneHitSpread26é'); + no('`${css.a}`'); + }); + + it('rejects a tag without className, with two, or with a non-expression one', () => { expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBeNull(); + expect(phoneHitOf('')).toBe('PhoneHit'); }); it('rejects a spread after className, which can override it, and accepts one before', () => { @@ -80,7 +111,44 @@ describe('phone hit test kit', () => { }); it('reads a className past braces inside strings', () => { - expect(phoneHitOf(tag("classNames('{', PhoneHit)"))).toBe('PhoneHit'); + ok("classNames('{', PhoneHit)", 'PhoneHit'); + }); + }); + + describe('expectPhoneHitClasses', () => { + const check = (src: string, want: (string | null)[]) => { + const dir = mkdtempSync(`${tmpdir()}/phone-hit-kit-`); + try { + writeFileSync(`${dir}/Probe.tsx`, src); + expectPhoneHitClasses(relative(`${process.cwd()}/src/app`, `${dir}/Probe.tsx`), want); + } finally { + rmSync(dir, { recursive: true }); + } + }; + const imp = "import { PhoneHit, PhoneHitSpread26 } from '../../styles/PhoneHit.css';\n"; + const src = + imp + + 'const A = () => (<>);'; + + it('pins each button in order, null meaning no class', () => { + check(src, ['PhoneHit', 'PhoneHitSpread26', null]); + expect(() => check(src, ['PhoneHit', 'PhoneHit', null])).toThrow(); + expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26', 'PhoneHit'])).toThrow(); + expect(() => check(src, ['PhoneHit', 'PhoneHitSpread26'])).toThrow(); + }); + + it('fails a name that is not read from PhoneHit.css or is applied conditionally', () => { + const local = "const PhoneHit = 'x';\n"; + expect(() => check(local, ['PhoneHit'])).toThrow(); + expect(() => check(local, [null])).not.toThrow(); + const cond = imp + ''; + expect(() => check(cond, ['PhoneHit'])).toThrow(); + const aliased = + "import { Other as PhoneHit } from '../../styles/PhoneHit.css';\n"; + expect(() => check(aliased, ['PhoneHit'])).toThrow(); + const wrongModule = + "import { PhoneHit } from './other';\n"; + expect(() => check(wrongModule, ['PhoneHit'])).toThrow(); }); }); }); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index d86f23f5..896b917b 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -2,6 +2,7 @@ // group of IconButtons). Not a test file: it registers nothing, the test files call it. import { readFileSync } from 'fs'; import { expect } from 'vitest'; +import ts from 'typescript'; import zlib from 'zlib'; import { compile, processVanillaFile } from '@vanilla-extract/integration'; @@ -72,77 +73,113 @@ const tagEnd = (src: string, from: number) => { return j; }; -/** Every outside any {} expression, and - * what it wraps (empty when self-closing). */ +const tsx = (src: string) => + ts.createSourceFile('source.tsx', src, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX); + +type Opening = ts.JsxOpeningElement | ts.JsxSelfClosingElement; + +/** Every IconButton element (not IconButtonX) of a parsed source, in source order. */ +const elements = (sf: ts.SourceFile) => { + const found: Opening[] = []; + const walk = (n: ts.Node) => { + if ( + (ts.isJsxOpeningElement(n) || ts.isJsxSelfClosingElement(n)) && + ts.isIdentifier(n.tagName) && + n.tagName.text === 'IconButton' + ) + found.push(n); + ts.forEachChild(n, walk); + }; + walk(sf); + return found; +}; + +const bodyOf = (el: Opening) => + ts.isJsxOpeningElement(el) && ts.isJsxElement(el.parent) + ? el.getSourceFile().text.slice(el.end, el.parent.closingElement.getStart()) + : ''; + +/** Every - [...src.matchAll(//])/g)].map((m) => { - const start = m.index ?? 0; - const end = tagEnd(src, start); - const tag = src.slice(start, end + 1); - const close = src.indexOf('', end); - return { tag, body: tag.endsWith('/>') || close === -1 ? '' : src.slice(end + 1, close) }; - }); + elements(tsx(src)).map((el) => ({ tag: el.getText(), body: bodyOf(el) })); export const iconButtonTags = (src: string) => iconButtons(src).map((b) => b.tag); -/** The expression of a tag's className={...} and the index of its closing brace, read to the balanced brace - * (strings and templates may hold braces); undefined when the tag has none. */ -const classNameExpr = (tag: string) => { - const m = /\sclassName=\{/.exec(tag); - if (!m) return undefined; - const from = m.index + m[0].length; - let depth = 1; - let quote = ''; - for (let j = from; j < tag.length; j += 1) { - const c = tag[j]; - if (quote) { - if (c === '\\') j += 1; - else if (c === quote) quote = ''; - } else if (c === '"' || c === "'" || c === '`') quote = c; - else if (c === '{') depth += 1; - else if (c === '}' && (depth -= 1) === 0) return { expr: tag.slice(from, j), end: j }; - } - return undefined; -}; - -/** The expression with its string literals and the literal text of its templates removed (a `${...}` keeps its - * expression), so only identifiers and operators remain. */ -const withoutLiterals = (expr: string) => - expr - .replace(/'(?:[^'\\]|\\.)*'|"(?:[^"\\]|\\.)*"/g, ' ') - .replace(/`((?:[^`\\]|\\.)*)`/g, (_, body: string) => - [...body.matchAll(/\$\{([^}]*)\}/g)].map((t) => ` ${t[1]} `).join(' ') - ); - -/** The PhoneHit* class a tag carries: a bare identifier in its className expression, applied unconditionally. A - * name inside a string, built by concatenation, or behind ?:, &&, || or ??, or followed by a {...spread}, is not the - * imported class (or not always applied), so it reads as null. */ -export const phoneHitOf = (tag: string) => { - const found = classNameExpr(tag); - if (found === undefined) return null; - // props spread in after className can override it at runtime; before it they cannot - if (tag.slice(found.end).includes('{...')) return null; - const bare = withoutLiterals(found.expr); - if (/\?|&&|\|\|/.test(bare)) return null; - const names = [...bare.matchAll(/(? m[1]); - return names.length === 1 ? names[0] : null; -}; - -/** The class each IconButton must carry, in source order: the variant is chosen by the button's drawn size and - * whether a clipping container or neighbours surround it, so a swapped variant is a wrong hit area. Also checks - * the name is the one PhoneHit.css.ts exports, not a local of the same name. */ -export const expectPhoneHitClasses = (file: string, want: string[]) => { - const src = readSource(file); - expect(iconButtonTags(src).map(phoneHitOf)).toEqual(want); - [...new Set(want)].forEach((name) => - expect(src).toMatch( - new RegExp( - `import \\{(?:[^}]*,)?\\s*${name}\\s*(?:,[^}]*)?\\} from '(\\.\\./)+styles/PhoneHit\\.css'` - ) - ) +/** Local names the source imports from the PhoneHit.css module (as written, not aliased). */ +const importedHits = (sf: ts.SourceFile) => + new Set( + sf.statements.flatMap((st) => { + const nb = ts.isImportDeclaration(st) ? st.importClause?.namedBindings : undefined; + return nb && + ts.isNamedImports(nb) && + ts.isStringLiteral((st as ts.ImportDeclaration).moduleSpecifier) && + /^(\.\.\/)+styles\/PhoneHit\.css$/.test( + ((st as ts.ImportDeclaration).moduleSpecifier as ts.StringLiteral).text + ) + ? nb.elements.filter((e) => !e.propertyName).map((e) => e.name.text) + : []; + }) ); + +const MERGERS = new Set(['classNames', 'clsx', 'cx']); + +/** The PhoneHit* class an IconButton carries, or null. className must appear exactly once, with no props spread + * after it (a spread can override it at runtime), and its expression must be: the bare identifier; a classNames / + * clsx / cx call with it as a direct argument; or a template with it as a direct span. The identifier must occur + * nowhere else in the expression, so a key, a member, a closure, a conditional or a comma can never carry it. When + * `imported` is given, the identifier must also be a binding imported from the PhoneHit.css module. */ +const hitOf = (el: Opening, imported?: Set): string | null => { + const props = el.attributes.properties; + const at = props.flatMap((p, i) => + ts.isJsxAttribute(p) && ts.isIdentifier(p.name) && p.name.text === 'className' ? [i] : [] + ); + if (at.length !== 1 || props.slice(at[0] + 1).some(ts.isJsxSpreadAttribute)) return null; + const attr = props[at[0]] as ts.JsxAttribute; + const expr = + attr.initializer && ts.isJsxExpression(attr.initializer) && attr.initializer.expression; + if (!expr) return null; + const isHit = (n: ts.Node): n is ts.Identifier => + ts.isIdentifier(n) && /^PhoneHit\w*$/.test(n.text) && (!imported || imported.has(n.text)); + let direct: ts.Node[] = []; + if (ts.isIdentifier(expr)) direct = [expr]; + else if ( + ts.isCallExpression(expr) && + ts.isIdentifier(expr.expression) && + MERGERS.has(expr.expression.text) + ) + direct = [...expr.arguments]; + else if (ts.isTemplateExpression(expr)) direct = expr.templateSpans.map((sp) => sp.expression); + const hits = direct.filter(isHit); + if (hits.length !== 1) return null; + let occurrences = 0; + const count = (n: ts.Node) => { + if (ts.isIdentifier(n) && n.text === hits[0].text) occurrences += 1; + ts.forEachChild(n, count); + }; + count(expr); + return occurrences === 1 ? hits[0].text : null; }; +/** The PhoneHit* class of one tag's text (see hitOf), without the import-binding check. */ +export const phoneHitOf = (tag: string) => { + const els = elements(tsx(tag.endsWith('/>') ? tag : `${tag}`)); + return els.length === 1 ? hitOf(els[0]) : null; +}; + +/** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not + * the one imported from PhoneHit.css. */ +const phoneHitClasses = (src: string) => { + const sf = tsx(src); + const imported = importedHits(sf); + return elements(sf).map((el) => hitOf(el, imported)); +}; + +/** The class each IconButton must carry, in source order (null = must carry none): the variant is chosen by the + * button's drawn size and whether a clipping container or neighbours surround it, so a swapped variant is a wrong + * hit area. The name must also be the one PhoneHit.css exports, not a local of the same name. */ +export const expectPhoneHitClasses = (file: string, want: (string | null)[]) => + expect(phoneHitClasses(readSource(file))).toEqual(want); + // IconButton `size` -> padding on each side, Icon `size` -> px (folds 2.7.2). Both default to 400. const BUTTON_PAD: Record = { '300': 4, '400': 8, '500': 12, '600': 16 }; const ICON_PX: Record = { @@ -188,10 +225,12 @@ export type SizeAllow = Record>; export const expectSizeTie = (file: string, allow: SizeAllow = {}) => { const allowed = allow[file] ?? {}; const seen = new Set(); - const buttons = iconButtons(readSource(file)); + const src = readSource(file); + const buttons = iconButtons(src); + const classes = phoneHitClasses(src); expect(buttons.length, `${file} has no IconButton`).toBeGreaterThan(0); buttons.forEach(({ tag, body }, i) => { - const variant = phoneHitOf(tag)?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); + const variant = classes[i]?.match(/^PhoneHit(?:Spread|Room)(\d+)$/); if (!variant) { expect(allowed[i], `${file} #${i} is allow-listed but has no sized variant`).toBeUndefined(); return; From d5a0c1b4ffd4b4248fecbaa3c4d18861f53c1126 Mon Sep 17 00:00:00 2001 From: Void Date: Fri, 13 Nov 2026 13:46:44 +0000 Subject: [PATCH 49/49] Phone hit: a repo-wide guard for bare IconButtons, PhoneHitInPlace for the preview arrows, drop the dead Editor.preview phoneHit.coverage.test.ts walks every .tsx under src/app and fails on any IconButton without a PhoneHit class unless it is named with its reason (EstateNavigator Find a room, LobbyHeader Members). UrlPreviewCard's two absolute arrows take PhoneHitInPlace, which adds the hit area without PhoneHit's position: relative. Editor.preview.tsx was imported nowhere and held 11 bare IconButtons. Co-Authored-By: Claude Sonnet 5.5 --- src/app/components/editor/Editor.preview.tsx | 84 ------------------- .../components/url-preview/UrlPreviewCard.tsx | 12 ++- src/app/phoneHit.coverage.test.ts | 58 +++++++++++++ src/app/phoneHitTestKit.ts | 2 +- src/app/phoneTapTargets.test.ts | 10 ++- src/app/styles/PhoneHit.css.ts | 4 + 6 files changed, 80 insertions(+), 90 deletions(-) delete mode 100644 src/app/components/editor/Editor.preview.tsx create mode 100644 src/app/phoneHit.coverage.test.ts diff --git a/src/app/components/editor/Editor.preview.tsx b/src/app/components/editor/Editor.preview.tsx deleted file mode 100644 index b760dddc..00000000 --- a/src/app/components/editor/Editor.preview.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import React, { useState } from 'react'; -import FocusTrap from 'focus-trap-react'; -import { - config, - Icon, - IconButton, - Icons, - Line, - Modal, - Overlay, - OverlayBackdrop, - OverlayCenter, -} from 'folds'; - -import { CustomEditor, useEditor } from './Editor'; -import { Toolbar } from './Toolbar'; -import { stopPropagation } from '../../utils/keyboard'; - -export function EditorPreview() { - const [open, setOpen] = useState(false); - const editor = useEditor(); - const [toolbar, setToolbar] = useState(false); - - return ( - <> - setOpen(!open)}> - - - }> - - setOpen(false), - clickOutsideDeactivates: true, - escapeDeactivates: stopPropagation, - }} - > - -
- - - - } - after={ - <> - setToolbar(!toolbar)} - aria-pressed={toolbar} - > - - - - - - - - - - } - bottom={ - toolbar && ( -
- - -
- ) - } - /> -
-
-
-
-
- - ); -} diff --git a/src/app/components/url-preview/UrlPreviewCard.tsx b/src/app/components/url-preview/UrlPreviewCard.tsx index ee8967af..5d40e10c 100644 --- a/src/app/components/url-preview/UrlPreviewCard.tsx +++ b/src/app/components/url-preview/UrlPreviewCard.tsx @@ -1,3 +1,4 @@ +import classNames from 'classnames'; import React, { useCallback, useEffect, useRef, useState } from 'react'; import { IPreviewUrlResponse } from 'matrix-js-sdk'; import { Box, Icon, IconButton, Icons, Scroll, Spinner, Text, as, color, config } from 'folds'; @@ -10,6 +11,7 @@ import { useIntersectionObserver, } from '../../hooks/useIntersectionObserver'; import * as css from './UrlPreviewCard.css'; +import { PhoneHitInPlace } from '../../styles/PhoneHit.css'; import { tryDecodeURIComponent } from '../../utils/dom'; import { mxcUrlToHttp } from '../../utils/matrix'; import { useMediaAuthentication } from '../../hooks/useMediaAuthentication'; @@ -181,7 +183,10 @@ export const UrlPreviewHolder = as<'div'>(({ children, ...props }, ref) => { <>
(({ children, ...props }, ref) => { <>
= { + 'features/estate/EstateNavigator.tsx': { + indices: [0], + why: 'Find a room sits in a css.Targets row that already gives it a 44 px phone target', + }, + 'features/lobby/LobbyHeader.tsx': { + indices: [1], + why: 'Members button, hidden on mobile', + }, +}; + +const tsxFiles = (dir: string): string[] => + readdirSync(`${process.cwd()}/src/app/${dir}`, { withFileTypes: true }).flatMap((e) => { + if (e.isDirectory()) return tsxFiles(`${dir}${e.name}/`); + return e.name.endsWith('.tsx') ? [`${dir}${e.name}`] : []; + }); + +describe('phone hit areas: every IconButton is covered or named', () => { + it('lists exactly the IconButtons without a PhoneHit class', () => { + const bare: Record = {}; + tsxFiles('').forEach((file) => { + const src = readFileSync(`${process.cwd()}/src/app/${file}`, 'utf8'); + if (!src.includes(' (c === null ? [i] : [])); + if (missing.length > 0) bare[file] = missing; + }); + expect(bare).toEqual( + Object.fromEntries(Object.entries(EXEMPT).map(([f, e]) => [f, e.indices])) + ); + }); +}); + +// UrlPreviewCard's arrows are absolutely positioned by their own class; PhoneHit would set position: relative on them +// and win or lose by stylesheet order, so they carry the variant that adds only the hit area. +describe('phone hit area of a button positioned by its own class', () => { + it('the arrows carry PhoneHitInPlace', () => { + expectPhoneHitClasses('components/url-preview/UrlPreviewCard.tsx', [ + 'PhoneHitInPlace', + 'PhoneHitInPlace', + ]); + }); + + it('PhoneHitInPlace draws the ::after and sets no position of its own', async () => { + const lists = await classLists('styles/PhoneHit.css.ts'); + expect(lists.PhoneHitInPlace).toHaveLength(1); + const css = await emitted('styles/PhoneHit.css.ts'); + const rules = [...css.matchAll(/^([^{}\n]*PhoneHitInPlace__[^{}\n]*) \{$/gm)].map((m) => m[1]); + expect(rules.length).toBeGreaterThan(0); + rules.forEach((sel) => expect(sel).toMatch(/::after$/)); + }); +}); diff --git a/src/app/phoneHitTestKit.ts b/src/app/phoneHitTestKit.ts index 896b917b..d8449cba 100644 --- a/src/app/phoneHitTestKit.ts +++ b/src/app/phoneHitTestKit.ts @@ -168,7 +168,7 @@ export const phoneHitOf = (tag: string) => { /** The PhoneHit* class of each IconButton of a source, in source order; null when it has none or the name is not * the one imported from PhoneHit.css. */ -const phoneHitClasses = (src: string) => { +export const phoneHitClasses = (src: string) => { const sf = tsx(src); const imported = importedHits(sf); return elements(sf).map((el) => hitOf(el, imported)); diff --git a/src/app/phoneTapTargets.test.ts b/src/app/phoneTapTargets.test.ts index 5a5394e7..192f8414 100644 --- a/src/app/phoneTapTargets.test.ts +++ b/src/app/phoneTapTargets.test.ts @@ -102,13 +102,16 @@ describe('phone icon-button hit areas', () => { expect(css).toMatch(/::after \{[^}]*transform: translate\(-50%, -50%\);/); // the box anchors to its own button, receives the tap, and sits above nothing it should not expect(css).toMatch(/@media [^{]+ \{\n\s*\.PhoneHit_PhoneHit__\w+ \{\n\s*position: relative;/); - const afterDecls = [...css.matchAll(/::after \{\n([^}]*)\}/g)].flatMap((m) => + // PhoneHit and PhoneHitInPlace each draw it; the Spread and Room classes compose PhoneHit + const afters = [...css.matchAll(/::after \{\n([^}]*)\}/g)]; + expect(afters).toHaveLength(2); + const afterDecls = afters.flatMap((m) => m[1] .split('\n') .map((l) => l.trim().split(':')[0]) .filter(Boolean) ); - expect([...afterDecls].sort()).toEqual([ + expect([...new Set(afterDecls)].sort()).toEqual([ 'content', 'height', 'left', @@ -117,8 +120,9 @@ describe('phone icon-button hit areas', () => { 'transform', 'width', ]); - // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the box) + // nothing on a phone moves a button off its own anchor: only relative (the button) and absolute (the boxes) expect([...css.matchAll(/position: (\w+);/g)].map((m) => m[1]).sort()).toEqual([ + 'absolute', 'absolute', 'relative', ]); diff --git a/src/app/styles/PhoneHit.css.ts b/src/app/styles/PhoneHit.css.ts index 9863851b..e971ecb3 100644 --- a/src/app/styles/PhoneHit.css.ts +++ b/src/app/styles/PhoneHit.css.ts @@ -29,6 +29,10 @@ export const PhoneHit = style({ selectors: { '&::after': hitArea }, }); +/** For a button that is already positioned (absolute or fixed) by its own class: PhoneHit's `position: relative` + * would fight that class by stylesheet order, and the positioned button already contains its ::after. */ +export const PhoneHitInPlace = style({ selectors: { '&::after': hitArea } }); + const spreads = new Map(); /** An icon button in a row, `visual` px wide: the row opens up so its 44 px hit area meets its neighbours'. */