From 4a841de073682a84874e6f2af6e7956df41ac9e0 Mon Sep 17 00:00:00 2001 From: Alois Date: Wed, 5 Aug 2026 21:41:55 +0200 Subject: [PATCH] (feat): improved mobile notifications (feat): add lint rules (feat): improve markdown inline code box --- apps/electron/build/icons/icon.icns | Bin 257363 -> 257363 bytes apps/tauri/monochrome_cropped.png | Bin 0 -> 2833 bytes .../android/app/src/main/AndroidManifest.xml | 1 + .../java/net/tensamin/client/MainActivity.kt | 4 +- .../tensamin/client/MtpForegroundService.kt | 14 +- .../net/tensamin/client/NativeMtpBridge.kt | 41 +++- .../res/drawable/ic_notification_small.png | Bin 0 -> 8525 bytes apps/tauri/src-tauri/icons/icon.icns | Bin 257363 -> 257363 bytes apps/tauri/src-tauri/src/lib.rs | 22 +- apps/tauri/src-tauri/src/mtp_backend.rs | 97 +++++++- apps/tauri/src/deeplinkHandler.tsx | 8 +- apps/web/src/index.tsx | 33 ++- eslint.config.ts | 3 + packages/call/src/mediaShare/controller.ts | 30 +-- packages/call/src/mediaShare/tauri.ts | 42 ++-- packages/call/src/speakingIndicator.ts | 21 +- packages/call/src/speakingState.ts | 6 +- packages/call/src/store.tsx | 83 +++---- packages/chat/src/components/gifPicker.tsx | 50 ++-- packages/chat/src/components/input.tsx | 7 + packages/chat/src/context.tsx | 4 +- packages/chat/src/screen.tsx | 14 +- packages/markdown/src/emojiData.ts | 4 +- packages/markdown/src/input.tsx | 18 +- packages/markdown/src/markdown.tsx | 226 +++++++++++++----- packages/mtp/src/context.tsx | 21 +- packages/notifications/src/context.tsx | 27 ++- packages/onboarding/src/index.tsx | 4 +- packages/onboarding/src/pages/legal.tsx | 4 +- packages/settings/src/components.tsx | 8 +- packages/shared/src/data.ts | 40 ++-- packages/shared/src/desktopMedia.tsx | 10 +- packages/shared/src/settings.ts | 12 +- utils/eslint-rules/index.ts | 208 ++++++++++++++++ utils/scripts/lint-packages.ts | 2 +- 35 files changed, 777 insertions(+), 287 deletions(-) create mode 100644 apps/tauri/monochrome_cropped.png create mode 100644 apps/tauri/src-tauri/gen/android/app/src/main/res/drawable/ic_notification_small.png diff --git a/apps/electron/build/icons/icon.icns b/apps/electron/build/icons/icon.icns index 7f3b5918284138bd07f143d54075ac99c7672c68..a7f0a1e940bbdd0f4c8b453dc3ff837603c4dc08 100644 GIT binary patch delta 72 zcmcb7i~sU1evZuKykZ9Cx53*TLm7W9+Fa->#@TN9gK@j%52ii+tmX_1W)CNSG!x#a eeB5mNsr$^GyqjN|wTo^4($0J>d~aj`h(_L=XPcKu_?Og6CGp5Vp& cc~P_Yn(g9i829#XKQfK!YWVieMNFN%06SkE!2kdN diff --git a/apps/tauri/monochrome_cropped.png b/apps/tauri/monochrome_cropped.png new file mode 100644 index 0000000000000000000000000000000000000000..424ebd1faf1814abfeeb3a169ba1fd7e8b2ae871 GIT binary patch literal 2833 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 = Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { @@ -44,6 +48,7 @@ class MtpForegroundService : Service() { try { NativeMtpBridge.nativeAttach(applicationContext) NativeMtpBridge.nativeStart(config) + started = true NativeMtpBridge.log(2, "Started MTP foreground service") } catch (error: Throwable) { NativeMtpBridge.log(0, "Failed to start MTP foreground service", error) @@ -52,6 +57,13 @@ class MtpForegroundService : Service() { return START_STICKY } + override fun onTaskRemoved(rootIntent: Intent?) { + if (MtpSecureStore.isEnabled(this) && MtpSecureStore.hasConfig(this)) { + startService(Intent(this, MtpForegroundService::class.java)) + } + super.onTaskRemoved(rootIntent) + } + override fun onDestroy() { if (!MtpSecureStore.isEnabled(this)) NativeMtpBridge.nativeStop() super.onDestroy() @@ -95,7 +107,7 @@ class MtpForegroundService : Service() { ) return NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(android.R.drawable.stat_notify_sync) - .setContentTitle("Tensamin background connection") + .setContentTitle("Tensamin") .setContentText(status) .setContentIntent(openIntent) .setOngoing(true) diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt index 996daf0..ba9422d 100644 --- a/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt +++ b/apps/tauri/src-tauri/gen/android/app/src/main/java/net/tensamin/client/NativeMtpBridge.kt @@ -6,6 +6,7 @@ import android.app.NotificationManager import android.app.PendingIntent import android.content.Context import android.content.Intent +import android.graphics.BitmapFactory import android.net.Uri import android.os.Build import android.os.PowerManager @@ -13,7 +14,12 @@ import android.provider.Settings import android.util.Log import androidx.annotation.Keep import androidx.core.app.NotificationCompat +import androidx.core.app.Person +import androidx.core.content.LocusIdCompat import androidx.core.content.ContextCompat +import androidx.core.content.pm.ShortcutInfoCompat +import androidx.core.content.pm.ShortcutManagerCompat +import androidx.core.graphics.drawable.IconCompat @Keep object NativeMtpBridge { @@ -85,6 +91,7 @@ object NativeMtpBridge { senderId: Long, sender: String, body: String, + avatar: ByteArray, ) { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { context.getSystemService(NotificationManager::class.java).createNotificationChannel( @@ -107,11 +114,36 @@ object NativeMtpBridge { openIntent, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, ) + val avatarBitmap = avatar.takeIf { it.isNotEmpty() }?.let { + BitmapFactory.decodeByteArray(it, 0, it.size) + } + val avatarIcon = avatarBitmap?.let(IconCompat::createWithAdaptiveBitmap) + val person = Person.Builder() + .setName(sender) + .setKey(senderId.toString()) + .setIcon(avatarIcon) + .build() + val shortcutId = "chat-$senderId" + val shortcut = ShortcutInfoCompat.Builder(context, shortcutId) + .setShortLabel(sender) + .setLongLived(true) + .setPerson(person) + .setIntent(openIntent) + .apply { if (avatarIcon != null) setIcon(avatarIcon) } + .build() + ShortcutManagerCompat.pushDynamicShortcut(context, shortcut) + + val style = NotificationCompat.MessagingStyle( + Person.Builder().setName("You").build(), + ).addMessage(body, System.currentTimeMillis(), person) val notification = NotificationCompat.Builder(context, MESSAGE_CHANNEL) - .setSmallIcon(android.R.drawable.sym_action_chat) + .setSmallIcon(R.drawable.ic_notification_small) .setContentTitle(sender) .setContentText(body) - .setStyle(NotificationCompat.BigTextStyle().bigText(body)) + .setStyle(style) + .setShortcutId(shortcutId) + .setLocusId(LocusIdCompat(shortcutId)) + .setLargeIcon(avatarBitmap) .setCategory(Notification.CATEGORY_MESSAGE) .setAutoCancel(true) .setContentIntent(pendingIntent) @@ -121,4 +153,9 @@ object NativeMtpBridge { .notify(senderId.hashCode(), notification) } + fun cancelMessageNotification(context: Context, senderId: Long) { + context.getSystemService(NotificationManager::class.java) + .cancel(senderId.hashCode()) + } + } diff --git a/apps/tauri/src-tauri/gen/android/app/src/main/res/drawable/ic_notification_small.png b/apps/tauri/src-tauri/gen/android/app/src/main/res/drawable/ic_notification_small.png new file mode 100644 index 0000000000000000000000000000000000000000..bec055d05956f03db912fd7bff42974e166b3225 GIT binary patch literal 8525 zcmeAS@N?(olHy`uVBq!ia0y~yU^HW3VD#c(V_;yI9#bsLz`($g?CBi9$jiVuC)3)9 zfq{XsILO_JVcj{ImkbOF44y8IAr*7p-YwsvnmY6S$M?mvWK=YYR5>^_xMm2rUNn68 z;>?QhfD48SYI7u?$L-zxF0^Etk(Cv5-{TI(oJlF70)~rK1Op0Bco;PJx)m|sz0i68 z@8WMS@7=9E#25NC%TW4jM-Cv(w9YVWYW@{CvKUz zgjqFqo#?vBUkp9{Pn}CoyJqaU+jEw7X5+H&GnNFazE#!SWITK3TDMDTb1&XBH5S?Y z^3OiwuG==h&nKoO`(1KLKT^7C$x&vJEup$oOSUbkRb4yr?{nkLhDw>;=Qv(36Y9EI zk~g`eV@dAj|9jHR7O9-Q*md#dBrfh-tEOch)yO;hWxW$=&V9){~KE*7&Ndy(_6WSNG*dDfjfhJ3@}0?fSau|GqVqHc6YGs=Q^9Vc5f; zaoTG0&jZ^Vm6aZydSh^nM=vMY?~>I4=?3P;y^|jEyf%8^@nA|vg^G30)51jQ;7Mi7 z-E>@(l2%$vmZlWU&rrFha+rb zZX9z+FDX;_z?bm%iAAIsW_im;cCFi2ZPL zxieSx*s14-%$!tw8P+e_QT9zN;qD!m-t7`UB#(xC?dgb^Hs`q4qeEs)Dz>eQX2^2b z=vw+k-{qUbQe&(1Of`L@;?50QL$1GgVl2K&)sW{o^$MO(u3*) zod^2w9txEe-^Nw4)uNW+hMi@f>E+x5Cq?}K?X|Q%v!XCj`=pkxUp~{G#go1s(v1-< zNR$o=@4kGW`L@dOjb*zhKk8c*y`$~PA+rX*?#tPl>t?@YK6BDHMLDfVq+F>W%Ee~Z zoaLo4H-0I+d@1tQ_dVmhIWotO9ZuoCeqHI&t2YL@4-c7fZ?}kS_Ssf2S4NO~(_xdD z`#qN{zKj$pH@nYoV`$kIx&5Trx}AcBm1Q&3iV|O6y838~{f6`+nay!N+X`ew1;3}a zXL5;~g=H01S#NMGlG*IIqjmM^8)Dnmi7l^Zm^WGG_!^(dx-rMyj@aHzH0p^>d@VJ1 zuW*FEWnbjfV`chRH)`Ze`p5nuuS=TITIIOm=>qKnuA`Id^H1^xZ=I-{lUHao`;yUv zny$b*<;qE!Mf1FRViG5BQ9Ww%`F%rf@#Pt&w^F#TPZTT+E}LQ0>ykLxguO!b=!-MD zerbPS<{kMT=c40x&Uj0hxY>4#nN!~}J}`N(TCmW!Y=-03bsIdRq$`y33XN7rWk>Ib z3KmhZo&4zBv)>Kss>cnb)jv1pGspLLXm2|FWzoe}-%D)=?uqoX-_o}1n<=_@hj62? z(xb*V2A4Ba5{tVhM?Tigf7sPgyy@_oRfes;m)s6SYtD1|edmcq+O^%%4UZd=l^)%D zqT|5KH9LCF88^4&s2xFEvzMIJl;i(WmoUADCoOEHeZZCiSz*EL*J?lA0xLTf4UZ@qFz zEz`Zr*6coaPVBRdXLD5k&P@9FK2Y%E?M-}EB_OBHoSL$!_4SgD2#Yg~Y#Rp!Byndwj z?%$!1*0*^Hn`br6oXPw8UB`_*zsYYLCgIy__M z)WqNK;&*JAc~c`|W!cURH%l(fTbgRdAa3@$Gy3rw-?A6~TDfP>>}Be|aVF=$T!a3yjJaha?+~%CdSzdHMwt3<8)k8X(PIITU6X(o21QoNnf}2 zrsZzj)5hAcz%FLV5^i=ST-S0DKq%NzBe+-Z+3Ous4TMj zaO8@{w1j+fN%zwaq(~_q_-S5Hc?8j+& zE;=Xp482rxIc28KJv{C9mDb!nI?T_~+9g!yW?wn}`2AwFj~%&ZYRr|MrM62L%CpAJ zl|2-;Y32Hjz0>v6*(Fpbl`-4RoqIU!vy1q)kA`Wty_FE4|8_?Gn3B!&T#(}pP`n@Tjo9H*6qI8vEti48kj4|o#r#- zQps(dUS=|v`?f?x)AWidi?TD@*L$1auF}g{{QgVF4qfXb9X~8Sta>A0rrm$zSi|&+ zDGryo8=5y4NBGCwaJ!Q#`0@1{f!ym`ws)eN$u;TxxjjJGT1P^yaqQ z<()H4cF|jvy{r}GWqhws?-D*>byIVVP`mq)MQ;RhuSMQ^XnpGK!RChzX#ll7QeVHSQuW`G3|Hy1JRp`ISSc+4`dIxUku;Gyk+K+yN&*7&o?w? zF7wS^JGV|!@c&%dMQc@Z8>W|SnQN0IZ5ubidX92~>}Ka11vW|2y5$V%DeW2Bp1+yi zpE;N4{JA6dNR6)2w3PM?WzXox)+ZMqx_-m$gwlf-Zv=9$Y~7MP`HfB(U;hoa<0l0R z%gZ{Z{Sx``>5V{`RR0aP6Ay1P-txFKPbB+VPRS3c+Z{U;l=M#XT{N3y#^lFz?ZmmH zr$vdKm@fnO=HMFv`@{sj zH#=|XT(X=o{_MHLsgV|pIw$-RnWH&mCd)4JRaxs;7<=~j%f5B2dO2^)l3eagmtB;~ z#}+q7_K?=r3&9u5J`45Rc~og0^$Rw;Ws{V>u~_-ht2Y9@0u)cMy@H6+qiYc z{6`n%_1{X!%Wd3x#pZ$gO~qSImlzM+PkX-M){k`=-nEuW(^A_rn$KP8JG`klVy#$( z)-Ah!hT~}vvB@7V^%ZU|XnG`l^w#4a9W&<3E>g8)elt<_P}cSYrVmytz6l&%_r#3b zQ=i#x*4)EEcdQxjOrCqF>f7ax9d_0(mn$=m-`Jhbw8PxG_3Ih`k1x)OH|B0~j+j+# zla$?W&yb$lp3%Ag1Yg3+m>W?~v;{v_lyyw|AyOdx;eg2hY6HXRS0CS-AR;2&SkZjr zSrx|(EhV{gd`5gKYkAk0pFVAt@=X1`AA_{-$KTn^^33-vBCKW~T`aiqo|K@X=XWOm zlYS|etZ;kDSDpACvlywXW6rexQqYaxL@1opQ(dU1cOEnJtcpPrn+m za>Gxn=;FT?O8+Lv9%@t1lz!m!VCT(9w;9~g-ajoAw~bl9h|Z~Rvxwx?e1$Nrm<$F2w~J$h)$p!BHDlm8+6C)1)y&Q8DmW`s*nA}s^H{2}U#d+@dT^8TXHTDzU z9s0(eQ1~};&EwsIh1q2svuEz+l*$&2-F)t8tFF>ChWaxeDGk#rOy=$8lzQ9N5n=VS zyEAlOMeNzcH3hCNF;ip@EmPA}`OE%6)#|R*H4E*l>q5D2Z#8AxDB5?9FCqAKiSV43 z=q0oAmLA&nYQnAAAD`{ewQgAJcZuyldBgrQ=aM!@Wd=MGj5hwG@A*2VeS@fqEvJRJ z_||O^nMbx=`r34ELp9?*i&+mDA9!`CSFg!pynKIS9ZyBcto6FAX}9jaa!lBz*Y4h+ zdbuW7u%OoEPg+~@_G!C0qtXR8IDS9dd{wW-YnR-OmFGgPMg12M{6AH8kS~Id5fcon;!DxO+;{_rlFH z=LiX|eIN4jm0E`*%hqc6ati#bWirIqgNBzynoQ&XWt6p;5t`CC8=GzLmKPFE@S=*ibgLbIsJa zYsb_lmh9p@cc}Zk`$?yS#qau>J%jyRm6Hq$La(k|Q>vGd8CWe=ps1I%_}QnGT~a>v zy}UNETd&sYSv&0C^-b-Dm38;jyj#_b#-iKeLj;c8dR_Ej&KrTh6TVbEuzBElGjfg4 z<#+q7gA=X!^{;FdwY$9Ruix%(Y7zR@-Jwa6#ytC$>E;L)${$_#B<*_O9p4<*a^ap< zZKX$zFZWqo?%?gxJ$A-UTCi7s@9x@ABCXGVb^q=%uak z-E6D*{kcf-P0Q4!8?XKR{xWZ+x$E-T4@@4c-?TYGcN&xU`7ce9?m2VYerTWaNm*MY zU654PF|%YBXI)$OEsLEhN@-P3{;mlQoRO-Rt^D@$+@rU8ue?;-A+g<~cXniUkbdjg z>o<>GzANH?AVZ~GxMwR^?5$H*Y(mbS8beTzZARB_&SSS0A6TDOeQ{0e+Rp;!{kM*t zu6gkKrsA?kc}*L?Z|uV_rV*SeeYSgiln70X?FCT(NM zd;9h4wJh$2T;)gi-YDF)y36vv)xP=ho5Fh8GxaZ`U`q08j1S-Uyc zoX)S(%)0P9M8rl`mc3iU&9(PI9#gdIdD9j14}JT~*c7o_Mpm#qrM+S5H?@lVGO_HQ zdD#K+&efiu8*+d7Ca>MVQc>k{<}}}{QdTaTBtiEP!? zsG1UcWzFrGfA{RTf9TokoW+@v1sP=>e&z?)H^y$>9P#)|X!WnTUF%ryaOT`P|9j2H zeX@f2r+E}!wu%@t>F?PZdY3y#P|&-o);V5zo1gQS@?XBGy=o6WuVn1XYx-OmxOwH= zTmFH~|CTReQ0M<3m%UEj?a0$e^>XvZuM6JKy8OM#-IMVSOU~{0)oUky>*U^_%&s<-wJI_nnt|d8cR5wU=rZQx?rW;CeQ#=#s+j#k{{3 zTBRQN9=Z9-KE|SO^MmfM?Y3{dWX@iB`M}=tN$VKPB)|Nf_HI(gjm77Ly`uV)@|*Ur z`@H8`RJhBL&+L_t4K6eE-@5fS{rkX*?ushgP_cZpVfGC005N>)JdczCUl=lX>j z3#3D8^`tG!1AUy?@BJ!VAsGGq$kh7D5t(_32W~(AJK>98*FJx7Q|Tjyhu@_~AA59S zr-=BkH9z@QRxv;EGe7w8z^u@D$K9?y7I`hxZd8)k-67pr+-BPs`cCh`j#Yf`@|d(w z`k$)MONf*|uv1m`_^QvX9Ti);n9Fx@_8q#*Vzwvf+h*NV-l*rzw){6X^*oMz`EL1* zp!W|q@xRGq(!RJ>g!ldFHroJA=@D1%_$GwzT=HfS+tsxK|D9ZqZ@s6pxJCD?)m@hM2YF1Z&+QK_ zyx^tx$$J(<9z)rhM?cI~2P-|2JuKsJNv5lB()X#tCv!~qGtUzhT>5=h_^03)TYv3ZY2 z+UlKilQe@={b#Ryv&+-PhV4&v=<)5DyftSRUH1J@9CKw&)%>-e1f!i8;;c_dgHj@+zpnYDn_15o&#&0DA+&1wYT-YdL+2e1GrGWbBDPw1$BIZ< zzIDGEmrvbpSNd+QZY-V7-?N)j=c2yyqHU=zrT>rob2jKoXDbY^-DR2f=1NCJ_FVNd8adIyI=5oizbo!vz^wde#mu-1FV%i< zet1)*G;{5fz%IA%m8J8xeCs|gSg8GBe(C&jmb+|o3@&q9-DTN-$o1?enW*x><|TVM zRn~eJUaX$o^SQsH-{nZ+<=VhY??ke%{E^5%U8MctOh-h(_s-zF@8xAG_8O1xC_l1$ zFyrOE7kfF^DE`x2yu$I3)v}HsVg-)Zen!8uSAH~c<-P}bOxYdX4ewLt+LX?=@jTC} z$NDVgS;owtBGL->J6?(3@lD{}$a6ziY27KV3BPy+1jCIEPdT;h%hb?rP?4YbEKSTb zQcyBiw96e%nOSoeFPwJq<+T?j z!hfX7g(EiqnSWA#kMD&UhpugOE^BDBvQ+waF;|uqa~&<3q4u|WCS%Q_Ojq7n zUwa=}FS>l&$hzUCh#u3vS+Yr5y)(MX%&K=vf|?^UzoyuIxYOe<^mUP@Isdl|No=%q4wqFqCEe}#lx`g*|crsbI&H*u!*Jsqhx9jhk( zWx1Qqcs(u6U~}YM=Z`LTqBl2x(W=&ZwR>UO*=-^f(X+dxo``STt}_4rnY3>oJ1q3A zUG`sEwrJ9}#XCxK($0Q~eQ;KA;Twg&r(`yoKJuKMX0}wif%#U%6@~2^DrdGw8r$-` zx!m?O>$0b2&ZKQDW#=un&phiPqP+F>=4Kw{>?7Owc_Wh-S_i=w9=#VHyuxfybcMubnL+V#;vbx*3b0KIzMhk2wlrD!QSD8|RUywi-sTqs8lNsx z`*84$KbKcr*Ce|KTvu1XAMNNOCIp_8DLObK=vqukPyJxPw4)or9}CJRScTe&PTit*bkQB(Rd&;7u6@FP zAl~Ij>`lj4VaGdi^<%D_c~!>!VOF6|R)BxvXOk;y;>?43uH>ELo3!-O$`wm256nG~ z7CP_vnQ6P_ZfGmLJHa<;uQAzBsVX83oHv9*VAh?(eA z6UIASIe}L#&+s&KYsiV*ycN6jRM^vwT>h9VXI6bH?mD_%FDrJHPR=`C)797dHM(vo z-*lX{?9`!c<*mofq|1*pSGylczUeq?#iT=OndS+*wfb_?Y9uTCU35DqtKvm-8Eyf#yZyq<5PA+F!-`{b1 zbMqEgQDrlb#$wr{$?eCsy)PCBy<;QsHLqE^k-33;Qt^!!*77$lyRNNYDrV+s&2V1x z^9-AQ2>KEyR??(MIGYku4KAh~>QN2vO?AqJ8 zj5b@_V$62EW4I?Hxc?mArLv`>W}eZ3Hd{Au^PQo+NyI|zM~QuZ{lqPj1zI1vx{7XQ z?3TE3-T%3U`^?~r=9k|sQhQ+1m7O8~=KIVr!9xBIQeB}B)BairRZQ)UsywBalUvHS zq1xq*xZr;IFA8&C=gBX-k$L;Ndyd(@d0`WS+#)8KvFEWrn^L$(>Mz%i3YRJSN}Mlw zH{>%$Pp{39=G-1oBJ`swiuIJ##<#w^yKa3wP$!bzq7~UMCb*xaZ0U}7Y1iKRJgC&m zx~jQ)!`4)(D(#~th1WYQbd^G$o>WSzbJ-(WVZLF*sp3d&8@3vj8|q5$&hr_cYMa;P zb*m-G`H5+JfAGxT4gVSVPFK~1E^q%9eLzq0`SucXhWzf1)tj1onvL9Y@)zxNeJdCi z7c=`?z`l+hVik(}vaS03SmGWm3Y%}l6)jxtEFOHv>As>8UsuP^n~e-gk1n2zya{^1u}PZ*fZ%b%vcjYMdtXa%`J~U9X}m& zbM8SZNudatizuMx7? z`>HfUQsu4F$B>D4CoPW1U&qGR(V@Mm`3BcvAJ^CWq^qQlKDrhADk5gKs!|>I8%M*c z;{OT7x~WGs&bqEmWRu$H8P)wL?}gjbV>2(+9#A}RT`6ge+zXADl?Tiv?k*SFepOWD zcU4tvlryhY((Ue7-M5&!=66`T91%aZb_HXJ8o>_I8>U+F{uTYub?#Br{m55=E;{dY-rQyV;aN6M zu*B}eoO4SB{(s0=xNq5_NopGNGPfO_@O*Dq(y5=175kGLmi*Ol6ThwIp1IqeQNFk1 z^5*6n1;1oB?w#?l>(*J3?2Bc^f!h8qbGU4pAH91SaX&}$g7Sgojp;jA^eTGBUrBg3 z?TevjcKUxwx4BDvRb`c4Ze;ww_SnfJ&tg?u%is6U3VH5Yd7l5Av(~1O504MOczAwzsNM)`5 z%fpl2t=h{o^?97=fc_WVI)m1sFE%R$U)=_LZL~YwqJW`}K{p1x3vz z@9Dmrb%twO*J{`=;^>4KGRgE?;o3v$V*HVe{^8pv+CX~!r@^;hbSu?--xSqbY zZ?53YCElSvv3EbEq`h7CsIsmuqUq|R|IASwd7?kx8#*yCFfe$!`njxgN@xNAIc8k; literal 0 HcmV?d00001 diff --git a/apps/tauri/src-tauri/icons/icon.icns b/apps/tauri/src-tauri/icons/icon.icns index 7f3b5918284138bd07f143d54075ac99c7672c68..a7f0a1e940bbdd0f4c8b453dc3ff837603c4dc08 100644 GIT binary patch delta 72 zcmcb7i~sU1evZuKykZ9Cx53*TLm7W9+Fa->#@TN9gK@j%52ii+tmX_1W)CNSG!x#a eeB5mNsr$^GyqjN|wTo^4($0J>d~aj`h(_L=XPcKu_?Og6CGp5Vp& cc~P_Yn(g9i829#XKQfK!YWVieMNFN%06SkE!2kdN diff --git a/apps/tauri/src-tauri/src/lib.rs b/apps/tauri/src-tauri/src/lib.rs index 5d49556..8131b57 100644 --- a/apps/tauri/src-tauri/src/lib.rs +++ b/apps/tauri/src-tauri/src/lib.rs @@ -17,7 +17,7 @@ pub fn run() { #[cfg(any(target_os = "ios", target_os = "android"))] let builder = builder.plugin(tauri_plugin_barcode_scanner::init()); - if let Err(error) = builder + let app = builder .invoke_handler(tauri::generate_handler![ mtp_backend::mtp_request, mtp_backend::mtp_status, @@ -26,6 +26,7 @@ pub fn run() { mtp_backend::mtp_load_keyring, mtp_backend::mtp_set_enabled, mtp_backend::mtp_set_ui_visible, + mtp_backend::mtp_post_message_notification, mtp_backend::mtp_is_ignoring_battery_optimizations, mtp_backend::mtp_request_battery_exemption, ]) @@ -45,9 +46,18 @@ pub fn run() { } Ok(()) }) - .run(tauri::generate_context!()) - { - eprintln!("error while running tauri application: {error}"); - panic!("error while running tauri application: {error}"); - } + .build(tauri::generate_context!()) + .expect("error while building tauri application"); + + app.run(|_app, event| { + #[cfg(target_os = "android")] + if let tauri::RunEvent::ExitRequested { + api, code: None, .. + } = event + { + if mtp_backend::manager().is_enabled() { + api.prevent_exit(); + } + } + }); } diff --git a/apps/tauri/src-tauri/src/mtp_backend.rs b/apps/tauri/src-tauri/src/mtp_backend.rs index 8cbfb8d..7c65b03 100644 --- a/apps/tauri/src-tauri/src/mtp_backend.rs +++ b/apps/tauri/src-tauri/src/mtp_backend.rs @@ -56,7 +56,7 @@ Zwkt6K2EOMmh1nvEzl83eMLYcod4GCl3b0J1Nn0CMBNYmEQJb4CEG5WoOe7aRn/L\n\ VKu6saHmHEynI7ysIPd8zQsK1HdmhlHKlw9Z5GpGvA==\n\ -----END CERTIFICATE-----\n"; -#[derive(Clone, Debug, Deserialize, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Eq, Serialize)] #[serde(rename_all = "camelCase")] pub struct MtpConfig { pub user_id: u64, @@ -135,6 +135,11 @@ impl MtpManager { pub fn configure_and_start(&'static self, config: MtpConfig) { let _guard = self.start_lock.lock().expect("start lock poisoned"); + if self.enabled.load(Ordering::SeqCst) + && self.config.read().expect("config lock poisoned").as_ref() == Some(&config) + { + return; + } *self.config.write().expect("config lock poisoned") = Some(config); self.enabled.store(true, Ordering::SeqCst); let generation = self.generation.fetch_add(1, Ordering::SeqCst) + 1; @@ -182,6 +187,10 @@ impl MtpManager { self.ui_visible.store(visible, Ordering::SeqCst); } + pub fn is_enabled(&self) -> bool { + self.enabled.load(Ordering::SeqCst) + } + pub fn snapshot(&self) -> MtpSnapshot { self.snapshot .read() @@ -446,6 +455,19 @@ async fn handle_push(generation: u64, connection: Arc, frame: Com message, }); } + if frame.is_type(CommunicationType::MessageState) + && frame.get_str(DataType::MessageState) == Some("read") + { + if let Some(partner_id) = frame + .get_data(DataType::ChatPartnerId) + .as_number() + .and_then(|value| u64::try_from(value).ok()) + { + if let Err(error) = android_cancel_notification(partner_id) { + eprintln!("failed to clear read message notification: {error}"); + } + } + } if frame.is_type(CommunicationType::MessageLive) && !manager.ui_visible.load(Ordering::SeqCst) { if let Err(error) = notify_message(connection, &frame).await { eprintln!("failed to create background message notification: {error}"); @@ -533,7 +555,15 @@ async fn notify_message( .or_else(|| user.get_str(DataType::Username)) .map(str::to_owned) .unwrap_or_else(|| format!("User {sender_id}")); - android_notify(sender_id, &sender, &String::from_utf8_lossy(&plaintext)); + let avatar = user + .get_str(DataType::Avatar) + .and_then(|avatar| decode_browser_base64(avatar).ok()); + android_notify( + sender_id, + &sender, + &String::from_utf8_lossy(&plaintext), + avatar.as_deref(), + )?; Ok(()) } @@ -834,6 +864,26 @@ pub fn mtp_set_ui_visible(visible: bool) { manager().set_ui_visible(visible); } +#[tauri::command] +pub fn mtp_post_message_notification( + sender_id: u64, + sender: String, + body: String, + avatar: Option, +) -> Result { + #[cfg(target_os = "android")] + { + let avatar = avatar.as_deref().map(decode_browser_base64).transpose()?; + android_notify(sender_id, &sender, &body, avatar.as_deref())?; + Ok(true) + } + #[cfg(not(target_os = "android"))] + { + let _ = (sender_id, sender, body, avatar); + Ok(false) + } +} + #[cfg(not(target_os = "android"))] fn android_store_config(_: &str) -> Result<(), String> { Ok(()) @@ -853,7 +903,13 @@ fn android_set_enabled(_: bool) -> Result<(), String> { #[cfg(not(target_os = "android"))] fn android_status(_: &str) {} #[cfg(not(target_os = "android"))] -fn android_notify(_: u64, _: &str, _: &str) {} +fn android_notify(_: u64, _: &str, _: &str, _: Option<&[u8]>) -> Result<(), String> { + Ok(()) +} +#[cfg(not(target_os = "android"))] +fn android_cancel_notification(_: u64) -> Result<(), String> { + Ok(()) +} #[cfg(not(target_os = "android"))] fn android_is_ignoring_battery_optimizations() -> Result { Ok(true) @@ -974,24 +1030,49 @@ mod android { }); } - pub fn notify(sender_id: u64, sender: &str, body: &str) { - let _ = with_env(|env, host| { + pub fn notify( + sender_id: u64, + sender: &str, + body: &str, + avatar: Option<&[u8]>, + ) -> Result<(), String> { + with_env(|env, host| { let sender = env.new_string(sender).map_err(|e| e.to_string())?; let body = env.new_string(body).map_err(|e| e.to_string())?; + let avatar = env + .byte_array_from_slice(avatar.unwrap_or_default()) + .map_err(|e| e.to_string())?; env.call_method( host.bridge.as_obj(), "postMessageNotification", - "(Landroid/content/Context;JLjava/lang/String;Ljava/lang/String;)V", + "(Landroid/content/Context;JLjava/lang/String;Ljava/lang/String;[B)V", &[ JValue::Object(host.context.as_obj()), JValue::Long(sender_id as i64), JValue::Object(&sender), JValue::Object(&body), + JValue::Object(&avatar), ], ) .map_err(|e| e.to_string())?; Ok(()) - }); + }) + } + + pub fn cancel_notification(sender_id: u64) -> Result<(), String> { + with_env(|env, host| { + env.call_method( + host.bridge.as_obj(), + "cancelMessageNotification", + "(Landroid/content/Context;J)V", + &[ + JValue::Object(host.context.as_obj()), + JValue::Long(sender_id as i64), + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) + }) } pub fn is_ignoring_battery_optimizations() -> Result { @@ -1084,7 +1165,7 @@ mod android { #[cfg(target_os = "android")] use android::{ - has_config as android_has_config, + cancel_notification as android_cancel_notification, has_config as android_has_config, is_ignoring_battery_optimizations as android_is_ignoring_battery_optimizations, notify as android_notify, request_battery_exemption as android_request_battery_exemption, set_enabled as android_set_enabled, status as android_status, diff --git a/apps/tauri/src/deeplinkHandler.tsx b/apps/tauri/src/deeplinkHandler.tsx index 2094b73..dcbb5c7 100644 --- a/apps/tauri/src/deeplinkHandler.tsx +++ b/apps/tauri/src/deeplinkHandler.tsx @@ -9,11 +9,11 @@ import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { isTauri } from "@tauri-apps/api/core"; import { useIsMobile } from "@methanium/ui"; -type DeeplinkContextValue = { - deeplinks: readonly string[]; -}; -export const deeplinkContext = createContext( + +export const deeplinkContext = createContext<{ + deeplinks: readonly string[]; +} | undefined>( undefined, ); diff --git a/apps/web/src/index.tsx b/apps/web/src/index.tsx index 6fbeae6..60d5ac9 100644 --- a/apps/web/src/index.tsx +++ b/apps/web/src/index.tsx @@ -27,7 +27,7 @@ import { useCall, useInitializeCall } from "@tensamin/call/store"; import { useIsSpeaking } from "@tensamin/call/speakingState"; import { Provider as MTPProvider } from "@tensamin/mtp"; import UserProvider from "@tensamin/user/context"; -import DeeplinkContext from "@tensamin/tauri/deeplinkHandler"; +import DeeplinkContext, { useDeeplinks } from "@tensamin/tauri/deeplinkHandler"; import NotificationsProvider from "@tensamin/notifications/context"; import TAuthWrapper from "@tensamin/tauth/context"; @@ -288,6 +288,7 @@ function AppShell() { + @@ -309,6 +310,36 @@ function AppShell() { ); } +function DeeplinkNavigator() { + const { deeplinks } = useDeeplinks(); + const navigate = useNavigate(); + const handledCount = useRef(0); + + useEffect(() => { + const links = deeplinks.slice(handledCount.current); + handledCount.current = deeplinks.length; + + for (const link of links) { + try { + const url = new URL(link); + const id = Number(url.searchParams.get("id")); + if ( + url.protocol === "tensamin:" && + url.hostname === "chat" && + Number.isSafeInteger(id) && + id > 0 + ) { + void navigate({ to: "/chat", search: { id } }); + } + } catch { + // Ignore malformed URLs delivered by the platform. + } + } + }, [deeplinks, navigate]); + + return null; +} + function createCallTrayIcon(color: string, speaking: boolean) { const canvas = document.createElement("canvas"); canvas.width = 32; diff --git a/eslint.config.ts b/eslint.config.ts index df086d6..f9bc102 100644 --- a/eslint.config.ts +++ b/eslint.config.ts @@ -6,6 +6,7 @@ import * as tsParser from "@typescript-eslint/parser"; import { dirname } from "node:path"; import { fileURLToPath } from "node:url"; import { + inlineSingleUseDeclarations, noReactNamespaceImport, noWindowLocationReload, } from "./utils/eslint-rules/index.js"; @@ -34,6 +35,7 @@ export default [ "react-hooks": reactHooks, tensamin: { rules: { + "inline-single-use-declarations": inlineSingleUseDeclarations, "no-react-namespace-import": noReactNamespaceImport, "no-window-location-reload": noWindowLocationReload, }, @@ -42,6 +44,7 @@ export default [ rules: { ...reactHooks.configs.recommended.rules, "react-hooks/set-state-in-effect": "off", + "tensamin/inline-single-use-declarations": "error", "tensamin/no-react-namespace-import": "error", "tensamin/no-window-location-reload": "error", }, diff --git a/packages/call/src/mediaShare/controller.ts b/packages/call/src/mediaShare/controller.ts index a2fde87..f123834 100644 --- a/packages/call/src/mediaShare/controller.ts +++ b/packages/call/src/mediaShare/controller.ts @@ -17,22 +17,6 @@ type MediaShareStoreState = { cameraSession: LocalMediaShareSession | null; }; -type MediaShareStoreSetState = ( - updater: - | Partial - | ((state: MediaShareStoreState) => Partial), -) => void; - -type MediaShareControllerOptions = { - room: Room; - getState: () => MediaShareStoreState; - setState: MediaShareStoreSetState; - getLocalParticipantId: () => number | null; - startWatching: (participantId: number) => void; - stopWatching: (participantId: number) => void; - syncParticipantState: () => void; -}; - export function createMediaShareController({ room, getState, @@ -41,7 +25,19 @@ export function createMediaShareController({ startWatching, stopWatching, syncParticipantState, -}: MediaShareControllerOptions) { +}: { + room: Room; + getState: () => MediaShareStoreState; + setState: ( + updater: + | Partial + | ((state: MediaShareStoreState) => Partial), + ) => void; + getLocalParticipantId: () => number | null; + startWatching: (participantId: number) => void; + stopWatching: (participantId: number) => void; + syncParticipantState: () => void; +}) { function getSession(kind: MediaShareKind) { return kind === "screen" ? getState().screenShareSession diff --git a/packages/call/src/mediaShare/tauri.ts b/packages/call/src/mediaShare/tauri.ts index e88034e..7263599 100644 --- a/packages/call/src/mediaShare/tauri.ts +++ b/packages/call/src/mediaShare/tauri.ts @@ -8,32 +8,16 @@ import type { MediaShareSource, } from "./types"; -type MobileMediaApi = { - startScreenShare: (includeAudio: boolean) => void; - stopScreenShare: () => void; - requestCameraPermission: () => void; -}; - declare global { interface Window { - tensaminMobileMedia?: MobileMediaApi; + tensaminMobileMedia?: { + startScreenShare: (includeAudio: boolean) => void; + stopScreenShare: () => void; + requestCameraPermission: () => void; + }; } } -type FrameDetail = { - data: string; - mimeType: string; - width: number; - height: number; -}; - -type AudioDetail = { - data: string; - sampleRate: number; - channelCount: number; - encoding: "pcm16le"; -}; - function eventDetail(event: Event): T { return (event as CustomEvent).detail; } @@ -164,7 +148,12 @@ async function startMobileScreen( }; const onFrame = (event: Event) => { - const detail = eventDetail(event); + const detail = eventDetail<{ + data: string; + mimeType: string; + width: number; + height: number; + }>(event); const image = new Image(); image.onload = () => { if (canvas.width !== detail.width || canvas.height !== detail.height) { @@ -179,7 +168,14 @@ async function startMobileScreen( }; const onAudio = (event: Event) => { - const bytes = decodeBase64(eventDetail(event).data); + const bytes = decodeBase64( + eventDetail<{ + data: string; + sampleRate: number; + channelCount: number; + encoding: "pcm16le"; + }>(event).data, + ); const samples = new Int16Array( bytes.buffer, bytes.byteOffset, diff --git a/packages/call/src/speakingIndicator.ts b/packages/call/src/speakingIndicator.ts index a887516..94ff030 100644 --- a/packages/call/src/speakingIndicator.ts +++ b/packages/call/src/speakingIndicator.ts @@ -10,18 +10,19 @@ const SPEAKING_HANGTIME_MS = 500; const ANALYSIS_INTERVAL_MS = 30; const FFT_SIZE = 256; -type AnalyserEntry = { - source: MediaStreamAudioSourceNode; - analyser: AnalyserNode; - track: MediaStreamTrack; - originalTrack?: MediaStreamTrack; - lastSpeakingTime: number; - isSpeaking: boolean; -}; - class SpeakingDetector { private audioContext: AudioContext | null = null; - private entries = new Map(); + private entries = new Map< + number, + { + source: MediaStreamAudioSourceNode; + analyser: AnalyserNode; + track: MediaStreamTrack; + originalTrack?: MediaStreamTrack; + lastSpeakingTime: number; + isSpeaking: boolean; + } + >(); private intervalId: ReturnType | null = null; private deaf = false; private gateThresholdStart = -50; diff --git a/packages/call/src/speakingState.ts b/packages/call/src/speakingState.ts index b689bf5..c4f5198 100644 --- a/packages/call/src/speakingState.ts +++ b/packages/call/src/speakingState.ts @@ -1,12 +1,10 @@ import { create } from "zustand"; -type SpeakingState = { +const useSpeakingState = create<{ speakingParticipantIds: Set; lastSpeakingParticipantId: number | null; micGated: boolean; -}; - -const useSpeakingState = create(() => ({ +}>(() => ({ speakingParticipantIds: new Set(), lastSpeakingParticipantId: null, micGated: false, diff --git a/packages/call/src/store.tsx b/packages/call/src/store.tsx index 5def2b6..687919b 100644 --- a/packages/call/src/store.tsx +++ b/packages/call/src/store.tsx @@ -51,7 +51,6 @@ setLogExtension( getLogger("tensamin"), ); -type CallState = "closed" | "closing" | "connecting" | "open" | "encrypting"; type CallView = "preview" | "focused" | "grid"; type ProtocolCallSecret = NonNullable< z.infer["CallSecret"] @@ -63,18 +62,9 @@ type WrappedCallSecret = { kemCiphertext: Uint8Array; wrappingScheme: string; }; -type IncomingCallInvite = { - callId: string; - callSecret: WrappedCallSecret; - senderId: number; -}; type CurrentCallData = (z.infer & { exists: boolean }) | null; -type NavigateFn = (options: { - to: string; - search?: Record; -}) => Promise; type SendFn = ( type: string, data: Record, @@ -84,44 +74,15 @@ type GetUserFn = (userId: number) => Promise<{ PublicKey: string }>; type RemoteVideoTrackSelector = Track.Kind | Track.Source; type Runtime = { - navigate: NavigateFn; + navigate: (options: { + to: string; + search?: Record; + }) => Promise; send: SendFn; load: LoadFn; getUser: GetUserFn; }; -type CallStore = { - state: CallState; - view: CallView; - invitedUserId: number | null; - callId: string | null; - incomingCallInvite: IncomingCallInvite | null; - callSecret: string | null; - livekitToken: string | null; - currentCallData: CurrentCallData; - deaf: boolean; - micEnabled: boolean; - cameraEnabled: boolean; - screenShareEnabled: boolean; - screenShareSession: LocalMediaShareSession | null; - cameraSession: LocalMediaShareSession | null; - disabledCameraParticipantIds: number[]; - focusedParticipantId: number | null; - focusedParticipantType: "user" | "stream" | null; - usersInFocusedViewHidden: boolean; - watchedStreamParticipantIds: number[]; - pendingWatchedParticipantIds: number[]; - activeScreenShareParticipantIds: number[]; - isEncrypted: boolean; - ownCallSecretInvitePending: boolean; - callIsFullscreen: boolean; - callIsPopout: boolean; - layoutVersion: number; - screenRef: React.RefObject | null; - runtime: Runtime | null; - lastFocusedParticipantId: number | null; -}; - let _keyProvider: ExternalE2EEKeyProvider | null = null; let _e2eeWorker: Worker | null = null; let _room: Room | null = null; @@ -1195,7 +1156,41 @@ async function ensureNoiseFilter( } } -export const useCall = create(() => ({ +export const useCall = create<{ + state: "closed" | "closing" | "connecting" | "open" | "encrypting"; + view: CallView; + invitedUserId: number | null; + callId: string | null; + incomingCallInvite: { + callId: string; + callSecret: WrappedCallSecret; + senderId: number; + } | null; + callSecret: string | null; + livekitToken: string | null; + currentCallData: CurrentCallData; + deaf: boolean; + micEnabled: boolean; + cameraEnabled: boolean; + screenShareEnabled: boolean; + screenShareSession: LocalMediaShareSession | null; + cameraSession: LocalMediaShareSession | null; + disabledCameraParticipantIds: number[]; + focusedParticipantId: number | null; + focusedParticipantType: "user" | "stream" | null; + usersInFocusedViewHidden: boolean; + watchedStreamParticipantIds: number[]; + pendingWatchedParticipantIds: number[]; + activeScreenShareParticipantIds: number[]; + isEncrypted: boolean; + ownCallSecretInvitePending: boolean; + callIsFullscreen: boolean; + callIsPopout: boolean; + layoutVersion: number; + screenRef: React.RefObject | null; + runtime: Runtime | null; + lastFocusedParticipantId: number | null; +}>(() => ({ state: "closed", view: "preview", invitedUserId: null, diff --git a/packages/chat/src/components/gifPicker.tsx b/packages/chat/src/components/gifPicker.tsx index fbec74b..e44742b 100644 --- a/packages/chat/src/components/gifPicker.tsx +++ b/packages/chat/src/components/gifPicker.tsx @@ -34,35 +34,25 @@ function getColumnCount(width: number, itemCount: number) { type KlipyKind = "gif" | "meme"; -type KlipyMediaFile = { - url?: string; - width?: number; - height?: number; -}; - -type KlipyMediaFormats = Record; - type KlipyItem = { id: number | string; title?: string; - file?: Record; + file?: Record< + string, + | Record< + string, + | { + url?: string; + width?: number; + height?: number; + } + | undefined + > + | undefined + >; blur_preview?: string; }; -type KlipyPage = { - items: KlipyItem[]; - currentPage: number; - hasNext: boolean; -}; - -type KlipyResponse = { - data?: { - data?: KlipyItem[]; - current_page?: number; - has_next?: boolean; - }; -}; - type PickerMedia = { key: React.Key; url: string; @@ -105,7 +95,11 @@ async function fetchKlipyPage({ kind: KlipyKind; page: number; search: string; -}): Promise { +}): Promise<{ + items: KlipyItem[]; + currentPage: number; + hasNext: boolean; +}> { const params = new URLSearchParams({ page: String(page), per_page: String(pageSize), @@ -128,7 +122,13 @@ async function fetchKlipyPage({ throw new Error(`Klipy request failed with status ${response.status}`); } - const body = (await response.json()) as KlipyResponse; + const body = (await response.json()) as { + data?: { + data?: KlipyItem[]; + current_page?: number; + has_next?: boolean; + }; + }; const data = body.data; return { diff --git a/packages/chat/src/components/input.tsx b/packages/chat/src/components/input.tsx index 5d6088a..e54235d 100644 --- a/packages/chat/src/components/input.tsx +++ b/packages/chat/src/components/input.tsx @@ -66,6 +66,13 @@ export default function InputComponent({ return () => cancelAnimationFrame(frame); }, [userId]); + useEffect(() => { + if (replyTo === undefined) return; + + const frame = requestAnimationFrame(() => composerRef.current?.focus()); + return () => cancelAnimationFrame(frame); + }, [replyTo]); + useEffect(() => { const focusComposerOnType = (event: KeyboardEvent) => { const composer = composerRef.current; diff --git a/packages/chat/src/context.tsx b/packages/chat/src/context.tsx index 78c1bb2..5b6e796 100644 --- a/packages/chat/src/context.tsx +++ b/packages/chat/src/context.tsx @@ -145,8 +145,6 @@ type SendMessageGet = ( data: { SendTime: number }, ) => Promise<{ data: RawMessage }>; -type GetChatSecret = (userId: number) => Promise; - type StoredDraftState = ChatDraft & { accountId: number; userId: number; @@ -204,7 +202,7 @@ export async function fetchReplyMessage({ ownId: number; chatUserId: number; send: SendMessageGet; - getChatSecret: GetChatSecret; + getChatSecret: (userId: number) => Promise; }) { const message = await getMessage({ sendTime: replyTo, diff --git a/packages/chat/src/screen.tsx b/packages/chat/src/screen.tsx index 8b3778e..0587d54 100644 --- a/packages/chat/src/screen.tsx +++ b/packages/chat/src/screen.tsx @@ -21,12 +21,6 @@ import { } from "./values"; import Wrapper from "@tensamin/user/wrapper"; -type MessageChunk = { - key: string; - messages: Array; - startIndex: number; -}; - function shouldFetchPreviousPage({ entry, hasNextPage, @@ -108,7 +102,11 @@ function buildMessageChunks( keyPrefix: string, startOffset = 0, ) { - const chunks: MessageChunk[] = []; + const chunks: { + key: string; + messages: Array; + startIndex: number; + }[] = []; for (let end = messages.length; end > 0; end -= MESSAGES_PER_VIRTUAL_ROW) { const start = Math.max(0, end - MESSAGES_PER_VIRTUAL_ROW); @@ -282,7 +280,7 @@ export default function Screen() { getItemKey, estimateSize, overscan: 2, - paddingStart: composerHeight + 8, + paddingStart: composerHeight + 20, }); const totalSize = virtualizer.getTotalSize(); const contentHeight = Math.max(totalSize, viewportHeight); diff --git a/packages/markdown/src/emojiData.ts b/packages/markdown/src/emojiData.ts index f1ed5ab..158b5b0 100644 --- a/packages/markdown/src/emojiData.ts +++ b/packages/markdown/src/emojiData.ts @@ -1,7 +1,5 @@ import shortcodeData from "emojibase-data/en/shortcodes/joypixels.json"; -type ShortcodeValue = string | string[]; - export type EmojiDefinition = { aliases: readonly string[]; hexcode: string; @@ -17,7 +15,7 @@ function normalizeName(value: string) { } export const emojis: readonly EmojiDefinition[] = Object.entries( - shortcodeData as Record, + shortcodeData as Record, ).map(([hexcode, value]) => { const aliases = Array.isArray(value) ? value : [value]; const name = aliases[0]; diff --git a/packages/markdown/src/input.tsx b/packages/markdown/src/input.tsx index 58ebe9f..e16c84d 100644 --- a/packages/markdown/src/input.tsx +++ b/packages/markdown/src/input.tsx @@ -76,10 +76,6 @@ export type InputProps = { onControllerChange?: (controller: InputController | null) => void; }; -type InputStyle = CSSProperties & { - "--tm-md-content-padding"?: string; -}; - function toCssLength(value: CSSProperties["padding"]): string | undefined { if (value === undefined) { return undefined; @@ -99,11 +95,6 @@ function toCssPadding( return `${toCssLength(vertical) ?? defaultVertical} ${toCssLength(horizontal) ?? defaultHorizontal}`; } -type TokenRange = { - from: number; - to: number; -}; - const hiddenTokenDecoration = Decoration.mark({ class: "tm-md-hidden-token" }); const strongDecoration = Decoration.mark({ class: "tm-md-strong" }); const emDecoration = Decoration.mark({ class: "tm-md-em" }); @@ -443,7 +434,9 @@ export default function Input(props: InputProps) { props.paddingX, Boolean(props.styled), ), - } as InputStyle + } as CSSProperties & { + "--tm-md-content-padding"?: string; + } } /> ); @@ -834,7 +827,10 @@ function buildDecorations(view: EditorView): DecorationSet { function addHiddenToken( builder: Range[], selections: ReadonlyArray<{ from: number; to: number }>, - token: TokenRange, + token: { + from: number; + to: number; + }, ): void { if (token.from >= token.to) return; diff --git a/packages/markdown/src/markdown.tsx b/packages/markdown/src/markdown.tsx index 95b4a87..bc64692 100644 --- a/packages/markdown/src/markdown.tsx +++ b/packages/markdown/src/markdown.tsx @@ -1,4 +1,11 @@ -import { Fragment, type ReactElement, type ReactNode } from "react"; +import { + Fragment, + useEffect, + useRef, + useState, + type ReactElement, + type ReactNode, +} from "react"; import Emoji from "./emoji"; import { findEmojiShortcodes } from "./emojiData"; @@ -23,43 +30,11 @@ type InlineTokenRange = { to: number; }; -type ParagraphBlock = { - type: "paragraph"; - text: string; -}; - -type HeadingBlock = { - type: "heading"; - level: number; - text: string; -}; - -type HrBlock = { - type: "hr"; -}; - -type BlockQuoteBlock = { - type: "blockquote"; - text: string; -}; - -type CodeBlock = { - type: "code"; - language: string; - code: string; -}; - type ListItem = { text: string; checked: boolean | null; }; -type ListBlock = { - type: "list"; - ordered: boolean; - items: ListItem[]; -}; - type TableBlock = { type: "table"; headers: string[]; @@ -67,17 +42,140 @@ type TableBlock = { }; type MarkdownBlock = - | ParagraphBlock - | HeadingBlock - | HrBlock - | BlockQuoteBlock - | CodeBlock - | ListBlock + | { + type: "paragraph"; + text: string; + } + | { + type: "heading"; + level: number; + text: string; + } + | { + type: "hr"; + } + | { + type: "blockquote"; + text: string; + } + | { + type: "code"; + language: string; + code: string; + } + | { + type: "list"; + ordered: boolean; + items: ListItem[]; + } | TableBlock; const INLINE_TOKEN_REGEX = /!\[([^\]]*)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|\[([^\]]+)\]\(([^)\s]+(?:\s+"[^"]*")?)\)|`([^`\n]+)`|~~([^~\n]+)~~|\*\*([^*\n]+)\*\*|__([^_\n]+)__|\*([^*\n]+)\*|(? + + Copied + + ); +} + +function CopyableCode({ + block = false, + language, + value, +}: { + block?: boolean; + language?: string; + value: string; +}) { + const [copied, setCopied] = useState(false); + const copiedTimer = useRef | undefined>( + undefined, + ); + + useEffect( + () => () => { + clearTimeout(copiedTimer.current); + }, + [], + ); + + async function copy() { + await navigator.clipboard.writeText(value); + setCopied(true); + clearTimeout(copiedTimer.current); + copiedTimer.current = setTimeout(() => setCopied(false), 1200); + } + + const code = ( + void copy()} + onKeyDown={(event) => { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + void copy(); + }} + > + {value} + + ); + + if (block) { + return ( +
+
{code}
+ +
+ ); + } + + return ( + <> + {code} + + + ); +} + /** * Executes parseInlineNodes. * @param input Parameter input. @@ -429,11 +527,7 @@ function renderInline(nodes: InlineNode[]): ReactNode[] { } if (node.type === "code") { - return ( - - {node.value} - - ); + return ; } if (node.type === "link") { @@ -523,11 +617,12 @@ export function renderBlocks(blocks: MarkdownBlock[]): ReactElement { if (block.type === "code") { return ( -
-              
-                {block.code}
-              
-            
+ ); } @@ -666,7 +761,7 @@ function readTable( } const markdownStyles = ` -.tm-md-root { color: hsl(var(--foreground)); line-height: 1.65; font-size: 1rem; } +.tm-md-root { color: var(--foreground); line-height: 1.65; font-size: 1rem; } .tm-md-heading { margin: 0.2rem 0 0.35rem; font-weight: 700; line-height: 1.25; } .tm-md-h1 { font-size: 1.65rem; } .tm-md-h2 { font-size: 1.45rem; } @@ -676,13 +771,15 @@ const markdownStyles = ` .tm-md-h6 { font-size: 0.95rem; opacity: 0.9; } .tm-md-blockquote { margin: 0.45rem 0; padding-left: 0.75rem; opacity: 0.95; } .tm-md-blockquote p { margin: 0.2rem 0; } -.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: hsl(var(--muted)); overflow-x: auto; } -.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; } -.tm-md-code { padding: 0.08rem 0.32rem; border-radius: 0.28rem; background: hsl(var(--muted)); } +.tm-md-pre { margin: 0.45rem 0; padding: 0.65rem 0.75rem; border-radius: 0.5rem; background: var(--muted); overflow-x: auto; } +.tm-md-code, .tm-md-codeblock { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 0.87em; cursor: pointer; } +.tm-md-code { padding: 0.08rem 0.32rem; border: 1px solid var(--border); border-radius: 0.28rem; background: var(--muted); } +.tm-md-codeblock { display: block; } +.tm-md-code:focus-visible, .tm-md-codeblock:focus-visible { outline: 2px solid var(--ring); outline-offset: 2px; } .tm-md-strong { font-weight: 700; } .tm-md-em { font-style: italic; } .tm-md-del { text-decoration: line-through; } -.tm-md-link { color: hsl(var(--primary)); text-decoration: underline; text-underline-offset: 0.14rem; } +.tm-md-link { color: var(--primary); text-decoration: underline; text-underline-offset: 0.14rem; } .tm-md-image { display: block; max-width: 100%; border-radius: 0.4rem; margin: 0.5rem 0; } .tm-md-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; } .tm-md-ul, .tm-md-ol { margin: 0.3rem 0 0.35rem 1.2rem; padding: 0; } @@ -691,7 +788,7 @@ const markdownStyles = ` .tm-md-table-wrap { overflow-x: auto; margin: 0.45rem 0; } .tm-md-table { border-collapse: collapse; width: 100%; min-width: 16rem; } .tm-md-table th, .tm-md-table td { padding: 0.4rem 0.5rem; text-align: left; } -.tm-md-table th { background: hsl(var(--muted)); font-weight: 600; } +.tm-md-table th { background: var(--muted); font-weight: 600; } .tm-md-hr { margin: 0.55rem 0; } .cm-editor.tm-md-editor { border-radius: inherit; background: transparent; caret-color: var(--foreground); } @@ -699,10 +796,10 @@ const markdownStyles = ` .cm-editor.tm-md-editor .cm-scroller { font-family: inherit; line-height: 1.55; max-height: 30vh; overflow-y: auto; overflow-x: hidden; } .cm-editor.tm-md-editor .cm-content { caret-color: var(--foreground); } .cm-editor.tm-md-editor .cm-content { padding: var(--tm-md-content-padding, 0.25rem 0.625rem); min-height: 2rem; } -.cm-editor.tm-md-editor .cm-line { padding: 0; color: hsl(var(--foreground)); } +.cm-editor.tm-md-editor .cm-line { padding: 0; color: var(--foreground); } .cm-editor.tm-md-editor .tm-md-editor-emoji { display: inline-block; width: 1.15em; height: 1.15em; vertical-align: -0.18em; object-fit: contain; pointer-events: none; } .cm-editor.tm-md-editor .tm-md-hidden-token { color: transparent; opacity: 0; font-size: inherit; } -.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: hsl(var(--muted)); border-radius: 0.3rem; } +.cm-editor.tm-md-editor .tm-md-code-line { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; background: var(--muted); border-radius: 0.3rem; } .cm-tooltip.cm-tooltip-autocomplete { min-width: 18rem; max-width: min(26rem, calc(100vw - 1rem)); overflow: hidden; border: 1px solid var(--border); border-radius: var(--radius); background: var(--popover); color: var(--popover-foreground); box-shadow: 0 4px 6px -1px rgb(0 0 0 / 0.1), 0 2px 4px -2px rgb(0 0 0 / 0.1); font-family: "Public Sans Variable", sans-serif; font-size: 0.875rem; } .cm-editor.tm-md-editor .cm-tooltip.cm-tooltip-autocomplete > ul { max-height: min(20rem, 45vh); padding: 0.25rem; font-family: "Public Sans Variable", sans-serif; scrollbar-width: thin; scrollbar-color: var(--border) transparent; } .cm-tooltip.cm-tooltip-autocomplete > ul::-webkit-scrollbar { width: 6px; height: 6px; } @@ -726,10 +823,15 @@ export function ensureMarkdownStyles(): void { if (typeof document === "undefined") return; const styleId = "tensamin-markdown-styles"; - if (document.getElementById(styleId)) return; + let style = document.getElementById(styleId) as HTMLStyleElement | null; - const style = document.createElement("style"); - style.id = styleId; - style.textContent = markdownStyles; - document.head.appendChild(style); + if (!style) { + style = document.createElement("style"); + style.id = styleId; + document.head.appendChild(style); + } + + if (style.textContent !== markdownStyles) { + style.textContent = markdownStyles; + } } diff --git a/packages/mtp/src/context.tsx b/packages/mtp/src/context.tsx index 2c9f1e5..69010a9 100644 --- a/packages/mtp/src/context.tsx +++ b/packages/mtp/src/context.tsx @@ -631,16 +631,6 @@ type NativeSnapshot = { error?: string; }; -type NativeEvent = - | { kind: "state"; snapshot: NativeSnapshot } - | { kind: "message"; generation: number; message: unknown } - | { - kind: "log"; - level: number; - message: string; - details?: unknown; - }; - function TauriProvider(props: { children: ReactNode; blockConnection?: boolean; @@ -713,7 +703,16 @@ function TauriProvider(props: { let disposed = false; let unlisten: UnlistenFn | undefined; void (async () => { - unlisten = await listen("mtp://event", ({ payload }) => { + unlisten = await listen< + | { kind: "state"; snapshot: NativeSnapshot } + | { kind: "message"; generation: number; message: unknown } + | { + kind: "log"; + level: number; + message: string; + details?: unknown; + } + >("mtp://event", ({ payload }) => { if (disposed) return; if (payload.kind === "state") { applySnapshot(payload.snapshot); diff --git a/packages/notifications/src/context.tsx b/packages/notifications/src/context.tsx index 453d4e7..a63b906 100644 --- a/packages/notifications/src/context.tsx +++ b/packages/notifications/src/context.tsx @@ -5,7 +5,7 @@ import { useMTP } from "@tensamin/mtp"; import { createContext, useEffect, useContext } from "react"; import { toast as sonnerToast } from "sonner"; import { Avatar, AvatarFallback, AvatarImage } from "@methanium/ui"; -import { isTauri } from "@tauri-apps/api/core"; +import { invoke, isTauri } from "@tauri-apps/api/core"; import { isPermissionGranted as isTauriNotificationPermissionGranted, requestPermission as requestTauriNotificationPermission, @@ -108,7 +108,30 @@ export default function Provider(props: { children: React.ReactNode }) { (await requestTauriNotificationPermission()) === "granted"; if (permissionGranted) { - sendTauriNotification({ title: user.Display, body: content }); + let handledNatively = false; + try { + handledNatively = await invoke( + "mtp_post_message_notification", + { + senderId: user.UserId, + sender: user.Display, + body: content, + avatar: user.Avatar, + }, + ); + } catch (error) { + log( + 1, + "notifications", + "red", + "Failed to create native message notification", + error, + ); + } + + if (!handledNatively) { + sendTauriNotification({ title: user.Display, body: content }); + } } } else { const hasPermissions = await requestNotificationPermission(); diff --git a/packages/onboarding/src/index.tsx b/packages/onboarding/src/index.tsx index da7971e..5efa9c9 100644 --- a/packages/onboarding/src/index.tsx +++ b/packages/onboarding/src/index.tsx @@ -21,10 +21,10 @@ export { type OnboardingStepControls, } from "@methanium/ui"; -type LegalDocs = z.infer; + interface GateState { - docs: LegalDocs; + docs: z.infer; acceptedPP: boolean; acceptedTOS: boolean; includeLegal: boolean; diff --git a/packages/onboarding/src/pages/legal.tsx b/packages/onboarding/src/pages/legal.tsx index 495d143..86c8133 100644 --- a/packages/onboarding/src/pages/legal.tsx +++ b/packages/onboarding/src/pages/legal.tsx @@ -5,7 +5,7 @@ import type { z } from "zod"; import { useOnboardingStep } from "@methanium/ui"; -type LegalDocs = z.infer; + export default function LegalPage({ docs, @@ -13,7 +13,7 @@ export default function LegalPage({ initiallyAcceptedTOS, onAccept, }: { - docs: LegalDocs; + docs: z.infer; initiallyAcceptedPP: boolean; initiallyAcceptedTOS: boolean; onAccept: () => Promise; diff --git a/packages/settings/src/components.tsx b/packages/settings/src/components.tsx index 315ba78..f2ca317 100644 --- a/packages/settings/src/components.tsx +++ b/packages/settings/src/components.tsx @@ -5,9 +5,7 @@ import { storageDefaults, type Storage } from "@tensamin/shared/data"; import { settingsStorageDefaults } from "@tensamin/shared/settings"; import { useStorage } from "@tensamin/storage/context"; -type BooleanStorageKey = { - [K in keyof Storage]: Storage[K] extends boolean ? K : never; -}[keyof Storage]; + type ListStorageKey = { [K in keyof Storage]: Storage[K] extends (string | number)[] ? K : never; @@ -21,7 +19,9 @@ export function Switch({ id, }: { label: React.ReactNode; - id: keyof typeof settingsStorageDefaults & BooleanStorageKey; + id: keyof typeof settingsStorageDefaults & ({ + [K in keyof Storage]: Storage[K] extends boolean ? K : never; +}[keyof Storage]); }) { const { save, load } = useStorage(); const [value, setValue] = useState(settingsStorageDefaults[id]); diff --git a/packages/shared/src/data.ts b/packages/shared/src/data.ts index 4dc5cfd..8480258 100644 --- a/packages/shared/src/data.ts +++ b/packages/shared/src/data.ts @@ -143,25 +143,7 @@ export type Contacts = z.infer; export type Communities = z.infer; export type Calls = z.infer; -type Base16Palette = Record< - | "base00" - | "base01" - | "base02" - | "base03" - | "base04" - | "base05" - | "base06" - | "base07" - | "base08" - | "base09" - | "base0A" - | "base0B" - | "base0C" - | "base0D" - | "base0E" - | "base0F", - string ->; + // MTP const user = z.object({ @@ -472,7 +454,25 @@ export interface Storage extends SettingsStorageDefaults { call_mute_range_start: number; call_mute_range_end: number; theme_color: string; - theme_palette: Base16Palette | null; + theme_palette: Record< + | "base00" + | "base01" + | "base02" + | "base03" + | "base04" + | "base05" + | "base06" + | "base07" + | "base08" + | "base09" + | "base0A" + | "base0B" + | "base0C" + | "base0D" + | "base0E" + | "base0F", + string +> | null; theme_primary_color: string; theme_polarity: "dark" | "light" | "system"; theme_tint: "soft" | "hard" | "extreme"; diff --git a/packages/shared/src/desktopMedia.tsx b/packages/shared/src/desktopMedia.tsx index 75550cd..6799966 100644 --- a/packages/shared/src/desktopMedia.tsx +++ b/packages/shared/src/desktopMedia.tsx @@ -22,7 +22,11 @@ export type DesktopScreenShareCapabilities = { hasReliableSystemAudio: boolean; }; -type ElectronDesktopApi = { + + +declare global { + interface Window { + tensaminDesktop?: { media?: { getScreenShareCapabilities?: () => Promise; listScreenShareSources?: () => Promise; @@ -53,10 +57,6 @@ type ElectronDesktopApi = { clear?: () => Promise; }; }; - -declare global { - interface Window { - tensaminDesktop?: ElectronDesktopApi; } } diff --git a/packages/shared/src/settings.ts b/packages/shared/src/settings.ts index 0c3116b..6fd4d2a 100644 --- a/packages/shared/src/settings.ts +++ b/packages/shared/src/settings.ts @@ -1,14 +1,14 @@ type StringKeyOf = Extract; -type SettingDefinition = { - display: string; - type: string; - default?: unknown; -}; + export type SettingsSchema = Record< string, - Record> + Record> >; const settings = { diff --git a/utils/eslint-rules/index.ts b/utils/eslint-rules/index.ts index 8dee264..ea4eea7 100644 --- a/utils/eslint-rules/index.ts +++ b/utils/eslint-rules/index.ts @@ -72,3 +72,211 @@ export const noWindowLocationReload: Rule.RuleModule = { }; }, }; + +interface AstNode { + type: string; + parent: AstNode | null; + range: [number, number]; +} + +interface TypeAliasDeclaration extends AstNode { + type: "TSTypeAliasDeclaration"; + typeAnnotation: AstNode; + typeParameters?: unknown; +} + +interface FunctionDeclaration extends AstNode { + type: "FunctionDeclaration"; + async: boolean; + generator: boolean; + params: AstNode[]; + body: AstNode & { body: AstNode[] }; +} + +interface ReturnStatement extends AstNode { + type: "ReturnStatement"; + argument: AstNode | null; +} + +function isExported(node: AstNode): boolean { + return ( + node.parent?.type === "ExportNamedDeclaration" || + node.parent?.type === "ExportDefaultDeclaration" + ); +} + +function containsContextSensitiveNode(value: unknown): boolean { + if (!value || typeof value !== "object") return false; + + const node = value as { type?: string; [key: string]: unknown }; + if ( + node.type === "ThisExpression" || + node.type === "Super" || + node.type === "MetaProperty" + ) { + return true; + } + + return Object.entries(node).some( + ([key, child]) => + key !== "parent" && + key !== "loc" && + key !== "range" && + containsContextSensitiveNode(child), + ); +} + +const unambiguousInlineTypes = new Set([ + "TSAnyKeyword", + "TSBigIntKeyword", + "TSBooleanKeyword", + "TSIntrinsicKeyword", + "TSLiteralType", + "TSNeverKeyword", + "TSNullKeyword", + "TSNumberKeyword", + "TSObjectKeyword", + "TSStringKeyword", + "TSSymbolKeyword", + "TSThisType", + "TSTupleType", + "TSTypeLiteral", + "TSTypeReference", + "TSUndefinedKeyword", + "TSUnknownKeyword", + "TSVoidKeyword", +]); + +export const inlineSingleUseDeclarations: Rule.RuleModule = { + meta: { + type: "suggestion", + docs: { + description: "Inline local types and functions that are used only once", + }, + fixable: "code", + messages: { + function: "Inline this function at its only call site.", + type: "Inline this type at its only use site.", + }, + schema: [], + }, + create(context) { + const sourceCode = context.sourceCode; + + return { + TSTypeAliasDeclaration(untypedNode: Rule.Node) { + const node = untypedNode as unknown as TypeAliasDeclaration; + if (node.typeParameters || isExported(node)) return; + + const eslintNode = node as unknown as Rule.Node; + const [variable] = sourceCode.getDeclaredVariables(eslintNode); + if (!variable || variable.references.length !== 1) return; + + const reference = variable.references[0] + .identifier as unknown as AstNode; + if ( + reference.range[0] >= node.range[0] && + reference.range[1] <= node.range[1] + ) { + return; + } + + const referenceParent = reference.parent; + if ( + !referenceParent || + referenceParent.type !== "TSTypeReference" || + referenceParent.parent?.type === "TSClassImplements" || + referenceParent.parent?.type === "TSInterfaceHeritage" + ) { + return; + } + + context.report({ + node: eslintNode, + messageId: "type", + fix(fixer) { + const annotation = sourceCode.getText( + node.typeAnnotation as unknown as Rule.Node, + ); + const replacement = unambiguousInlineTypes.has( + node.typeAnnotation.type, + ) + ? annotation + : `(${annotation})`; + + return [ + fixer.replaceText( + referenceParent as unknown as Rule.Node, + replacement, + ), + fixer.remove(eslintNode), + ]; + }, + }); + }, + + FunctionDeclaration(untypedNode: Rule.Node) { + const node = untypedNode as unknown as FunctionDeclaration; + if ( + node.async || + node.generator || + node.params.length !== 0 || + node.body.body.length !== 1 || + isExported(node) + ) { + return; + } + + const statement = node.body.body[0] as ReturnStatement; + if (statement.type !== "ReturnStatement" || !statement.argument) return; + + const eslintNode = node as unknown as Rule.Node; + const functionScope = sourceCode.getScope(eslintNode); + if ( + functionScope.references.length !== 0 || + functionScope.through.length !== 0 || + containsContextSensitiveNode(statement.argument) + ) { + return; + } + + const [variable] = sourceCode.getDeclaredVariables(eslintNode); + if (!variable || variable.references.length !== 1) return; + + const reference = variable.references[0] + .identifier as unknown as AstNode; + const call = reference.parent; + if ( + !call || + call.type !== "CallExpression" || + (call as AstNode & { callee: AstNode }).callee !== reference || + (call as AstNode & { arguments: AstNode[] }).arguments.length !== 0 || + (call as AstNode & { optional?: boolean }).optional || + (reference.range[0] >= node.range[0] && + reference.range[1] <= node.range[1]) + ) { + return; + } + + context.report({ + node: eslintNode, + messageId: "function", + fix(fixer) { + const expression = sourceCode.getText( + statement.argument! as unknown as Rule.Node, + ); + const replacement = + statement.argument!.type === "Literal" + ? expression + : `(${expression})`; + + return [ + fixer.replaceText(call as unknown as Rule.Node, replacement), + fixer.remove(eslintNode), + ]; + }, + }); + }, + }; + }, +}; diff --git a/utils/scripts/lint-packages.ts b/utils/scripts/lint-packages.ts index 941594c..0aea8c3 100644 --- a/utils/scripts/lint-packages.ts +++ b/utils/scripts/lint-packages.ts @@ -36,7 +36,7 @@ for (const targetDir of targetDirs) { const entry = relative(rootDir, fullPath); console.log(`Linting ${entry}...`); try { - execSync("pnpm run lint", { cwd: fullPath, stdio: "inherit" }); + execSync("pnpm run lint --fix", { cwd: fullPath, stdio: "inherit" }); console.log(`${entry} linted successfully.`); } catch { console.error(`Failed to lint ${entry}.`);