^lXKaH
zgDv5Pw&lT3nntLIs7`A!5fGaj59eQ3d#+9{S$j6)#bebatB*yFc1(9O1mqD8$Sgjb
zKSibdwtcweNwDU_ttyR?%WkERSLxBNc|Hz6l>LSME{!wxwg#4umo)LgJu2m1l6&>=RP^VFH#y9Vs;1cA$1mIBIhH<3Cq09WB~vm5o|~RP>psKQdaqbl!SovU(CB@QhuLgeZZ
z+PvpeKBrnh!4NdCsv$T)4JYBE<;#~h)2L|f0g1XA`Dv%RG3-D7U(R{<{qPi?zg0A3@5y&z}m;pQAA(tm3uLzyBX8KM9`^PwARBP*D+8HD!W1
zolJ!M_~@Dhy5}-MY?cG{G*fYLE)S!M@Mw<0w+JR~?npv_8DOD#T}e!xeW@64JaYf4
z%BJ+#ra|djDM&0DM_B50`&GcAXALH$yH2k&mBbPbHpjVPQ`7BSI^VaJ2MZl2A3Fwb
zrv3`jh8YJ}Yo2d?L%g^wTcx04w7Z8=J~4`pA6k^&$<#n|#m=KrSd;{Ez)hc0v
z_dRLRCLK8pAWveJXc>Q=?T)!fraqldd~UUV$!+7$Gpt?t2pKk*v7{LZ)u}Ab?VK2;Jpi@j;J?W-UN>tJsh&WD{CQl
zSm;X3aS_BWd4V0XCqS5V4ShOPSi>|>1tPXi7IfT_#WOf(?|{tf25MN=G6qRT87orz
ztD?E|;rmz3g)tk933B=K@uSS1fucxBw?f%YFe-@trylz3KweC
zo^m8YcDJr4U0i7GRgR^6ilwHl5lsT4cxv7!-9N#9?l~1)G@qXy*{znhCl?a2@y!
zY%wf*PF)j*{M1KK1aG_Qw%iuix=kV}s8>t0N708#8ynGP>?ODO;JX%s!Zme}FC$fm
zsgkDYU*1Rh(+mAgB)*6}_i5spxVb_gR*hcbw!1EdqDSm^s}-0>`w!8JL+-k`Ujob~
zxC`WmuS9RfsgPM=2%N9Jda{Pc0}-^CFSZtq7Sf-9vOIxzVat=ZG;^+Y+R|UTHRMn4
zOI?9#7&v6qqQ`kYOWqPKo6wng1J=MOqoRw*R2k!GCrKAJz46gZ51mP58d8SenkKgJ
z;aN{u$5|YEhXJZT=y12yTWd{DS`N1UqI(>3p7vv)%(vu?uh?xKG+4nBjLr}LN>fRs
zb&|FZ9&0@CKB-k#Fe6V}0aY3}*oH5l%~E{XcEPV!*T<>v3fw&Zlq+zspC9dA+Usf;
z5kek71ShOoC7*mW{F74{;i>8sEN^)SHUSFm7VM5cN54uY(iHhiXO+MGyyCO#{NWt7
z=tW*w_af>g)-iPKwKZw}h46bb0BP=v8DKVmx*DJ+P&`aRpU3JE^k)8Ly4l67SUu#~
z2)YMZH*XSh4p>>F9Y$2Sv?_i=M7gS7xpWOLsFs-wx4Ww46dAla|{}2sHePd-_iY{Tvbw9
z3f=Y^Qa&cnaVCYTR?k34)2qPSzt$E-
zn{>_MZMH@deZW9P+kJDB7p1=?qv2G~x&wQf;+eM9h=v+0;%O5#))LpoM-$a4B*?B>
zC}!QXD`XoLW$Bgm?Ho_zBaiVMdmK@yA(}Qt)08_lCh$LDtcY47<-mcKUp~$d-}7pz
z-SJd>15lDVT$pCGbO*JD_@i5@|jcng}h>j+zylNCGOARwW1j|*{yeuGWqVO%{
za$VQ2Q|w@I{**|(Uvu#b^MBs|aM<(dqgzuJ*_5(_wYT79XtHHFsIaWUZCfHHkomU_
zArBj#0=D$<|9&O_4~n{Y%x>u?JItWp-U3suZ`1j7`678||Km5}sZwd+b}Tl$r4SDI
zJFNA1gnjV*RC+DX&hCmAa2&PbVU+R1Ef_Dva_f+Rg@@s{*BatmwU>O{0TvYjtB~Wo
z=DBM;hiyNoy@dH>29bY@41MCM=FPC@fJquA5vS^A;S25!d)@Q)oGS$*1j*W^k*bzl
zMDU@|kVqg$A*ov)+s|o&W^E$%FFF(X)4A-QCqzo4fg1qd*ae>noMe=?!Mp43P;tOp
zRQz;Q`l1okLeur^*|P<3$783`HwmjOmV=Z66C0l1_W0Aoex=tuucXLU+aDQU1S^UZ
z72(IY$g^KK=l-~Y$e+rnYFtlB7r-hMEy7%jurY5h4djmO2|3USYzhtoZgip5bAj^!
znci5rxG_;yPZ+6xOAB~|dd86(s#Wh2Y1b(}xH!XEY_fiLuwYmt;qOe-#ABTZZ?cLQ
z2A+Xs^!-D(QBNhzj!kr2bd&;Ysk-p|aN~XEPVzVuCTPkYa02I@n@{?Lt0K3G7n;&8
z6Lnm#(bp@(746VPKaI9zC11|S7g|)3{L=_JET1z*W3>AasR>F$ryWo7jxH*azJ>9$
zYpFCWbATQEcr%)%ix)-?uzkOLbfC|FXH!N5PIT3sSL2zJw!X2Q=X)#Cv6|pnr87W
zd=?jQsG!l6=tELz(AaZ@Yb0wAeCt>NrlX_tjAktEw~@>l^o*l~d1t|_CAGYx;^|^B
zrmO~qpQ=%C|ChnyFS~SfmjA@j`G#b*-+9UpjeyoV^HWy$R+&y?NuO7}Lrop_&SM)^rF{NDOlbTt4(
zo}ONbP=ja^GBKWAA()ZmqG7VXxGq&T5X9`ex>WoC-*Av@<7$}+s%kItq>qISXVPb9
zhOAQ*t}ay;9cu1BNA`rIKX%Ui!n6(W!tji$ZSu8phgTZ8E)a~7iVb=jThh-0harnd
z6J@MSJ(_7Xv36%}F7?u=1`&z}Pf5IZAMazYfbA%uJE1!m3LOp)AQOFl?%9{AI~vIb
zh`bc=_BTfKEZ`PFGia?QBLBRQVaj
z8u}%mB2U_~|1>`T@#`rdfxmi5J`3=#qx9Y#PoVqDD1_4jB`}EZTlgO;;jcBH|DTqF
z%Rb`gd-v|O-n%xkr&${-h(+cyCnrxwyL}`9(NBUl|M;sjk+d!p
zC*Oge$?nn_KO)A8B!vm}bE7)Tn@f92f9iEn^0{b+_AMtqbX-XVo*9hemNKfP0nL%W
zU~>gdl+RD~SfvKv5t$UfSCv&gMH$O)Q^Z1Rn8<{2&=XL^VkV9cA)=P7qt9sCd(z**ABCM*iszbDhjes%ij2nMDM4nM);qV|UEtLfI+5q@
zP%t>ae(oBCQ^L5|0@F)^HVRH2S{uyVqn#+u=9-ictkqfbH0o295jwn1sS1oCiZz_M
zam%K0b|@`~cuZLZyc1?a*>)ynm=8C{d)Ahkgmz!lOfTcTV}+zY4-rdf%(Y?`8=tZZ
zIsUadziT;bCMD#P(E0e!O<3sQ&7KCgp5f!)Z>-a5z)EInbdbs8-#@Ig-E7+WUN!Fl
zefYQyMX+g+YH&`LGtt6H4JI<_OOGj)XDK0K^ytt&&Fg02Hpl<*>hO9k?MGsTysw($
z>d;}d(Gw7D_P_n^g()rdK-q)p{<0_3UCoB`aw?Mz{5^(x%0sN%q9Jw1dG}*cJ{(g&
zGZ8qEv4lS~7a7bi$r);xSt5M1E18$ct*c|=qD+KnrVt$JJK{Qw`fJmiTiApmeJatp
z!)=e4Pf(oE2Ir2F)C!#7w&6L2`llRDwIA=5fP~Xm_c!g+oG(cYMSAH)!WiDoRv?6I
zxUEL(lz;>nMJ@oUFG7%~xAT#IK2Vs)FOc630Nm^fD?FTt62EFvMET65yN|!QH1BVs
z6QCckL&jz}VBk%P%d99l(A3+dG{6_ml;lp#{GAdr{@})p=f;0%Op+P@VvpMVPfPj#
z*5g5@MOj%{#S$s^jHd;J!V9YuBmJ358HLz1v*@i}^V)ovMnQS4N>#w$W*@b9`#B3N3D`}AMq}oDEQz7
zF}{N@;I5gKw60KEJE{optl(?o=R*4jJ!{|8wZ%_#5@yR?Jb!ecNQPscOi>GXJ}`In%GFl3^j(;y}$h9T%O
z4w-($KbN56ix=-=#>B#n*f|G$<>JGm@0sR*yn=x$ril6E)onHSTV&@jb^)zZ-Pi}L
zSFWMl=QLIEL*M4>ZZ*lsoKx}nzL(hzmT%#{msj$d$Rnx)BMcy2ZG0|wV|px4m4&&n
zN|HZySSr=TQ|>i{_9-GG#|w|PGz8+W5v-(b(3dHDsDW950fqtzm5Ggn4CxRvovWyhD+rKf!lHIETE-Z+DU1-nh-A0#a*&Ijjo%jLadHyotX4NuUc`5|*$HP+z{bzO>)CYJ5dCg{ZzZGP+MK0^_v-fCb0N
zyhYI=p1M>S1a|wkIp#-%%zo~>(g`lp*DPvn@xal61Glkai9I$zBCR*SCp!P{$Vq-E
zMlJ49_BLiTr}S(>I}_o*O_6e(N#T@g`7b@jKdG;ORa}3`CmsK5@cZ=s2zU&|E(1IX
zSv(q0*g3IN|C{jI*I%nasj)USAe4Li?tyV?qB7gzY}Jge{`&CR`mfdEv$%k(eOb&P
z=z+3$H$bT_83m)mOI1yb0zp4}rL3GMdf8vt9pErFv`>9P=ytoULL9FwmCY3&+O>WA
zc00m7*yz;--avJ+NCg?qOpw*EPVo|#ZBw|wfDckcKTUQO=eblx@!qbB-aNo6o#_d2
zD8G3N7tL7MD_oqA_2ln(@&Gy>0t2;G^3`*C>4Xwot%QJsB$aY2<`z`+fPdK8K@}sVg
z0zIbmVXP{>dE|L8hhd~}he7-*eb-pQCYt_(rDfd&8$skZQT*NNlQgd?sBWbh&)+H(
zveq-g1#J~~3go>ux|*~U`wqp2LRQw++Fd=Q`_Dwb`
zY{GL%lz^9*kdP448zEa0Hq&iqe)1hX)cf&T?Q}nbWOb$2V99)dJB<%T9w;?w8rs9-
zQBUSTQIn;s!PzKl-rxc#WKh-E=-8nDlC#h_?hSfV|lhBK&RRr*0+zv$f
z39YkWLuAq8CMscGJ`Wr_LcKBs>28XT$jL6JSX0D)?Eu(c$^Zm)4j?`__|Uq0n494A
ziYY@qy}|)tCGr=WCOSHzaW;d`z+zpLW|To=VariZ
z?|Jb%2%O6{G^#1X+cPN}kZIvM`>fi^V466fSPi1{vV*3i&UTEh{UC(Dl1B4j|aIS8I&Wgt4D
zA25Ex?ZSn;Bz@nrbweup7ar`&lu1YMk@?%ud{)$__1ZEP6*9+YkkhER;9&P`?lCQ-
zIn!GEN6PuB%c&{lVD?E>0>iuk4y7E5Kcn(MMf}i^tOlSFzOf3Vgo*snV?pKEgs@E;
zHM*OWv{}O{X(LU4en#wg8~6&izqkR{-fSCl^nEnFrFi7E4yT?ie(YFu{yzD7)x-TMo
zwuQ?M<=5qQ&K)(M{&LbdQIU$0D&Wln~3CKN&kYqFXQyH`y?7tE1TEQDAnf7GlmbI!MLsHiZ+Vo3+CM
zUulsL<9pdjqne)zqpZgZ^i51EJ}2pF6LGk*VQadN0w5cd$3VIgv?xLgSS%YKt_>GE
zQc~xU7jax&O`OkquvG@M_73Lvm{P5>y};a1)SZ`u%=ZJb!8Q!dq8h_ugU>GIM>&F#
z=G+FV9fviIMMXy!o{d0`x%1V6P$sCu#FP|g8z>Oq*5`}<9d?O!mqz%h_V^q_95y!{
z_p^|SjB-uXLQ-qe
z&Vqv%9i&fa9jXnNp4V9QYok&4{+^i!n`I=PS$}pu6AXwNm;NUQ?Z4t1VDLV-pN@Wd
Tk~bd$KZm}z+W&a(sZ0M0Kvvq9
literal 12767
zcmeHtcT`hZyLTvxol#U&q}afKfMWp>A&feMfDpujN{NbyiV&nq%fZSpC{YPyloEXD
zB19AjMF`j^5djg98U#XzNC+k2OjOeYJDKuxH6I^)xx?G_cgUrE&7hSAb?n>_en7?Ob8
z-VLv(P~E;TqP{XdXa8fq_Zd>$_G=k0vNXc)-u`JzQ~q$GqDQ6iLZfGvfnNhg4YIax
zjq%(NzGsQvtF&t799g`lqgLiW4i`vu!G{31v+&f1&Rqg?a&2SZ
zLlac-$};hXcJT?uRqD5O%LYtFiIv~f#Y#KA4TrXa)zZLO9Q^6nwfM2lBN^)l&Kh)+
zH?^ki&g>JjXc?K8AJ!jIu6S_R?|d|Esr=93u>9_nW$(Hlu2yzX&Q?{hYO)@>r~N3!
zJNT^eZjVfjTWP857gaXg;ed=R4tH)V
z2q?29^A_N6zdpgq;BY^#)BxkZBP)-?y|w)dPi~IV8}u)UOy&fYc71GJk{&4F5Q+~|
zL~(04+f2Oc8VAbYsX0UG(J$&*v5;tFhJ5+lm6sZ9SisSTP9QeS7KgINF@I%+n-w)N
zdlP=O2*bWXGiWfiV^UaDLbx{2+Q8EfnR!?15i+T=1-p?1)VFll07@yI{Kg713rh
ziik(5;STxsTjE`}uMuWdMhoaK@|M~O>>1*F!IN39zI7@a(q9NKbw7K*TSuTc08QT}
z*N&CVDM`e81p_=LSwZwtK)17$kdv}Sp$ZGu>{zoze6u2nv}!}!m?igf+jPJE0}{d*
z2x?Ig@t72*CFwXWYsao)XNRq3n?7lU9!EZpZGTJ%H2zEtOR@Nv_zbzjxY*G^-8@A^
z#?1}(oFbl5&xcGt$xo})Zs=dxtcQPzRtlpf>pNGAV$>4>#S
zOP*nf$VWhJUnU)0_$`<~oyyVDyJ5RYIs@8UpdR{}kv=+!A~L6QxLxo8^Dt}?Y|@WD
zbyz3j(MiZ#xXIOqG&>#{m(@PlYkkjSwe)meT@Ark{^GHzh>JB^Ny*aBj!xP$78Hn-
z$}fFCg-k{==Vj1REzEl2pr66)(%C~-cco^dGF>qb!6SL(p!L?5f|28kL5!L?6fRo-
zw+L1DIJNTp!3V*KruLS25+atw{X%cL7yPPhGzbJvYLz}4PUZ~vk=-a7H)VHKbgI9a
zydwD-9aroM^!H6@sFY5Y+wQU~Yw0UfAYWCm*o3Y$*lf-(sMNX9rBG8E%hd>B3n9TOkc?@xUTG5cG2)mX8`mM5_A~x=P9%iWuBfIQ(`uKCdibiahLSb*#enf{u
z@N}pG>)n)swkntHHXP!Eh^>rCEHFjQmd(rP=aAS?qp=B96lA)}45=2ShYC(sjSpX}8r}I|c5YzHQLY1n96non9-->S%OUU=F}$tLYVL6A3;{PrX!{
zqKCE{S@Leiae_Tpr_L^b$xOFi0jc^e3AqbL7u<#eMI&sM5!9K|$X+2JmMqxsAdoY1
zoacF?f%xWx&H)eOnN!k*D+UYQn5Rpqr!1;p(-a9wl(Mg;&;QI_`Ba};@YNcl3k*0}
zt`?%}OKh&kbsG;jtBMc0cLdfFMlN}`C`s`%Tm}oMIgR~b@r`7K%Z@QLsDT}7nPF&<8U7Nek08h)CAMepgWr`-GK$fPtCBq)hX>gsWiJ7h8t#~xUmE%~cA`w?GjKm#%ga^DKV{Gv;s-#l
z!bUnvEQ>-GIXX!W*}8TF;c$*zwcyd*LB-N6IJ5oiE~uULrp-=|(RPBpV1FqrC2eEL
zQe0AkTBxuA8FtUNk0JOO-|Z*Q`TriYc2vCTtK-6DxQodRI!?#!-(4PA_57D=eaLA)
zCM$8XXZ#LUE!V(l{0!c`qu9^gC{*mLgDTRRs`R1uNusV<>K=g{u5Snkm`4Kav=FeG
z&p#RMS>X}`jM?yJe^254o9MrE81LJL>7J~T!nT@HXYxRks*O({9m@9uzy29&QLv!y
z+&1g`gq>)cI3e4CW#c#@9BQ-=-EJLCLM#9&j)AjRfCVrXO$wN*tZ%uMYH?WgU$&?P`J7Mv}E0$X=5|0
zsrVwdPhF#~S;4f?P^3_`lRZ^HX^$kj_Wb%9zGsZmUagPj&3R}6?HFe{
z1yM9AzX$g4Vkc%xd!S!4|NpB&K5%22v};q%fwhsZ=u(*n)TZ>-QKI
zk6J+#ucJy*nx*Qw`ylyK#ttmcguxs}3r}iOBahrcX8P?D0YG&}vx=%mYGm#w);ZZR5O3KD&
z{~?hTo>w%GXiQr5WLKTZOovEJ3^VXvwEln{SExhWH*t^<*kcb|tzw`0M@FIQ^mwQ7
z%mfvJORcHN_;ez}6b!YisIR5s7&wSZoH-86@0*KA0a?L3>@w0%M*7`OcNqvc=*w+n
z;I;K#Glfl&q|=Dqok+4J#WHZZ)vdQb)bBeS*<NRpjU>BlrD^f2Y=9&RVFT(dvu*~0eM>suJ(bTFp*U^Vp?8pQ
zM(p}iI|6A`>7#>uDFTzJtcF4k+|0b&)Xc%z1G4fx^<8C(_XBmg5-fV8$s>ft9hBM+
zV3mJHMqv+xV4HIb7O;%arKiqze=NS5E$mbac|VxI2=x$IH{Vm5#vOX;^YOBtnO0
z2qha)b$E(UqrM+YeSqErr0w@cJl%SGQYwRPs*lI>PCnLvoWJLmlNcrgDpdpGRh@$9PhoA#2Zo`2cR7Gvl2;O)dc)S1Vhun*Y*vJ`YaeF@!xNOFT4D$qx4K
z&~owED9zZ$bMs*Jz(M_irw;f)k=3cT`Az(VB7ADdl^WfOtfQW~`8_MK=mp_Dm%1$`
zY&Zay)ae`qE6z>Hn*BP
ziEf`RVrrUZwkMfRSar-#R^sM)Z1?P86fD)wEONo)qVw}`DGo=%eu*TWUj9@Ngt@2V9d=>P00gyD4xysaaD(7#5!%f}uO6M+Pbyt_
zH*QH@43^)68PV$JFQpW|>mOTxmw4Y=;&$Gidam4-?_c_<3#!0%j2sNI)
z$wn%zp_SK~q^Wm}KGfLzXt(I)B-O^dWF~fqe#dhyunme5f?03K#A1byTMi>y7ImmS
z8*TNe4o9V4{}1oY9lPRqWh5Q7-;ekYkn*uwO5VHQB7r-_<;DEv<&?iv2mfoi$g^zj
zkiUVR&FNgj*{>h72=NzyYNk|zaEO%(9x(`%f82~>HLDnOHmGY^;_0u|xjO=DOFriL
zZ=!kQQTy~hwmk?DM(3{=tiWHzI>_G=sg)7cqMFS6&ryYb^}Vvk$_TEtB{}0}xTH(&
ziPv0m#1asZ63xmTN7Ws~x%~l`MeNlyvr=#MHsM&BV4(nA_FHk^es
z<2Y&Kno^)z{;MDDbtAnpagOk(-z|u?G8xYTd+rszm9=%vA>N9vVnmZ*UC?`XdZYKfg80S%pcD2I__6js1{-y3hqt
zqP1U}@GN;QJH%n~onim*j8eWtNXPPwMwg3r`;k@VVS3|EXoBW^@9Ydgx~GCDeA3(_
zoEqaj;XcCNIqr7L-l}Le)v9i;sxEI6PUn`Y%%N+Oj9)+@P3=H*UtJYDWL2Tvd&l|A
zV(o)gOx%x_6jAuj>=sIGTpQ$5m|MQK<~wz7vJJcW1QAA&s<->Ds3r
z4Yj5ImHl-&@K@*WSDO6$VdJs)(4N|dAo-yJ
z{A{cIBo6=~{2k;;-dLWb52d$^#MtTGMu%go-MfO*@>QOEH~HUSxAZ>l`upfC4Gx&s
zN93YB#w(D|v$msOj&?Win|SZ&7dI7_Gf%)_#ItFs9`(XC7sg%aWGRX$gx5f77M4Mx
zg8oSIhbDe{1U$!-#&~@S4R){8eN`9`%BIEN+YUm^YT*aM-Ui~MS^LXM5@5mF6kZQ2pZ-m(d=rRmflssI$VO*2n>M0=A)GL+l6A^nBl%J2n9sEA>&C;9ibVFF%ToKm4pkm8m;*Onq
zz%JJQ*U=#WpB+;8{DG4?4jEwy8)0U?C|rscPdzL8e2+_WD)}2xCxDc&4Ge@?1(6?7
zF1?Kf50v7Bun*0%dc(RaVu(l*%4;rKzdY$`al*Yw4-d#57?bY>(?2VxKmVKBe9kJz
zR43wp)#m@*%J>gz)1R>CYEQ@D3sqzPE0?j70tsnw1)=g5SU#+{3w(VnruRdK8aPHbs5KX1Ia43AMu%gbnvn(u{lu>(iwdeb3{G
zRRrJVw**e_xU#XOJBDz#Gcoz!Wz-%SkM;N;|NWQE7i3rLws*=zwT=5^ZR+
zu*F-6ph>xrN>l$vasF~q0J+F@)J2)>tn>igsb1zc>O$`P5>dsNg|RdVnjl~SzD#wm
zFmpK9sl-2F{Y>@AxGDnNj|CH!T|*_4_Tc_+ervxo)A>dC{KAD!55t^7N!uLxa?r`0
zFFoH_>v%e_ko}Es9zU8=A)mfl;?rJht>}K?QzDwnm1$!`sG$@=;yAO1LQr=e;FC3>rq**8;Zz;#AEfc^Y$KWdS|egD|sH=8W+Ed0%VztT6T3$a|Ysueqfic3yf-&g^BsiX`Z
ztHauW@xw=RTbB&9N!yBC;hx7EK?Mps@k=uiVq)eG^qR6(YiJx0pI5|Hb!&T>;duhA
zEbftpWlw*0qHSZ#!?K7nvRljK=Hc-FS*R~rr9refKmU1&=>e>a+
zkHr2%3Z%fbtFfEq!sX&zoz6Up$9G)bv#qg1R9Q@wxi7^IWFU*p14ML=Tgr>gr8>O|
zv_6Q`h0HeRQcL|@v^GNGNd_{*K*WqhLMTcMkvJF^pTSUf@)C$t+nULpYl*#5OuDLc
zvAp9z(r!rO&d%!kq|yXq1I~QD^rIaK;R0qLaNIZh3!d%Mu6lH3sHkv9yV+OdR
z_JrhV)QSmljVDg9#jX7<1g^}D<=@YmUoSlQ_#1}EEW@z0%1^iW3D>enr82urVl`h)
ze`=SsI0uzrwMlS)F?N5b6vn>$%po>{LXq^JERUaMo;nNdNbpnT($C852GD+bLgO%S
zX_ldK8Jl0p96qWlV;O_M>8M1DjcB>)zbj)1+F1X<9VdniFSC_=O)taU?FXIv>UT_9
zhoYHwCIhjzqaw#XF;F2J6}F&4cxVo)8R6Ckgn6E3`wxY1ZAUyyHw=exj|fN~(VI}H
z@o>k0-5nIshr25!c`Y|04VkCJW#)Ju+j)*pnF{`msR(8o`7(_Sbb>hb(_krg)1gW+b2N2x6AHBrnE3Yz?+kf>Z`bR@(LkFs^@o
zLGwqjjgHalI2N8?M8G7QVC|lMZ4Av$lZuvGgwlY3zJgP{7vN0PX{{tC?e4R^x)2d)Eq5pcNj{G|4=Wy3SUrOxS@xx3yOXk6c9R{kP%
zZhGknQdTLjTth$Z%EnG%rB&|KS-g1hO;48b9z#~vXL6Il$&)89N(!DY^`1cwF8aaN
z@>Gydu%KFt18iq3NeEiul3F4~1Y*I4qP%+bd@2FCW@N~6f9KL~2q2wXoy}T#kYL~rI?1|Fn
z>qHk+qRB7f_PCdQ7nv*2rOMkhM$Z^GFDfbUK>7vRxw(
zWggZEOQpTYgq}HShoxH2^LmH(8ZqoTRuCef4-6Cd9sWK_F79uOmm0>5p)=@*Cdji>
zycqK%&7J-(qCs+}fjug6=d&}1&enbaWW1FzJ9?)`T(^{8k~?(PY4_apm`X6RvA+ufsgAK{4wA4{R$}v{d<4S0ORo8q#JNVwk-Hd7KQdeda(&^XYWW99q
z>6$s!B#ar+L~6&rnKmo(jGIZ4d&P{+t0uHODxfZu|JLDwhy<7%!B~A9(D2<4D8}{Z
z`%3boxoICJ%M_B-l6QOTL+8GIeUm_>2%>qs8M2N(8*2Z&g7y#_u?x%mGT3*+MaH91`*WT%R0#8zHsHN$_8sNlRv(-
zoB+Qe_>RvancgT7I<4nexZSVw7Obr2xHY=K{2X{4GG#I@o5R>A(sdnLnRHV@F&3aD
z#xAyOp7WZfe(C^mSEUQOS5g49XImMrYyGh(}mZ*f_2hegLyh7^{gQ4$GaOF}H
zUBl{B4O&a4_=swuhhR(1Z)d!b#maiyh)CP-P)FYkv8JY)AFJz7lV}qBSY`CM7d$)R
z4FZN2(&TWh1ubHtW7qt~Mcq`2Fj+Hv&LrN#@a}7j?;CatLJB|2(P3D$W^z`)9$8oRADb$QG+0Lt#={ZZfnhn`f
zhur`J#E~ek#C@!6(CBz9Z?9aKf$0Bk7rUsuI$FyiQ0D%TOziPYQ(U%k`S5RHO-)?O
zwc26Xt;@hJwq>Z~+h)F0pQ6j<1zLJhl%q8zFIx%1f8laISYFP(_fhnzjFrY}m{#7?Q!6DHV5mHXgtw$wYy1
z{mYO4#XCc#dicIAO`$xW3H0qL7n8v1Tp)?YAf?wC1tC)O4K!XWii(_g9UVEE;lM}`
zlo!=&V{=}-+a&fBkiRwiM19e``X>ht1kUkld6g<_PagP`2#*2
zVHfDAjnEA_B9#FUH|XI)?J99mrmZONCvHm+~gser4>{Rd8W+qNa(7ERgO
zKY1iBNKun~VUfArWpm>?y*0#6OoT8n0NkAp+;Hpj0Q;e%>?g*YrN9(t7WE{n9J^c%
zf+unr(+P3y#|`{SZURR4AMY^Q1Bl#RvjS7boo*lR>f#of6PBr)b{GW-2fNF!%W>M1
z8i$^JQ7rIiKT&X{_0!uQm`{ZRY&(6K1RJ+23%{|ro)L36Twp8}cQ2NKNgvckI03Xm
zm-*^j*G#NUdgIfGuw-?sD0M>F@aE^V)P?nf>2F$g)h&cn7XH
zX>@fuM({y2$;+%rS~CCUlNHBmK5Z`4)l-#hot&KHa2`fcj$y;YNC`mgv|lHn!-9U_
zB;f#|6sJj;X*v=w48?`*<7@K-*wX|@!B)(oyw`;be`*KQmf-+ab^X3;^TF+z1GP
zLyj@z*n1Ya8bSEf4|R3)WOHh)L4s^F45urX_YIUiEK|hYcN48F?5(8I!L&`s6G-5P
zuxm)yHFM2z&Ip<2aX(0yJdSBidx3{5g;KT;{`STdC~Yd2Pj4YXp@9j1ke^Mvsf-PT
zL|d7OeMlBvV0B+QhCPkIn2F9ERVx(Y&y>Qn7rT~UjP6TTk9iZW7@$U({fhCX&6Lw~
zU?42nT>U2b+-wGtMnJ2#Xh@2foB3cP*{QNc9fJqEKW850>7>#Kc-IUVdmCMc0vhl<
zyMFA->H!1fRXy0mX~kl}&JyqAL+-X=4;&GmK1$X)=g>W`P?iB@LVc~dEeAKHaz1B6J(P@X*YiXa5U?c^eC-M4Yf
z!+tqVzR@%T_4oImvk5+AEUtLoH^3H`Fw5XB-~qv+L~fNyJrCX_CA56GM%ZDc#kP1&
zx%*XI6Z~A5to;2;pt%%QU>AnlQR+T*9LqEQM@o8kQH2gUr(uoAEsEfoIE$Zc4(9AX
HbL0O2NJN>k
diff --git a/interface/skin.dmf b/interface/skin.dmf
index 7502d0e1a5c..74dfdb2771d 100644
--- a/interface/skin.dmf
+++ b/interface/skin.dmf
@@ -349,3 +349,26 @@ window "statwindow"
anchor2 = 100,100
is-visible = false
saved-params = ""
+<<<<<<< HEAD
+=======
+
+window "tgui_say"
+ elem "tgui_say"
+ type = MAIN
+ pos = 848,500
+ size = 231x30
+ anchor1 = 50,50
+ anchor2 = 50,50
+ is-visible = false
+ saved-params = ""
+ statusbar = false
+ can-minimize = false
+ elem "browser"
+ type = BROWSER
+ pos = 0,0
+ size = 231x30
+ anchor1 = 0,0
+ anchor2 = 0,0
+ saved-params = ""
+
+>>>>>>> acfa5e4fdd0 (TGUI Say: Upgrades chat input with modern features (#67116))
diff --git a/modular_skyrat/master_files/code/modules/mob/living/sillicon/robot.dm b/modular_skyrat/master_files/code/modules/mob/living/sillicon/robot.dm
index 407ebb90d64..59302960037 100644
--- a/modular_skyrat/master_files/code/modules/mob/living/sillicon/robot.dm
+++ b/modular_skyrat/master_files/code/modules/mob/living/sillicon/robot.dm
@@ -1,23 +1,3 @@
-// Typing indicator icon change
-/mob/living/silicon/robot/set_typing_indicator(state, emote)
- var/static/mutable_appearance/type_indicator = mutable_appearance('modular_skyrat/modules/indicators/icons/typing_indicator.dmi', "borg0", CHAT_INDICATOR_LAYER)
- var/static/mutable_appearance/emote_indicator = mutable_appearance('modular_skyrat/modules/indicators/icons/emote_indicator.dmi', "borg0", CHAT_INDICATOR_LAYER)
- typing_indicator = state
-
- if(typing_indicator)
-
- // Tallborg stuff
- if((!robot_resting) && model && model.model_features && (R_TRAIT_TALL in model.model_features))
- type_indicator.pixel_y = 16
- emote_indicator.pixel_y = 16
- else
- type_indicator.pixel_y = 0
- emote_indicator.pixel_y = 0
- add_overlay((emote ? emote_indicator : type_indicator))
-
- else
- regenerate_icons()
-
// Smoke particle effect for heavy-duty cyborgs
/datum/component/robot_smoke
diff --git a/modular_skyrat/modules/indicators/code/typing_indicator.dm b/modular_skyrat/modules/indicators/code/typing_indicator.dm
deleted file mode 100644
index 3a249de6681..00000000000
--- a/modular_skyrat/modules/indicators/code/typing_indicator.dm
+++ /dev/null
@@ -1,41 +0,0 @@
-GLOBAL_VAR_INIT(typing_indicator_overlay, mutable_appearance('modular_skyrat/modules/indicators/icons/typing_indicator.dmi', "default0", CHAT_INDICATOR_LAYER))
-GLOBAL_VAR_INIT(emote_indicator_overlay, mutable_appearance('modular_skyrat/modules/indicators/icons/emote_indicator.dmi', "default0", CHAT_INDICATOR_LAYER))
-
-/mob
- var/typing_indicator = FALSE
-
-/mob/proc/set_typing_indicator(state, emote)
- typing_indicator = state
- if(typing_indicator)
- if(emote)
- add_overlay(GLOB.emote_indicator_overlay)
- else
- add_overlay(GLOB.typing_indicator_overlay)
- else
- cut_overlay(GLOB.typing_indicator_overlay)
- cut_overlay(GLOB.emote_indicator_overlay)
-
-/mob/living/key_down(_key, client/user)
- if(!typing_indicator && stat == CONSCIOUS)
- var/list/binds = user.prefs?.key_bindings_by_key[_key]
- if(binds)
- if("Say" in binds)
- set_typing_indicator(TRUE)
- if("Me" in binds)
- set_typing_indicator(TRUE, TRUE)
- return ..()
-
-/proc/animate_speechbubble(image/I, list/show_to, duration)
- var/matrix/M = matrix()
- M.Scale(0,0)
- I.transform = M
- I.alpha = 0
- for(var/client/C in show_to)
- C.images += I
- animate(I, transform = 0, alpha = 255, time = 5, easing = ELASTIC_EASING)
- sleep(duration-5)
- animate(I, alpha = 0, time = 5, easing = EASE_IN)
- sleep(20)
- for(var/client/C in show_to)
- C.images -= I
-
diff --git a/modular_skyrat/modules/server_overflow/code/client_procs.dm b/modular_skyrat/modules/server_overflow/code/client_procs.dm
index 431bc139dfb..a70d66c9b43 100644
--- a/modular_skyrat/modules/server_overflow/code/client_procs.dm
+++ b/modular_skyrat/modules/server_overflow/code/client_procs.dm
@@ -21,7 +21,8 @@
return FALSE
return TRUE
-/mob/dead/new_player/proc/connect_to_second_server(choice)
+/mob/dead/new_player/proc/connect_to_second_server()
+ var/choice = tgui_alert(src, "The server is currently experiencing high demand, please consider joining our secondary server.", "High Demand", list("Stay here", "Connect me!"))
if(!client)
return
if(choice != "Send me there")
@@ -32,3 +33,4 @@
message_admins("WARNING: Overflow server IP not set!")
return
client << link(overflow_server_ip)
+
diff --git a/tgstation.dme b/tgstation.dme
index 8c8ae503ade..211831b03f6 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -164,6 +164,7 @@
#include "code\__DEFINES\span.dm"
#include "code\__DEFINES\spatial_gridmap.dm"
#include "code\__DEFINES\species_clothing_paths.dm"
+#include "code\__DEFINES\speech_channels.dm"
#include "code\__DEFINES\speech_controller.dm"
#include "code\__DEFINES\stat.dm"
#include "code\__DEFINES\stat_tracking.dm"
@@ -4344,10 +4345,6 @@
#include "code\modules\tgui\states.dm"
#include "code\modules\tgui\status_composers.dm"
#include "code\modules\tgui\tgui.dm"
-#include "code\modules\tgui\tgui_alert.dm"
-#include "code\modules\tgui\tgui_input_list.dm"
-#include "code\modules\tgui\tgui_input_number.dm"
-#include "code\modules\tgui\tgui_input_text.dm"
#include "code\modules\tgui\tgui_window.dm"
#include "code\modules\tgui\states\admin.dm"
#include "code\modules\tgui\states\always.dm"
@@ -4370,6 +4367,13 @@
#include "code\modules\tgui\states\reverse_contained.dm"
#include "code\modules\tgui\states\self.dm"
#include "code\modules\tgui\states\zlevel.dm"
+#include "code\modules\tgui_input\alert.dm"
+#include "code\modules\tgui_input\list.dm"
+#include "code\modules\tgui_input\number.dm"
+#include "code\modules\tgui_input\text.dm"
+#include "code\modules\tgui_input\say_modal\modal.dm"
+#include "code\modules\tgui_input\say_modal\speech.dm"
+#include "code\modules\tgui_input\say_modal\typing.dm"
#include "code\modules\tgui_panel\audio.dm"
#include "code\modules\tgui_panel\external.dm"
#include "code\modules\tgui_panel\telemetry.dm"
@@ -5392,7 +5396,6 @@
#include "modular_skyrat\modules\indicators\code\combat_indicator.dm"
#include "modular_skyrat\modules\indicators\code\sealed.dm"
#include "modular_skyrat\modules\indicators\code\ssd_indicator.dm"
-#include "modular_skyrat\modules\indicators\code\typing_indicator.dm"
#include "modular_skyrat\modules\inflatables\code\inflatable.dm"
#include "modular_skyrat\modules\interaction_menu\code\click.dm"
#include "modular_skyrat\modules\interaction_menu\code\interaction_component.dm"
diff --git a/tgui/packages/common/timer.js b/tgui/packages/common/timer.js
index 1177071b9c6..7d89e935b9b 100644
--- a/tgui/packages/common/timer.js
+++ b/tgui/packages/common/timer.js
@@ -28,11 +28,31 @@ export const debounce = (fn, time, immediate = false) => {
};
};
+/**
+ * Returns a function, that, when invoked, will only be triggered at most once
+ * during a given window of time.
+ */
+export const throttle = (fn, time) => {
+ let previouslyRun, queuedToRun;
+ return function invokeFn(...args) {
+ const now = Date.now();
+ queuedToRun = clearTimeout(queuedToRun);
+ if (!previouslyRun || now - previouslyRun >= time) {
+ fn.apply(null, args);
+ previouslyRun = now;
+ } else {
+ queuedToRun = setTimeout(
+ invokeFn.bind(null, ...args),
+ time - (now - previouslyRun)
+ );
+ }
+ };
+};
+
/**
* Suspends an asynchronous function for N milliseconds.
*
* @param {number} time
*/
-export const sleep = time => (
- new Promise(resolve => setTimeout(resolve, time))
-);
+export const sleep = (time) =>
+ new Promise((resolve) => setTimeout(resolve, time));
diff --git a/tgui/packages/tgui-say/components/dragzone.tsx b/tgui/packages/tgui-say/components/dragzone.tsx
new file mode 100644
index 00000000000..95ebfa1a854
--- /dev/null
+++ b/tgui/packages/tgui-say/components/dragzone.tsx
@@ -0,0 +1,20 @@
+import { dragStartHandler } from 'tgui/drag';
+import { DragzoneProps } from '../types';
+
+/** Creates a draggable edge. Props Req: Location */
+export const Dragzone = (props: Partial) => {
+ const { theme } = props;
+ if (!theme) return null;
+ const direction
+ = (props.top && 'top')
+ || (props.right && 'right')
+ || (props.bottom && 'bottom')
+ || (props.left && 'left');
+
+ return (
+
+ );
+};
diff --git a/tgui/packages/tgui-say/constants/index.tsx b/tgui/packages/tgui-say/constants/index.tsx
new file mode 100644
index 00000000000..145d9985c36
--- /dev/null
+++ b/tgui/packages/tgui-say/constants/index.tsx
@@ -0,0 +1,73 @@
+/** Radio channels */
+export const CHANNELS = ['Say', 'Radio', 'Me', 'OOC'] as const;
+
+/** Window sizes in pixels */
+export const WINDOW_SIZES = {
+ small: 30,
+ medium: 50,
+ large: 70,
+ width: 231,
+} as const;
+
+/** Line lengths for autoexpand */
+export const LINE_LENGTHS = {
+ small: 20,
+ medium: 35,
+} as const;
+
+/**
+ * Radio prefixes.
+ * Contains the properties:
+ * id - string. css class identifier.
+ * label - string. button label.
+ */
+export const RADIO_PREFIXES = {
+ ':a ': {
+ id: 'hive',
+ label: 'Hive',
+ },
+ ':b ': {
+ id: 'binary',
+ label: '0101',
+ },
+ ':c ': {
+ id: 'command',
+ label: 'Cmd',
+ },
+ ':e ': {
+ id: 'engi',
+ label: 'Engi',
+ },
+ ':m ': {
+ id: 'medical',
+ label: 'Med',
+ },
+ ':n ': {
+ id: 'science',
+ label: 'Sci',
+ },
+ ':o ': {
+ id: 'ai',
+ label: 'AI',
+ },
+ ':s ': {
+ id: 'security',
+ label: 'Sec',
+ },
+ ':t ': {
+ id: 'syndicate',
+ label: 'Syndi',
+ },
+ ':u ': {
+ id: 'supply',
+ label: 'Supp',
+ },
+ ':v ': {
+ id: 'service',
+ label: 'Svc',
+ },
+ ':y ': {
+ id: 'centcom',
+ label: 'CCom',
+ },
+} as const;
diff --git a/tgui/packages/tgui-say/handlers/arrowKeys.tsx b/tgui/packages/tgui-say/handlers/arrowKeys.tsx
new file mode 100644
index 00000000000..a736c9d860e
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/arrowKeys.tsx
@@ -0,0 +1,15 @@
+import { KEY_DOWN, KEY_UP } from 'common/keycodes';
+import { getHistoryLength } from '../helpers';
+import { Modal } from '../types';
+
+/** Increments the chat history counter, looping through entries */
+export const handleArrowKeys = function (this: Modal, direction: number) {
+ const { historyCounter } = this.fields;
+ if (direction === KEY_UP && historyCounter < getHistoryLength()) {
+ this.fields.historyCounter++;
+ this.events.onViewHistory();
+ } else if (direction === KEY_DOWN && historyCounter > 0) {
+ this.fields.historyCounter--;
+ this.events.onViewHistory();
+ }
+};
diff --git a/tgui/packages/tgui-say/handlers/backspaceDelete.tsx b/tgui/packages/tgui-say/handlers/backspaceDelete.tsx
new file mode 100644
index 00000000000..340b4132b09
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/backspaceDelete.tsx
@@ -0,0 +1,22 @@
+import { CHANNELS } from '../constants';
+import { Modal } from '../types';
+
+/**
+ * 1. Resets history if editing a message
+ * 2. Backspacing while empty resets any radio subchannels
+ * 3. Ensures backspace and delete calculate window size
+ */
+export const handleBackspaceDelete = function (this: Modal) {
+ const { buttonContent, channel } = this.state;
+ const { radioPrefix, value } = this.fields;
+ // User is on a chat history message
+ if (typeof buttonContent === 'number') {
+ this.fields.historyCounter = 0;
+ this.setState({ buttonContent: CHANNELS[channel] });
+ }
+ if (!value.length && radioPrefix) {
+ this.fields.radioPrefix = '';
+ this.setState({ buttonContent: CHANNELS[channel] });
+ }
+ this.events.onSetSize(value.length);
+};
diff --git a/tgui/packages/tgui-say/handlers/click.tsx b/tgui/packages/tgui-say/handlers/click.tsx
new file mode 100644
index 00000000000..e25b3385e8d
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/click.tsx
@@ -0,0 +1,9 @@
+import { Modal } from '../types';
+
+/**
+ * User clicks the channel button.
+ * Simulates the tab key.
+ */
+export const handleClick = function (this: Modal) {
+ this.events.onIncrementChannel();
+};
diff --git a/tgui/packages/tgui-say/handlers/componentMount.tsx b/tgui/packages/tgui-say/handlers/componentMount.tsx
new file mode 100644
index 00000000000..03f57120c45
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/componentMount.tsx
@@ -0,0 +1,23 @@
+import { CHANNELS } from '../constants';
+import { windowLoad, windowOpen } from '../helpers';
+import { Modal } from '../types';
+
+/** Attach listeners, sets window size just in case */
+export const handleComponentMount = function (this: Modal) {
+ Byond.subscribeTo('props', (data) => {
+ this.fields.maxLength = data.maxLength;
+ this.fields.lightMode = !!data.lightMode;
+ });
+ Byond.subscribeTo('force', () => {
+ this.events.onForce();
+ });
+ Byond.subscribeTo('open', (data) => {
+ const channel = CHANNELS.indexOf(data.channel) || 0;
+ this.setState({ buttonContent: CHANNELS[channel], channel });
+ setTimeout(() => {
+ this.fields.innerRef.current?.focus();
+ }, 1);
+ windowOpen(CHANNELS[channel]);
+ });
+ windowLoad();
+};
diff --git a/tgui/packages/tgui-say/handlers/componentUpdate.tsx b/tgui/packages/tgui-say/handlers/componentUpdate.tsx
new file mode 100644
index 00000000000..cc57c53873d
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/componentUpdate.tsx
@@ -0,0 +1,6 @@
+import { Modal } from '../types';
+
+/** After updating the input value, sets back to false */
+export const handleComponentUpdate = function (this: Modal) {
+ this.setState({ edited: false });
+};
diff --git a/tgui/packages/tgui-say/handlers/enter.tsx b/tgui/packages/tgui-say/handlers/enter.tsx
new file mode 100644
index 00000000000..d181a77fd1b
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/enter.tsx
@@ -0,0 +1,23 @@
+import { CHANNELS } from '../constants';
+import { storeChat, windowClose } from '../helpers';
+import { Modal } from '../types';
+
+/** User presses enter. Closes if no value. */
+export const handleEnter = function (
+ this: Modal,
+ event: KeyboardEvent,
+ value: string
+) {
+ const { channel } = this.state;
+ const { maxLength, radioPrefix } = this.fields;
+ event.preventDefault();
+ if (value && value.length < maxLength) {
+ storeChat(value);
+ Byond.sendMessage('entry', {
+ channel: CHANNELS[channel],
+ entry: channel === 0 ? radioPrefix + value : value,
+ });
+ }
+ this.events.onReset();
+ windowClose();
+};
diff --git a/tgui/packages/tgui-say/handlers/escape.tsx b/tgui/packages/tgui-say/handlers/escape.tsx
new file mode 100644
index 00000000000..7c2527fab3c
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/escape.tsx
@@ -0,0 +1,8 @@
+import { windowClose } from '../helpers';
+import { Modal } from '../types';
+
+/** User presses escape, closes the window */
+export const handleEscape = function (this: Modal) {
+ this.events.onReset();
+ windowClose();
+};
diff --git a/tgui/packages/tgui-say/handlers/force.tsx b/tgui/packages/tgui-say/handlers/force.tsx
new file mode 100644
index 00000000000..3bb11037c77
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/force.tsx
@@ -0,0 +1,19 @@
+import { CHANNELS, WINDOW_SIZES } from '../constants';
+import { windowSet } from '../helpers';
+import { Modal } from '../types';
+
+/** Sends the current input to byond and purges it */
+export const handleForce = function (this: Modal) {
+ const { channel, size } = this.state;
+ const { radioPrefix, value } = this.fields;
+ if (value && channel < 2) {
+ this.timers.forceDebounce({
+ channel: CHANNELS[channel],
+ entry: channel === 0 ? radioPrefix + value : value,
+ });
+ this.events.onReset(channel);
+ if (size !== WINDOW_SIZES.small) {
+ windowSet();
+ }
+ }
+};
diff --git a/tgui/packages/tgui-say/handlers/incrementChannel.tsx b/tgui/packages/tgui-say/handlers/incrementChannel.tsx
new file mode 100644
index 00000000000..6c005d0c63c
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/incrementChannel.tsx
@@ -0,0 +1,32 @@
+import { CHANNELS } from '../constants';
+import { Modal } from '../types';
+
+/**
+ * Increments the channel or resets to the beginning of the list.
+ * If the user switches between IC/OOC, messages Byond to toggle thinking
+ * indicators.
+ */
+export const handleIncrementChannel = function (this: Modal) {
+ const { channel } = this.state;
+ const { radioPrefix } = this.fields;
+ if (radioPrefix === ':b ') {
+ this.timers.channelDebounce({ mode: true });
+ }
+ this.fields.radioPrefix = '';
+ if (channel === CHANNELS.length - 1) {
+ this.timers.channelDebounce({ mode: true });
+ this.setState({
+ buttonContent: CHANNELS[0],
+ channel: 0,
+ });
+ } else {
+ if (channel === 2) {
+ // Disables thinking indicator for OOC channel
+ this.timers.channelDebounce({ mode: false });
+ }
+ this.setState({
+ buttonContent: CHANNELS[channel + 1],
+ channel: channel + 1,
+ });
+ }
+};
diff --git a/tgui/packages/tgui-say/handlers/index.tsx b/tgui/packages/tgui-say/handlers/index.tsx
new file mode 100644
index 00000000000..b400c82aa72
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/index.tsx
@@ -0,0 +1,41 @@
+import { handleArrowKeys } from './arrowKeys';
+import { handleBackspaceDelete } from './backspaceDelete';
+import { handleComponentMount } from './componentMount';
+import { handleComponentUpdate } from './componentUpdate';
+import { handleClick } from './click';
+import { handleEnter } from './enter';
+import { handleEscape } from './escape';
+import { handleForce } from './force';
+import { handleIncrementChannel } from './incrementChannel';
+import { handleInput } from './input';
+import { handleKeyDown } from './keyDown';
+import { handleRadioPrefix } from './radioPrefix';
+import { handleReset } from './reset';
+import { handleSetSize } from './setSize';
+import { handleViewHistory } from './viewHistory';
+import { Modal } from '../types';
+
+/**
+ * Maps all TGUI say events with their associated handlers.
+ *
+ * return -- object: events
+ */
+export const eventHandlerMap = (parent: Modal): Modal["events"] => {
+ return {
+ onArrowKeys: handleArrowKeys.bind(parent),
+ onBackspaceDelete: handleBackspaceDelete.bind(parent),
+ onClick: handleClick.bind(parent),
+ onComponentMount: handleComponentMount.bind(parent),
+ onComponentUpdate: handleComponentUpdate.bind(parent),
+ onEnter: handleEnter.bind(parent),
+ onEscape: handleEscape.bind(parent),
+ onForce: handleForce.bind(parent),
+ onIncrementChannel: handleIncrementChannel.bind(parent),
+ onInput: handleInput.bind(parent),
+ onKeyDown: handleKeyDown.bind(parent),
+ onRadioPrefix: handleRadioPrefix.bind(parent),
+ onReset: handleReset.bind(parent),
+ onSetSize: handleSetSize.bind(parent),
+ onViewHistory: handleViewHistory.bind(parent),
+ };
+};
diff --git a/tgui/packages/tgui-say/handlers/input.tsx b/tgui/packages/tgui-say/handlers/input.tsx
new file mode 100644
index 00000000000..553f1fa95ef
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/input.tsx
@@ -0,0 +1,11 @@
+import { Modal } from '../types';
+
+/**
+ * Grabs input and sets size, force values etc.
+ * Input value only triggers a rerender on setEdited.
+ */
+export const handleInput = function (this: Modal, _, value: string) {
+ this.fields.value = value;
+ this.events.onRadioPrefix();
+ this.events.onSetSize(value.length);
+};
diff --git a/tgui/packages/tgui-say/handlers/keyDown.tsx b/tgui/packages/tgui-say/handlers/keyDown.tsx
new file mode 100644
index 00000000000..1e554f33917
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/keyDown.tsx
@@ -0,0 +1,35 @@
+import { KEY_BACKSPACE, KEY_DELETE, KEY_DOWN, KEY_TAB, KEY_UP } from 'common/keycodes';
+import { isAlphanumeric, getHistoryLength } from '../helpers';
+import { Modal } from '../types';
+
+/**
+ * Handles other key events.
+ * TAB - Changes channels.
+ * UP/DOWN - Sets history counter and input value.
+ * BKSP/DEL - Resets history counter and checks window size.
+ * TYPING - When users key, it tells byond that it's typing.
+ */
+export const handleKeyDown = function (this: Modal, event: KeyboardEvent) {
+ const { channel } = this.state;
+ const { radioPrefix } = this.fields;
+ if (!event.keyCode) {
+ return; // Really doubt it, but...
+ }
+ if (isAlphanumeric(event.keyCode)) {
+ if (channel !== 3 && radioPrefix !== ':b ') {
+ this.timers.typingThrottle();
+ }
+ }
+ if (event.keyCode === KEY_UP || event.keyCode === KEY_DOWN) {
+ if (getHistoryLength()) {
+ this.events.onArrowKeys(event.keyCode);
+ }
+ }
+ if (event.keyCode === KEY_DELETE || event.keyCode === KEY_BACKSPACE) {
+ this.events.onBackspaceDelete();
+ }
+ if (event.keyCode === KEY_TAB) {
+ this.events.onIncrementChannel();
+ event.preventDefault();
+ }
+};
diff --git a/tgui/packages/tgui-say/handlers/radioPrefix.tsx b/tgui/packages/tgui-say/handlers/radioPrefix.tsx
new file mode 100644
index 00000000000..21feda26c93
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/radioPrefix.tsx
@@ -0,0 +1,34 @@
+import { RADIO_PREFIXES } from '../constants';
+import { Modal } from '../types';
+
+/**
+ * Gets any channel prefixes from the chat bar
+ * and changes to the corresponding radio subchannel.
+ *
+ * Exemptions: Channel is OOC, value is too short,
+ * Not a valid radio pref, or value is already the radio pref.
+ */
+export const handleRadioPrefix = function (this: Modal) {
+ const { channel } = this.state;
+ const { radioPrefix, value } = this.fields;
+ if (channel > 1 || value.length < 3) {
+ return;
+ }
+ const nextPrefix = value.slice(0, 3)?.toLowerCase();
+ if (!RADIO_PREFIXES[nextPrefix] || radioPrefix === nextPrefix) {
+ return;
+ }
+ this.fields.value = value.slice(3);
+ // Binary is a "secret" channel
+ if (nextPrefix === ':b ') {
+ Byond.sendMessage('thinking', { mode: false });
+ } else if (radioPrefix === ':b ' && nextPrefix !== ':b ') {
+ Byond.sendMessage('thinking', { mode: true });
+ }
+ this.fields.radioPrefix = nextPrefix;
+ this.setState({
+ buttonContent: RADIO_PREFIXES[nextPrefix]?.label,
+ channel: 0,
+ edited: true,
+ });
+};
diff --git a/tgui/packages/tgui-say/handlers/reset.tsx b/tgui/packages/tgui-say/handlers/reset.tsx
new file mode 100644
index 00000000000..ffe5215eb43
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/reset.tsx
@@ -0,0 +1,21 @@
+import { CHANNELS, WINDOW_SIZES } from '../constants';
+import { valueExists } from '../helpers';
+import { Modal } from '../types';
+
+/**
+ * Resets window to default parameters.
+ *
+ * Parameters:
+ * channel - Optional. Sets the channel and thus the color scheme.
+ */
+export const handleReset = function (this: Modal, channel?: number) {
+ this.fields.historyCounter = 0;
+ this.fields.radioPrefix = '';
+ this.fields.value = '';
+ this.setState({
+ buttonContent: valueExists(channel) ? CHANNELS[channel!] : '',
+ channel: valueExists(channel) ? channel! : -1,
+ edited: true,
+ size: WINDOW_SIZES.small,
+ });
+};
diff --git a/tgui/packages/tgui-say/handlers/setSize.tsx b/tgui/packages/tgui-say/handlers/setSize.tsx
new file mode 100644
index 00000000000..ba8ccc56278
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/setSize.tsx
@@ -0,0 +1,22 @@
+import { LINE_LENGTHS, WINDOW_SIZES } from '../constants';
+import { windowSet } from '../helpers';
+import { Modal } from '../types';
+
+/** Adjusts window sized based on event.target.value */
+export const handleSetSize = function (this: Modal, value: number) {
+ const { size } = this.state;
+ if (value > LINE_LENGTHS.medium && size !== WINDOW_SIZES.large) {
+ this.setState({ size: WINDOW_SIZES.large });
+ windowSet(WINDOW_SIZES.large);
+ } else if (
+ value <= LINE_LENGTHS.medium
+ && value > LINE_LENGTHS.small
+ && size !== WINDOW_SIZES.medium
+ ) {
+ this.setState({ size: WINDOW_SIZES.medium });
+ windowSet(WINDOW_SIZES.medium);
+ } else if (value <= LINE_LENGTHS.small && size !== WINDOW_SIZES.small) {
+ this.setState({ size: WINDOW_SIZES.small });
+ windowSet(WINDOW_SIZES.small);
+ }
+};
diff --git a/tgui/packages/tgui-say/handlers/viewHistory.tsx b/tgui/packages/tgui-say/handlers/viewHistory.tsx
new file mode 100644
index 00000000000..bfbf5aa3122
--- /dev/null
+++ b/tgui/packages/tgui-say/handlers/viewHistory.tsx
@@ -0,0 +1,24 @@
+import { CHANNELS } from '../constants';
+import { getHistoryAt, getHistoryLength } from '../helpers';
+import { Modal } from '../types';
+
+/** Sets the input value to chat history at index historyCounter. */
+export const handleViewHistory = function (this: Modal) {
+ const { channel } = this.state;
+ const { historyCounter } = this.fields;
+ if (historyCounter > 0 && getHistoryLength()) {
+ this.fields.value = getHistoryAt(historyCounter);
+ if (channel < 2) {
+ this.timers.typingThrottle();
+ }
+ this.setState({ buttonContent: historyCounter, edited: true });
+ this.events.onSetSize(0);
+ } else {
+ this.fields.value = '';
+ this.setState({
+ buttonContent: CHANNELS[channel],
+ edited: true,
+ });
+ this.events.onSetSize(0);
+ }
+};
diff --git a/tgui/packages/tgui-say/helpers/index.tsx b/tgui/packages/tgui-say/helpers/index.tsx
new file mode 100644
index 00000000000..0a729846e89
--- /dev/null
+++ b/tgui/packages/tgui-say/helpers/index.tsx
@@ -0,0 +1,157 @@
+import { CHANNELS, RADIO_PREFIXES, WINDOW_SIZES } from '../constants';
+import { KEY_0, KEY_Z } from 'common/keycodes';
+import { classes } from 'common/react';
+import { debounce, throttle } from 'common/timer';
+
+/**
+ * Window functions
+ */
+/**
+ * Once byond signals this via keystroke, it
+ * ensures window size, visibility, and focus.
+ */
+export const windowOpen = (channel: string) => {
+ setOpen();
+ Byond.sendMessage('open', { channel });
+};
+
+/**
+ * Resets the state of the window and hides it from user view.
+ * Sending "close" logs it server side.
+ */
+export const windowClose = () => {
+ setClosed();
+ Byond.sendMessage('close');
+};
+
+/** Some QoL to hide the window on load. Doesn't log this event */
+export const windowLoad = () => {
+ Byond.winset('tgui_say', {
+ pos: '848,500',
+ });
+ setClosed();
+};
+
+/**
+ * Modifies the window size.
+ *
+ * Parameters:
+ * size - The size of the window in pixels. Optional.
+ */
+export const windowSet = (size: number = WINDOW_SIZES.small) => {
+ Byond.winset('tgui_say', { size: `${WINDOW_SIZES.width}x${size}` });
+ Byond.winset('tgui_say.browser', { size: `${WINDOW_SIZES.width}x${size}` });
+};
+
+/** Private functions */
+/** Sets the skin props as opened. Focus might be a placebo here. */
+const setOpen = () => {
+ Byond.winset('tgui_say', {
+ 'is-visible': true,
+ size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
+ });
+ Byond.winset('tgui_say.browser', {
+ 'is-visible': true,
+ size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
+ });
+};
+
+/** Sets the skin props as closed. */
+const setClosed = () => {
+ Byond.winset('tgui_say', {
+ 'is-visible': false,
+ size: `${WINDOW_SIZES.width}x${WINDOW_SIZES.small}`,
+ });
+ Byond.winset('map', {
+ focus: true,
+ });
+};
+
+/**
+ * Chat history functions
+ */
+/** Stores a list of chat messages entered as values */
+let savedMessages: string[] = [];
+
+/** Returns the chat history at specified index */
+export const getHistoryAt = (index: number): string =>
+ savedMessages[savedMessages.length - index];
+
+/**
+ * The length of chat history.
+ * I am absolutely being excessive, but whatever
+ */
+export const getHistoryLength = (): number => savedMessages.length;
+
+/**
+ * Stores entries in the chat history.
+ * Deletes old entries if the list is too long.
+ */
+export const storeChat = (message: string): void => {
+ if (savedMessages.length === 5) {
+ savedMessages.shift();
+ }
+ savedMessages.push(message);
+};
+
+/** Miscellaneous */
+
+/**
+ * Returns modular css classes.
+ *
+ * Parameters:
+ * element - required string. The element selector.
+ * theme - optional string. The theme to apply.
+ * options - optional string | number. Adds another css selector.
+ */
+export const getCss = (
+ element: string,
+ theme?: string,
+ options?: string | number
+): string =>
+ classes([
+ element,
+ valueExists(theme) && `${element}-${theme}`,
+ valueExists(options) && `${element}-${options}`,
+ ]);
+
+/**
+ * Returns a string that represents the css selector to use.
+ * Light mode takes precedence over radioPrefixes,
+ * radioPrefixes takes precedence over channel.
+ *
+ * Parameters:
+ * lightMode - boolean. If true, returns the light mode selector.
+ * radioPrefix - string. If not empty, returns the radio prefix selector.
+ * channel - number. The channel to use.
+ */
+export const getTheme = (
+ lightMode: boolean,
+ radioPrefix: string,
+ channel: number
+): string => {
+ return (
+ (lightMode && 'lightMode')
+ || RADIO_PREFIXES[radioPrefix]?.id
+ || CHANNELS[channel]?.toLowerCase()
+ );
+};
+
+/** Checks keycodes for alpha/numeric characters */
+export const isAlphanumeric = (keyCode: number): boolean =>
+ keyCode >= KEY_0 && keyCode <= KEY_Z;
+
+/** Timers: Prevents overloading the server, throttles messages */
+export const timers = {
+ channelDebounce: debounce((mode) => Byond.sendMessage('thinking', mode), 400),
+ forceDebounce: debounce(
+ (entry) => Byond.sendMessage('force', entry),
+ 1000,
+ true
+ ),
+ typingThrottle: throttle(() => Byond.sendMessage('typing'), 4000),
+};
+
+/** Checks if a parameter is null or undefined. Returns bool */
+export const valueExists = (param: any): boolean =>
+ param !== null && param !== undefined;
diff --git a/tgui/packages/tgui-say/index.tsx b/tgui/packages/tgui-say/index.tsx
new file mode 100644
index 00000000000..41956294f46
--- /dev/null
+++ b/tgui/packages/tgui-say/index.tsx
@@ -0,0 +1,19 @@
+import './styles/main.scss';
+import { createRenderer } from 'tgui/renderer';
+import { TguiSay } from './interfaces/TguiSay';
+
+const renderApp = createRenderer(() => {
+ return ;
+});
+
+const setupApp = () => {
+ // Delay setup
+ if (document.readyState === 'loading') {
+ document.addEventListener('DOMContentLoaded', setupApp);
+ return;
+ }
+
+ renderApp();
+};
+
+setupApp();
diff --git a/tgui/packages/tgui-say/interfaces/TguiSay.tsx b/tgui/packages/tgui-say/interfaces/TguiSay.tsx
new file mode 100644
index 00000000000..413173b27c5
--- /dev/null
+++ b/tgui/packages/tgui-say/interfaces/TguiSay.tsx
@@ -0,0 +1,75 @@
+import { TextArea } from 'tgui/components';
+import { WINDOW_SIZES } from '../constants';
+import { Dragzone } from '../components/dragzone';
+import { eventHandlerMap } from '../handlers';
+import { getCss, getTheme, timers } from '../helpers';
+import { Component, createRef } from 'inferno';
+import { Modal, State } from '../types';
+
+/** Primary class for the TGUI say modal. */
+export class TguiSay extends Component<{}, State> {
+ events: Modal['events'] = eventHandlerMap(this);
+ fields: Modal['fields'] = {
+ historyCounter: 0,
+ innerRef: createRef(),
+ lightMode: false,
+ maxLength: 1024,
+ radioPrefix: '',
+ value: '',
+ };
+ state: Modal['state'] = {
+ buttonContent: '',
+ channel: -1,
+ edited: false,
+ size: WINDOW_SIZES.small,
+ };
+ timers: Modal['timers'] = timers;
+
+ componentDidMount() {
+ this.events.onComponentMount();
+ }
+
+ componentDidUpdate() {
+ if (this.state.edited) {
+ this.events.onComponentUpdate();
+ }
+ }
+
+ render() {
+ const { onClick, onEnter, onEscape, onKeyDown, onInput } = this.events;
+ const { innerRef, lightMode, maxLength, radioPrefix, value } = this.fields;
+ const { buttonContent, channel, edited, size } = this.state;
+ const theme = getTheme(lightMode, radioPrefix, channel);
+
+ return (
+
+
+
+
+ {!!theme && (
+
+ )}
+
+
+
+
+
+ );
+ }
+}
diff --git a/tgui/packages/tgui-say/package.json b/tgui/packages/tgui-say/package.json
new file mode 100644
index 00000000000..354d52ece55
--- /dev/null
+++ b/tgui/packages/tgui-say/package.json
@@ -0,0 +1,13 @@
+{
+ "private": true,
+ "name": "tgui-say",
+ "version": "1.0.0",
+ "dependencies": {
+ "common": "workspace:*",
+ "dompurify": "^2.3.1",
+ "inferno": "^7.4.8",
+ "tgui": "workspace:*",
+ "tgui-dev-server": "workspace:*",
+ "tgui-polyfill": "workspace:*"
+ }
+}
diff --git a/tgui/packages/tgui-say/styles/button.scss b/tgui/packages/tgui-say/styles/button.scss
new file mode 100644
index 00000000000..e2d32412395
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/button.scss
@@ -0,0 +1,47 @@
+@use 'sass:color';
+@use 'sass:selector';
+@use './colors.scss';
+
+.button {
+ background-color: colors.$button;
+ border: thin solid;
+ border-radius: 2px;
+ color: colors.$background;
+ font-family: 'Consolas', monospace;
+ font-weight: bold;
+ font-size: 0.9rem;
+ height: 100%;
+ padding: 0;
+ text-align: center;
+ vertical-align: middle;
+ width: 4rem;
+
+ &:hover {
+ background-color: lighten(colors.$button, 10%);
+ }
+}
+
+.button-lightMode {
+ @extend .button;
+ background-color: colors.$lightBorder;
+ border: none;
+ color: black;
+
+ &:hover {
+ background-color: colors.$lightHover;
+ }
+}
+
+/** Creates a button for each channel */
+@each $channel,
+$color in colors.$channel-map {
+ .button-#{$channel} {
+ color: $color;
+ border-color: darken($color, 10%);
+
+ &:hover {
+ border-color: lighten($color, 10%);
+ color: lighten($color, 5%);
+ }
+ }
+}
diff --git a/tgui/packages/tgui-say/styles/colors.scss b/tgui/packages/tgui-say/styles/colors.scss
new file mode 100644
index 00000000000..6ba748c99d3
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/colors.scss
@@ -0,0 +1,60 @@
+@use 'sass:color';
+@use 'sass:map';
+
+$background: #131313;
+$button: #1f1f1f;
+$lightMode: #FFFFFF;
+$lightBorder: #BBBBBB;
+$lightHover: #EAEAEA;
+
+////////////////////////////////////////////////
+// Normal chat colors
+$say: #a4bad6;
+$radio: #1ecc43;
+$me: #5975da;
+$ooc: #cca300;
+
+////////////////////////////////////////////////
+// Subchannel chat colors
+$ai: #d65d95;
+$binary: #1e90ff;
+$centcom: #2681a5;
+$command: #fcdf03;
+$engi: #f37746;
+$hive: #855d85;
+$medical: #57b8f0;
+$science: #c68cfa;
+$security: #dd3535;
+$syndicate: #8f4a4b;
+$service: #6ca729;
+$supply: #b88646;
+
+$_channel_map: (
+ 'say': $say,
+ 'radio': $radio,
+ 'me': $me,
+ 'ooc': $ooc,
+
+ 'ai': $ai,
+ 'binary': $binary,
+ 'centcom': $centcom,
+ 'command': $command,
+ 'engi': $engi,
+ 'hive': $hive,
+ 'medical': $medical,
+ 'science': $science,
+ 'security': $security,
+ 'syndicate': $syndicate,
+ 'service': $service,
+ 'supply': $supply,
+);
+
+$channel_keys: map.keys($_channel_map) !default;
+
+$channel-map: (
+);
+
+@each $channel in $channel_keys {
+ $channel-map: map-merge($channel-map, ($channel: map.get($_channel_map, $channel),
+ ));
+}
diff --git a/tgui/packages/tgui-say/styles/dragzone.scss b/tgui/packages/tgui-say/styles/dragzone.scss
new file mode 100644
index 00000000000..33968665720
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/dragzone.scss
@@ -0,0 +1,73 @@
+@use 'sass:color';
+@use './colors.scss';
+
+.dragzone-horizontal {
+ color: transparent;
+ height: 5px;
+ width: 100%;
+}
+
+.dragzone-vertical {
+ color: transparent;
+ height: 100%;
+ position: absolute;
+ top: 0;
+ width: 5px;
+}
+
+@each $channel,
+$color in colors.$channel-map {
+ $darkened: darken($color, 20%);
+
+ .dragzone-bottom-#{$channel} {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid $darkened;
+ border-right: 2px solid $darkened;
+ }
+
+ .dragzone-left-#{$channel} {
+ @extend .dragzone-vertical;
+ left: 0;
+ border-left: 2px solid $darkened;
+ }
+
+ .dragzone-right-#{$channel} {
+ @extend .dragzone-vertical;
+ right: 0;
+ border-right: 2px solid $darkened;
+ }
+
+ .dragzone-top-#{$channel} {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid $darkened;
+ border-right: 2px solid $darkened;
+ border-top: 2px solid $darkened;
+ }
+}
+
+/** Lightmode static theme */
+.dragzone-bottom-lightMode {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid colors.$lightBorder;
+ border-right: 2px solid colors.$lightBorder;
+ border-bottom: 2px solid colors.$lightBorder;
+}
+
+.dragzone-left-lightMode {
+ @extend .dragzone-vertical;
+ left: 0;
+ border-left: 2px solid colors.$lightBorder;
+}
+
+.dragzone-right-lightMode {
+ @extend .dragzone-vertical;
+ right: 0;
+ border-right: 2px solid colors.$lightBorder;
+}
+
+.dragzone-top-lightMode {
+ @extend .dragzone-horizontal;
+ border-left: 2px solid colors.$lightBorder;
+ border-right: 2px solid colors.$lightBorder;
+ border-top: 2px solid colors.$lightBorder;
+}
diff --git a/tgui/packages/tgui-say/styles/main.scss b/tgui/packages/tgui-say/styles/main.scss
new file mode 100644
index 00000000000..db460291f66
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/main.scss
@@ -0,0 +1,13 @@
+@use 'sass:meta';
+
+// Core styles
+@include meta.load-css('~tgui/styles/reset.scss');
+// Atomic styles
+@include meta.load-css('~tgui/styles/atomic/text.scss');
+// External styles
+@include meta.load-css('~tgui/styles/components/TextArea.scss');
+// Local styles
+@include meta.load-css('./button.scss');
+@include meta.load-css('./dragzone.scss');
+@include meta.load-css('./modal.scss');
+@include meta.load-css('./textarea.scss');
diff --git a/tgui/packages/tgui-say/styles/modal.scss b/tgui/packages/tgui-say/styles/modal.scss
new file mode 100644
index 00000000000..b436e4035de
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/modal.scss
@@ -0,0 +1,70 @@
+@use 'sass:color';
+@use './colors.scss';
+
+@keyframes gradient {
+ 0% {
+ background-position: 0 0;
+ }
+
+ 100% {
+ background-position: 100% 0;
+ }
+}
+
+.modal {
+ background-color: colors.$background;
+ display: flex;
+ flex-direction: column;
+ max-width: 231px;
+ width: 100%;
+}
+
+.modal-lightMode {
+ @extend .modal;
+ background-color: colors.$lightMode;
+}
+
+.modal__content {
+ display: flex;
+ flex-direction: row;
+ height: 100%;
+ padding: 1px 0 1px 5px;
+}
+
+/** Window sizes */
+.modal-30 {
+ height: 30px;
+}
+
+.modal-50 {
+ height: 50px;
+}
+
+.modal-70 {
+ height: 70px;
+}
+
+/** Creates an animated border for each channel */
+@each $channel,
+$color in colors.$channel-map {
+ .modal-#{$channel} {
+ &:after {
+ animation: gradient 10s linear infinite;
+ background: linear-gradient(to right,
+ darken($color, 35%),
+ $color,
+ lighten($color, 10%),
+ $color,
+ darken($color, 35%));
+ background-position: 0% 0%;
+ background-size: 500% auto;
+ bottom: 0px;
+ content: '';
+ height: 2px;
+ left: 0px;
+ position: absolute;
+ right: 0px;
+ z-index: 999;
+ }
+ }
+}
diff --git a/tgui/packages/tgui-say/styles/textarea.scss b/tgui/packages/tgui-say/styles/textarea.scss
new file mode 100644
index 00000000000..a34e5ba1963
--- /dev/null
+++ b/tgui/packages/tgui-say/styles/textarea.scss
@@ -0,0 +1,24 @@
+@use './colors.scss';
+
+.textarea {
+ background-color: transparent;
+ border: none;
+ color: colors.$background;
+ flex-grow: 1;
+ font-family: "Lucida Console", monospace;
+ font-size: 1.1rem;
+ margin-top: 2px;
+}
+
+.textarea-lightMode {
+ @extend .textarea;
+ color: black;
+}
+
+/** Creates an input for each channel */
+@each $channel,
+$color in colors.$channel-map {
+ .textarea-#{$channel} {
+ color: $color;
+ }
+}
diff --git a/tgui/packages/tgui-say/types/index.tsx b/tgui/packages/tgui-say/types/index.tsx
new file mode 100644
index 00000000000..c75f7e4d31b
--- /dev/null
+++ b/tgui/packages/tgui-say/types/index.tsx
@@ -0,0 +1,57 @@
+import { RefObject } from 'inferno';
+
+export type Modal = {
+ events: Events;
+ fields: Fields;
+ setState: (state: {}) => void;
+ state: State;
+ timers: Timers;
+};
+
+type Events = {
+ onArrowKeys: (direction: number) => void;
+ onBackspaceDelete: () => void;
+ onClick: () => void;
+ onEscape: () => void;
+ onEnter: (event: KeyboardEvent, value: string) => void;
+ onForce: () => void;
+ onKeyDown: (event: KeyboardEvent) => void;
+ onIncrementChannel: () => void;
+ onInput: (event: InputEvent, value: string) => void;
+ onComponentMount: () => void;
+ onComponentUpdate: () => void;
+ onRadioPrefix: () => void;
+ onReset: (channel?: number) => void;
+ onSetSize: (size: number) => void;
+ onViewHistory: () => void;
+};
+
+type Fields = {
+ historyCounter: number;
+ innerRef: RefObject;
+ lightMode: boolean;
+ maxLength: number;
+ radioPrefix: string;
+ value: string;
+};
+
+export type State = {
+ buttonContent: string | number;
+ channel: number;
+ edited: boolean;
+ size: number;
+};
+
+type Timers = {
+ channelDebounce: ({ mode: boolean }) => void;
+ forceDebounce: ({ channel: number, entry: string }) => void;
+ typingThrottle: () => void;
+};
+
+export type DragzoneProps = {
+ theme: string;
+ top: boolean;
+ right: boolean;
+ bottom: boolean;
+ left: boolean;
+};
diff --git a/tgui/packages/tgui/components/TextArea.js b/tgui/packages/tgui/components/TextArea.js
index 4b6fda547cb..97a2c7c72a1 100644
--- a/tgui/packages/tgui/components/TextArea.js
+++ b/tgui/packages/tgui/components/TextArea.js
@@ -9,12 +9,12 @@ import { classes } from 'common/react';
import { Component, createRef } from 'inferno';
import { Box } from './Box';
import { toInputValue } from './Input';
-import { KEY_ESCAPE } from 'common/keycodes';
+import { KEY_ENTER, KEY_ESCAPE, KEY_TAB } from 'common/keycodes';
export class TextArea extends Component {
constructor(props, context) {
super(props, context);
- this.textareaRef = createRef();
+ this.textareaRef = props.innerRef || createRef();
this.fillerRef = createRef();
this.state = {
editing: false,
@@ -54,19 +54,47 @@ export class TextArea extends Component {
};
this.handleKeyDown = e => {
const { editing } = this.state;
- const { onKeyDown } = this.props;
- if (e.keyCode === KEY_ESCAPE) {
+ const { onChange, onInput, onEnter, onKeyDown } = this.props;
+ if (e.keyCode === KEY_ENTER) {
this.setEditing(false);
- e.target.value = toInputValue(this.props.value);
- e.target.blur();
+ if (onChange) {
+ onChange(e, e.target.value);
+ }
+ if (onInput) {
+ onInput(e, e.target.value);
+ }
+ if (onEnter) {
+ onEnter(e, e.target.value);
+ }
+ if (this.props.selfClear) {
+ e.target.value = '';
+ e.target.blur();
+ }
+ return;
+ }
+ if (e.keyCode === KEY_ESCAPE) {
+ if (this.props.onEscape) {
+ this.props.onEscape(e);
+
+ }
+ this.setEditing(false);
+ if (this.props.selfClear) {
+ e.target.value = '';
+ } else {
+ e.target.value = toInputValue(this.props.value);
+ e.target.blur();
+ }
return;
}
if (!editing) {
this.setEditing(true);
}
+ if (onKeyDown) {
+ onKeyDown(e, e.target.value);
+ }
if (!dontUseTabForIndent) {
const keyCode = e.keyCode || e.which;
- if (keyCode === 9) {
+ if (keyCode === KEY_TAB) {
e.preventDefault();
const { value, selectionStart, selectionEnd } = e.target;
e.target.value = (
@@ -76,9 +104,6 @@ export class TextArea extends Component {
e.target.selectionEnd = selectionStart + 1;
}
}
- if (onKeyDown) {
- onKeyDown(e, e.target.value);
- }
};
this.handleFocus = e => {
const { editing } = this.state;
@@ -104,7 +129,6 @@ export class TextArea extends Component {
if (input) {
input.value = toInputValue(nextValue);
}
-
if (this.props.autoFocus || this.props.autoSelect) {
setTimeout(() => {
input.focus();
@@ -117,11 +141,10 @@ export class TextArea extends Component {
}
componentDidUpdate(prevProps, prevState) {
- const { editing } = this.state;
const prevValue = prevProps.value;
const nextValue = this.props.value;
const input = this.textareaRef.current;
- if (input && !editing && prevValue !== nextValue) {
+ if (input && typeof nextValue === "string" && prevValue !== nextValue) {
input.value = toInputValue(nextValue);
}
}
diff --git a/tgui/packages/tgui/interfaces/CommandReport.js b/tgui/packages/tgui/interfaces/CommandReport.js
deleted file mode 100644
index afcdb746fe5..00000000000
--- a/tgui/packages/tgui/interfaces/CommandReport.js
+++ /dev/null
@@ -1,90 +0,0 @@
-import { useBackend } from '../backend';
-import { Button, Dropdown, Input, Section, Stack, TextArea } from '../components';
-import { Window } from '../layouts';
-
-export const CommandReport = (props, context) => {
- const { act, data } = useBackend(context);
- const {
- command_name,
- custom_name,
- command_name_presets = [],
- command_report_content,
- played_sound,
- announcer_sounds = [],
- announce_contents,
- } = data;
- return (
-
-
-
-
-
-
-
-
- act('set_report_sound', {
- picked_sound: value,
- })} />
-
-
-
-
-
-
-
-
- );
-};
diff --git a/tgui/packages/tgui/interfaces/CommandReport.tsx b/tgui/packages/tgui/interfaces/CommandReport.tsx
new file mode 100644
index 00000000000..e6f864bce65
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/CommandReport.tsx
@@ -0,0 +1,130 @@
+import { useBackend, useLocalState } from '../backend';
+import { Button, Dropdown, Input, Section, Stack, TextArea } from '../components';
+import { Window } from '../layouts';
+
+type Data = {
+ announce_contents: string;
+ announcer_sounds: string[];
+ command_name: string;
+ command_name_presets: string[];
+ command_report_content: string;
+ custom_name: string;
+ played_sound: string;
+};
+
+export const CommandReport = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+/** Allows the user to set the "sender" of the message via dropdown */
+const CentComName = (_, context) => {
+ const { act, data } = useBackend(context);
+ const { command_name, command_name_presets = [], custom_name } = data;
+
+ return (
+
+ );
+};
+
+/** Features a section with dropdown for sounds. */
+const AnnouncementSound = (_, context) => {
+ const { act, data } = useBackend(context);
+ const { announcer_sounds = [], played_sound } = data;
+
+ return (
+
+
+ act('set_report_sound', {
+ picked_sound: value,
+ })}
+ />
+
+ );
+};
+
+/** Creates the report textarea with a submit button. */
+const ReportText = (_, context) => {
+ const { act, data } = useBackend(context);
+ const { announce_contents, command_report_content } = data;
+ const [commandReport, setCommandReport] = useLocalState(
+ context,
+ 'textArea',
+ command_report_content
+ );
+
+ return (
+
+ );
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx
index 837d00f770c..8b358cd1ed4 100644
--- a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/tgui.tsx
@@ -34,3 +34,10 @@ export const tgui_lock: FeatureToggle = {
description: 'Locks TGUI windows to your main monitor.',
component: CheckboxInput,
};
+
+export const tgui_say_light_mode: FeatureToggle = {
+ name: 'Say: Light mode',
+ category: 'UI',
+ description: 'Sets TGUI Say to use a light mode.',
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/typing_indicator.tsx b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/typing_indicator.tsx
new file mode 100644
index 00000000000..b57c864e4fc
--- /dev/null
+++ b/tgui/packages/tgui/interfaces/PreferencesMenu/preferences/features/game_preferences/typing_indicator.tsx
@@ -0,0 +1,8 @@
+import { CheckboxInput, FeatureToggle } from '../base';
+
+export const typingIndicator: FeatureToggle = {
+ name: 'Enable typing indicators for self',
+ category: 'GAMEPLAY',
+ description: "Enable typing indicators that show you're typing a message.",
+ component: CheckboxInput,
+};
diff --git a/tgui/packages/tgui/interfaces/TextInputModal.tsx b/tgui/packages/tgui/interfaces/TextInputModal.tsx
index d6c7ba0afc2..ee9881c6e42 100644
--- a/tgui/packages/tgui/interfaces/TextInputModal.tsx
+++ b/tgui/packages/tgui/interfaces/TextInputModal.tsx
@@ -90,15 +90,10 @@ const InputArea = (props, context) => {
autoSelect
height={multiline || input.length >= 30 ? '100%' : '1.8rem'}
maxLength={max_length}
- onKeyDown={(event) => {
- const keyCode = window.event ? event.which : event.keyCode;
- if (keyCode === KEY_ENTER) {
- act('submit', { entry: input });
- event.preventDefault();
- }
- if (keyCode === KEY_ESCAPE) {
- act('cancel');
- }
+ onEscape={() => act('cancel')}
+ onEnter={(event) => {
+ act('submit', { entry: input });
+ event.preventDefault();
}}
onInput={(_, value) => onType(value)}
placeholder="Type something..."
diff --git a/tgui/webpack.config.js b/tgui/webpack.config.js
index 1aa326eee87..5065237d795 100644
--- a/tgui/webpack.config.js
+++ b/tgui/webpack.config.js
@@ -40,6 +40,10 @@ module.exports = (env = {}, argv) => {
'./packages/tgui-polyfill',
'./packages/tgui-panel',
],
+ 'tgui-say': [
+ './packages/tgui-polyfill',
+ './packages/tgui-say',
+ ],
},
output: {
path: argv.useTmpFolder
diff --git a/tgui/yarn.lock b/tgui/yarn.lock
index 6c7e730d744..f652f0f4ab7 100644
--- a/tgui/yarn.lock
+++ b/tgui/yarn.lock
@@ -8191,6 +8191,19 @@ resolve@^2.0.0-next.3:
languageName: unknown
linkType: soft
+"tgui-say@workspace:packages/tgui-say":
+ version: 0.0.0-use.local
+ resolution: "tgui-say@workspace:packages/tgui-say"
+ dependencies:
+ common: "workspace:*"
+ dompurify: ^2.3.1
+ inferno: ^7.4.8
+ tgui: "workspace:*"
+ tgui-dev-server: "workspace:*"
+ tgui-polyfill: "workspace:*"
+ languageName: unknown
+ linkType: soft
+
"tgui-workspace@workspace:.":
version: 0.0.0-use.local
resolution: "tgui-workspace@workspace:."
diff --git a/tools/build/build.js b/tools/build/build.js
index a2cddb4a3e6..9269bf5ed63 100644
--- a/tools/build/build.js
+++ b/tools/build/build.js
@@ -184,6 +184,8 @@ export const TguiTarget = new Juke.Target({
'tgui/public/tgui.bundle.js',
'tgui/public/tgui-panel.bundle.css',
'tgui/public/tgui-panel.bundle.js',
+ "tgui/public/tgui-say.bundle.css",
+ "tgui/public/tgui-say.bundle.js",
],
executes: () => yarn('tgui:build'),
});