From b151ef8c072ae783aa32a03ff5068efbd8e59023 Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 9 Oct 2026 10:37:44 -0400 Subject: [PATCH 1/7] nobilis: Discord request timeouts, history fetch guard, gateway tally Co-Authored-By: Claude Sonnet 5.5 --- nobilis | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nobilis b/nobilis index baa37ab..a34821e 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit baa37abde4a4e261bc96cd2683d4e18e4e691745 +Subproject commit a34821e6b1cb9889ceb00d51d70f3e8480bfd4a6 From 24106e51f4ab565eef6ad1b0a80c85da19ffcc13 Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 9 Oct 2026 11:00:10 -0400 Subject: [PATCH 2/7] Tray: a bubble drawn for the system's colour scheme with the number waiting on it; a menu of what is new, Status, notifications, Settings, Restart moho and Quit; no daemon plumbing (#330) Co-Authored-By: Claude Sonnet 5.5 --- .github/workflows/release.yml | 6 + package.json | 1 + resources/icons/tray-alert.png | Bin 4280 -> 0 bytes resources/icons/tray-dark-1.png | Bin 0 -> 1691 bytes resources/icons/tray-dark-2.png | Bin 0 -> 2014 bytes resources/icons/tray-dark-3.png | Bin 0 -> 2124 bytes resources/icons/tray-dark-4.png | Bin 0 -> 1882 bytes resources/icons/tray-dark-5.png | Bin 0 -> 2012 bytes resources/icons/tray-dark-6.png | Bin 0 -> 2159 bytes resources/icons/tray-dark-7.png | Bin 0 -> 1847 bytes resources/icons/tray-dark-8.png | Bin 0 -> 2226 bytes resources/icons/tray-dark-9.png | Bin 0 -> 2194 bytes resources/icons/tray-dark-more.png | Bin 0 -> 2052 bytes resources/icons/tray-dark.png | Bin 0 -> 797 bytes resources/icons/tray-light-1.png | Bin 0 -> 1632 bytes resources/icons/tray-light-2.png | Bin 0 -> 1968 bytes resources/icons/tray-light-3.png | Bin 0 -> 2071 bytes resources/icons/tray-light-4.png | Bin 0 -> 1830 bytes resources/icons/tray-light-5.png | Bin 0 -> 1968 bytes resources/icons/tray-light-6.png | Bin 0 -> 2125 bytes resources/icons/tray-light-7.png | Bin 0 -> 1800 bytes resources/icons/tray-light-8.png | Bin 0 -> 2176 bytes resources/icons/tray-light-9.png | Bin 0 -> 2145 bytes resources/icons/tray-light-more.png | Bin 0 -> 2008 bytes resources/icons/tray-light.png | Bin 0 -> 771 bytes resources/icons/tray.png | Bin 2633 -> 0 bytes scripts/lib/harness.mjs | 4 +- scripts/make-tray-icons.mjs | 52 +++++++ scripts/tray-check.mjs | 159 +++++++++++++++++++ src/main/index.ts | 232 +++++++++++++++++++--------- src/main/notifications.test.ts | 34 +++- src/main/notifications.ts | 36 +++-- src/main/tray.test.ts | 79 ++++++++++ src/main/tray.ts | 110 +++++++++++++ src/preload/index.ts | 12 ++ src/renderer/src/state/store.ts | 17 ++ src/shared/ipc.ts | 6 +- 37 files changed, 655 insertions(+), 93 deletions(-) delete mode 100644 resources/icons/tray-alert.png create mode 100644 resources/icons/tray-dark-1.png create mode 100644 resources/icons/tray-dark-2.png create mode 100644 resources/icons/tray-dark-3.png create mode 100644 resources/icons/tray-dark-4.png create mode 100644 resources/icons/tray-dark-5.png create mode 100644 resources/icons/tray-dark-6.png create mode 100644 resources/icons/tray-dark-7.png create mode 100644 resources/icons/tray-dark-8.png create mode 100644 resources/icons/tray-dark-9.png create mode 100644 resources/icons/tray-dark-more.png create mode 100644 resources/icons/tray-dark.png create mode 100644 resources/icons/tray-light-1.png create mode 100644 resources/icons/tray-light-2.png create mode 100644 resources/icons/tray-light-3.png create mode 100644 resources/icons/tray-light-4.png create mode 100644 resources/icons/tray-light-5.png create mode 100644 resources/icons/tray-light-6.png create mode 100644 resources/icons/tray-light-7.png create mode 100644 resources/icons/tray-light-8.png create mode 100644 resources/icons/tray-light-9.png create mode 100644 resources/icons/tray-light-more.png create mode 100644 resources/icons/tray-light.png delete mode 100644 resources/icons/tray.png create mode 100644 scripts/make-tray-icons.mjs create mode 100644 scripts/tray-check.mjs create mode 100644 src/main/tray.test.ts create mode 100644 src/main/tray.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3e54bb3..9364fcc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -270,6 +270,12 @@ jobs: - name: layout check run: xvfb-run -a npm run test:ui + # The tray cannot be looked at, so this reads what main gave it - icon, + # tooltip, menu - and presses its entries, including Restart moho, which + # has to bring back both the window and a daemon of its own. + - name: tray check + run: xvfb-run -a npm run test:tray + # MOHO_BUILD is computed inside the npm script - a shell assignment on # the electron-builder line - so it cannot be injected from out here. # The only way to control what the artifact is called is to guarantee diff --git a/package.json b/package.json index eb35974..36d1652 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "typecheck": "tsc --noEmit -p tsconfig.node.json && tsc --noEmit -p tsconfig.web.json", "test": "vitest run", "test:ui": "electron-vite build && node scripts/ui-check.mjs", + "test:tray": "electron-vite build && node scripts/tray-check.mjs", "shots": "electron-vite build && node scripts/ui-shots.mjs", "pack": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux AppImage", "pack:deb": "npm run daemon && npm run build && MOHO_BUILD=$(git rev-parse --short HEAD)$(git diff --quiet HEAD || echo -dirty) electron-builder --linux deb", diff --git a/resources/icons/tray-alert.png b/resources/icons/tray-alert.png deleted file mode 100644 index 3267894888f2907a852b22ea520a3961b8f9de74..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 4280 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 literal 0 HcmV?d00001 diff --git a/resources/icons/tray-dark-2.png b/resources/icons/tray-dark-2.png new file mode 100644 index 0000000000000000000000000000000000000000..d94dcbd04758c08744dc87cbd3c53db2d14dee15 GIT binary patch literal 2014 zcmV<42O;>0P)V>Z2~S-Rh21ITZ&TO0`d|RYX{a_rmQO7 zQPYr!K-MLoY2LhCz16=$gvEf7iVg)qL9Gt>Z&sJ=-RtjTZ1$jO)>Ea*P#cvnae!1F z42t8MowRabGb;Q4uOH7v*wdwYFhhyH4=qGniMd z#L=uSSTLvf{mu0;xdRnIf6z4RsZvc>9}y9}Yx*>t9uGO5i8xBefwFfs*x;k}Z=2}Y z^MTC>b-|&99v;1Q>!(qiM4c=zpESut{SbHp7zgAhDjRK7{ra@G?nQvJm6fg>SHVGG za^#qMYBwjX*T4S&*I#=B2?k>rRfnx%&c}*3Z0Lw2YketoyWP&x2R*+vQOAH6fC=;? zc%Ti-SQ+j(a|?b!ui#UK>(S|qShOHEQ=~$>>3_L5IqnQ_yWLLB$g35YF%S*{2)ya@ z)-QpsSDmFFC&q3cIRmWgH|RWal;HU$BoM$TzL&fikE3#PV;B%x7QR5R@mwtPtq#pB zja&+GUo*;)w_0T2f(KBhweD71TZ*#xA>chl>t8qGKX3>vs|T<3z4eTIeI>@&vAql^ zT#rt-OTe&@saFFFyNBBu?^^T6NMM;H4prCSt(yYf72YLmik#fXAEV>Le}IUsHs{nS zjQr&*QS|m*ySeekYCEyEoE$YKTrt`P*R|mdVDKQw&TzY{ytS?HIzHGJH+X=+NpC7{ z2Da+T%jxP(*KLf7PiRXH2?z(YEgCazl07j*|d&UutgI!%%cTG8urK zKldwNjwXuGv2TA|IR&0@JKhu@ma1d+2wseEqNG2X^rE{dnE%}v2 zK{uOQxVC%+(%znwzO*L5G|hsD?#*Q7ol_<=^7U8IN8Z&_@28i!vUC}tJ~qDyd=_q` zbly%Kj(dQs00L(f6ecQ}_ry#_{P=m4q4%H%8_#iVSvBUh>&ZBBCfrEt3XN0&29H2pxSifTK=GwpZEbg-<28*Dixx2Axo4w}!yUW! zaP6ndvD%Vb^IgVwgu;{A`bN;w{|OWWh-DFM z@TIIibSdiY^P*+QywaN5&S+O`Fj$*lglV6ITopdwp_>3rPd@F@U<=5-18k_t(zH3n zTQ~hX7Bhoq0dXs%bLiHp$lc!Uo;EzZ)OsUZ5`e&I6yNrptUnS-Z?2p-ESTFdM?}rggejsZq3iQF za6<`a(bQ4Pl07+{uC1f@ZfK2^f1rF)g@#(+;H@u z>=aAwSB5&BL-0J%3ZGxFn7XT2S@H!zrXw;7)c9CR>=KZfLMS;*K$xJEEXEW9MBWML zU|QUIQptbpj`tc&>R+4ehS*-e#rrVQ=iTz{19jexW1j$};vc$iBM7qHj%S+y3)TYW zpm>yCnCQkjJEm^OxPIkw^Ie5SqZK?dIDVxKYkVi`{$$6Ddlqo&p%)Q$#T%35*f~7k zu_WFA+Zh1R($Zp%DJuL+Nc1}uRFL_@Q=%||HN*2A-#dKxaEu>@ZXw|pr+fV537W+- zg3RLcpQHxi<7vOp3N#9`9o<;xJAU*;B0_`qn_M!Lq`-2YxBlmY(^b literal 0 HcmV?d00001 diff --git a/resources/icons/tray-dark-3.png b/resources/icons/tray-dark-3.png new file mode 100644 index 0000000000000000000000000000000000000000..f832cee37b1098468ca089ddcb914e03af866db5 GIT binary patch literal 2124 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 AEC2ui literal 0 HcmV?d00001 diff --git a/resources/icons/tray-dark-9.png b/resources/icons/tray-dark-9.png new file mode 100644 index 0000000000000000000000000000000000000000..98280ad09f9e392db37363f1450641c2d203e87a GIT binary patch literal 2194 zcmV;D2yOR?P)NrA5I<+`DFdgcQRdmJfa82EaBN9|D3Bu|44L+%&tU#y^a0@}PP7tWE=+; z4z+VFDPz)xrw~r(us-!}A2%zj2z+$LWKeNZPj@cP-@UshQmhQ;*zI;Z3+H-%uOU7J zegu>-hU9^6bldac2d69JISi6ddGG-yZ~Spg$pa{9sZ6QbjJ?opY#?Q+(&_*CP_g9< zaJ$`3#g=+d!jhPxhyfCQ=dG{X2>qWrT|L8))n2$#n6~p}l*y9@^$lLU#GO}m5o)=L zZALB`-&_-waPZQ{-1y;7%+`f2yP_3Fz6zEzK-g2a-_7I!gr@KOw5>dY!KW^D>5{1A zWk};@<#YItogmcQjQ_;HY2UmB83+urv(NeruCj;BhEES^>ZVA6DFMaB3l<>x{;(O1 z2%@&B>7#SdSK;*+IZ<$yem+VTsI5h7Z5<@jZ*|c7{(quuQy*P!8i5q7ZL6rr2tS(= z(7)gfyGD!z5_u%t?k{f*D?BxKT9hnwz16_dSnep9hj6*f!bl4Q_44q8MS^mfxo(t@ z@-3j)MUbKU4*6Vwj`VX*Rtp?5~_XVmG_ z4Q$fQ#weN7Gap-i{ver}lY_Ig)X;~L zZuzAim>#3rY>XDFsz&~_nPzr9N8cswiIszeLNKLjGhMImA=KQ0nm2>=uYLt#Qw@EH z6w@^TAS>L6D*>@0aIT)7qcymelns)r({jjq>IX6TkbcWe-~jIGDnA@FP0NUQ?6k7& z*uI0_4^EAGEWNACN_{)rh${hLA6S_rT(q*3fdK8ldY0RN_#;~TO+y>)+I2d%y@-A% zdeIzOGYr9na3ij-KuA&yoCfGS#uE2icF=XY|GtmzH>$DEnT>7cOq8@#WN$CQOCJ;T zo=45jM#;#C>KAA*KM;~o8*ayU4G?@0*xlDw-U0c&nEFXc@cadW=PyL5T#LUHB?~lp zkv^YQAa93TaeV~@VxYLP`<>N5_gMTXlP8n@wN+7a-~NME-MSrq=YT<+*8n^Pb3O9` za%y&W0k4@EI=@g#>Lbe#smAA_wwd`%d43DZq^v=5?b>yE-hIzJ0xAF2KX#v=5ruyj zAf)^@@KXR?lUr3)td$UT)ML9hkF53U$a-=;y>+JvU2Z||>_o}Tz+Ny1=RyyNA-j29 z=hknxMePtH5U|?_$0~jU@@GSh(!N|l*7|j^ zd(|{LUVI7P;UiX7FXX+s?-!ARc)tPwX;K7#8GZ=(J2wHx$Kp_HZKJ)iYPjT&`9!F4 zs4x*T03ak4vB-P2_7p&Cd3kP7SKkK)ihWytKBE%9#2OHZC$3&^|4Ug}nGIn2!Gmo+CG-fAEsU*32uS3weIm5PDtSo!3<#rI zk-gt&XlNKH>RZdpb3>ZCNeb2y`!RgCgk5%3-#Tso{(-X5?S3FL{HhH_vJilTMg(iT z=juL)l(&|xP7k_zR!S+BD3Z@g!EE}k$AJzZxric8>AF1X=uJ5gS>yl|6fY=M5U)1X z*A*F!n=}OUBZS~3RR*f+>+9Rc;;5*obgEPWn?O7Xs7>{ChPfsi0bz7Wm zP42pkQ83ptUzOrJvTvg`aIE*gMnELaAUGm*@h@Q$jShmBfiCDfVA0f+e4)$xB=SL| zTn=KMkrOillJZDw2uhHY2q8D1NdiO`L@H=HpZ-!KzwEa9436l(HpvZXdi-W}G15F9 zp(F<4vYE!503qHQmsk=2i9BqkGbKQWKLOgfL=<0WsJ5MEO5(9=lXwz5fb9LY?xZ?rNnodE!CZEf20+^MgHq&itZDT&`a z#R?7Bk(p-y*2$A6gI0zye>mOq7M3VF-<8Pa^!-U{94=4Cgf8HcL>@-9?ev~GbvBkx z(^b07*qoM6N<$g5Q}Twg3PC literal 0 HcmV?d00001 diff --git a/resources/icons/tray-dark-more.png b/resources/icons/tray-dark-more.png new file mode 100644 index 0000000000000000000000000000000000000000..d5dc0b7725e4c2678ce97586be5aaa151b64b02f GIT binary patch literal 2052 zcmV+f2>bVmP)ER;t=D0ywN zk9&XqL3Xp7&2Bc^o849O{p&n_&+k3w+;i@^FoyqcsB{M~9NL#JFX$6v1#p)@g@PFh z#sLW25IC-MO=CB=$3wfQ!}DrOMR4k5ZolauQVY09!0mo-TT0S92l? z8Arf{)vH~4ci)pDJPH`e*ihgX)FxkF=epuUhx&$*%^X}>-PfOnVQGar%Hfm`jhtS(xxqG->~h9P_Hb(S11EL~Zm>FO}HCm)2m=+FWS z3u|hwMD-+UqO5G*JQMX0@G)QpkehTksAtYL)jtMsuBs~AnVtWpR*+wza&nR0UaXFe zWKG9$s_Lw~tUHUgZ|@Cn9(Iv&yWLsJk@JixJOXGogwlg$`~*NmZu1_kd(|i|uFyX) zAtYdRbkKYF2;Ez@VP3mtgG)f9QtRwn1FR2kjynR}Zg-Yur;01F{!=D5T{*T1WfZ4T*VhxtOx!4>$GdPtv~T zhl53WoK7_*xMPf&T>pY+WAY%#J`r&ME5h5m|FjeFcy7uB0(5WtV}xE-tc*Wv!4ZE+|l50>K%@w_;FNy2{#xB^7Hi?hnhBcz<=^P)7hapUa_B*bP? zhmB;R>w1?`$|oYP0iOxB=iYZOR#z9k(@hw+O{d_&2cmRUPUNUi+1WT}-5xFH%nFsY z{5-PeS47E(*EgO$uicc76>Ldaxg?O&h~U)Ctj5MhpNN!L-92Zuyu>$?eg8Ph^>rIV zb)%?=!snk$DvrRV%i@?eRRfz>`Qc!iWi*jW^8JwxIo&Z{PS_WL$nOgeZSM|N zQ#%C!CGzD&!p|Nbni0^`Du7Vsz(3{^+;)HknBmXy@idczx2J_P_!e9Jw%Vm?6ep+kKR&0Gas7)6hlEJIcgP9D-l z02s<*o7=s(0HCm@=8A<{3F_i-ib>#trD=B;?b`M3P@U-_01&q_dVg0{Rb>Da?b+F& zDgOe5=&f7wIO5REMMX7#J{e0OjRXMj$xBz;e-$KQN^NbMM+# zONP1&2uFGr!D#H##C!jlGiO34p4RH>$pKSeCyFnU_#H%#z{?KZdOCcyce@uCw6wg{ zIg~=C5P-m06rXQ7-Eb_ty|rp}p5N8GLPRamggZo0O#k&ba6<{_(A06ul7mifc1`#u z2ViE|yh;tVrMamg@^I0Qj3J;O0Tj>a;(M~GsVRv~zyL~1D<_KPTLsWy$S=d<2oDpk@zcBo+ZF-wG#zIS6xv%k#K+F*U(AL&w zPMJJ$Ye4kz3Mxte;VDs=z_SIpjtA=M>iqEp6Mk{JXIIS8EWRShQf~evH42xf?+e|) zc|rD~8=G4ikDp3JY1Dp`8_7;m;E9%|hQxjWu^DL}(&g8-)h0000vfW literal 0 HcmV?d00001 diff --git a/resources/icons/tray-dark.png b/resources/icons/tray-dark.png new file mode 100644 index 0000000000000000000000000000000000000000..e8a40ca2f592066f1e56c36e51a94d87272fa812 GIT binary patch literal 797 zcmV+&1LFLNP)_sNVQNcQbB7ETUnH(g5X`Ns7JxOhYFr; zy<2ZyiXM9K(4M^bABeU39~5otwiP3k=A;!1{s2?6881PkWTr9Qo!RI-$Nj$DyziUY z9cBg`4u`|xaQv@`b{oU6REiNdfiYkh=uJyhhkOS%5G*uc_Wj!On|5y6Gk_on3L;;8 zfaDh7?N_BCVu^TE@aq#%6z%95wE+Y{Q1EiarxMO5tIrG)UKafLsxAcWKPvLYhuvx( zK){7soSWXGr!~!Y{x#h*>{Z0l6Qh;YrKnX=X|DOf>vr=1zSm$}Rn!=i7t>H<1Btw( zs%ZicLurVy#0gbFH^dL5AjB32RfT`y9p4u&0NVm=;{vcPz&0*`#GWLn0@poR>+@u- zPY8Jpcx$w`1C(olk+3Y=eu|{plZ($pQS|xG%R{A7e_=-~Avm6T?;gBcUvB9&nGqnk zy;~%}=E};pL{1v%?T|zQ@MLXXS&WO%O!Rh2>L0;Iqu%zmTUxz35(z+hJ!8rr$k#8M z=SCz^D z8ZZmQsVFi_jGvoR6@67<`_qSyhXw|Z2soFHE@RA8)|MWtirQJA;MXURyhvV?F&6rI z^LKO&$`JstwY3!=9UOR4mwvy1vvkk283QwYz4;r9i;F*XjcxC$1gAz%dkw~$N6o`z z@9^{+upyE2__?`C^lG)8n-bYpYQ^6yrzK2P)|c<4rA3GJA#KwIh00000NkvXXu0mjfe0^$> literal 0 HcmV?d00001 diff --git a/resources/icons/tray-light-1.png b/resources/icons/tray-light-1.png new file mode 100644 index 0000000000000000000000000000000000000000..ce5c51c0c2c0ce5b98c928be0b2f6be29ff65f82 GIT binary patch literal 1632 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 B{0sm9 literal 0 HcmV?d00001 diff --git a/resources/icons/tray-light-4.png b/resources/icons/tray-light-4.png new file mode 100644 index 0000000000000000000000000000000000000000..a1b1d423d88baaa817a658a16f4ca86ee0f4e0bd GIT binary patch literal 1830 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 literal 0 HcmV?d00001 diff --git a/resources/icons/tray-light-9.png b/resources/icons/tray-light-9.png new file mode 100644 index 0000000000000000000000000000000000000000..6cb6535bb52fd665a47ee2fbf9042aac29ccd852 GIT binary patch literal 2145 zcmV-n2%h(eP))+QKIl^=psu%-oVE25%+D98fJ;_B}F z+F@qiJN;vsb#~{yx3j-y*0A4Xl6%iN_uO;7^X`4;-S=P=7dNER0rZ8oMT??o^)Ch% zD9jOa|)v@Bg3_9fD5RJjN655$K+R+06YbZo`cy?fJr*p3{uEWCBP_+^VIvxdm! z{;UR;Oxd~RT{nXfL%``p&7VefCs6OCmtc^d35EJ}X(v;BuxbsN*A6)wi2Tp43C(TTyt%VbZ1f$ghC1TO-&iUygM1H| zIjH1?q+mao?}ozW12`mKb=~I}d++y4N*=(7R8zNRC4uQzI2))o8nN_Gio@}Ms)pi; z&K-)*FDZ%=penx}6Ro}%F5K#D-ywSK_7bjQ{H8}R#*XPRHhcU8@xMGu?$jClmrlU^ z`t3ytXHUGt```V6+rE$p7}oT{rQmr5#1g6XLzz5)PpnP(wphk}INa*07tAk8UUL>7 zta^a-z8A=~w2*$`KeVk}jb<`E>;h9hL3rNvZqwJ~EZ>Skfhz%tMB-}T+kGX}9|#;Y zP4f-ts_-rpIYkIHex^v4Id~AOwY7)L{;-|%FaEd4w)*CUt}&>v>|ArRneV$2(6!<9 zy+`y1w#x2&`$Bo^SmF65j4zVq&bB&OI?IDIuR_9Mw=g1^tlgCFcqAw{y6XlA;e~?U z0yi@wTva^Oa1K>`4ssC!(=K-nLsh!x_WFk>d*}uPzu1SngK*gS^RQ#Z&ojP>6CG`R z6DK&&HR)2TijD9Lfx8`Bp%;FuH_WINtaUdP5KZycs`&tAgw=k<{L#*d@+?(dZ3L(`rQf!!F=UA`~G<8iZ~8}qVl zU;i-YUpg@0x$H#3OWl!gl$8L(FuHdph2vhfn#s`i^ZV)e-S4s5K5(?L-aAYCx((Rz z;!U%6&Cm;P=Nn~R0y#B43A_r>b&Ms~cn+{_l7D}S;b6X9Rsuxr&jXvgI>xi0ca}6zmF!z@lYQ&$B2{?q7m8$= zH;dt54rW;SvsG4-XjS8>xH?y{PZh> zA%Ly&4d6!rwxw%*K3S$vbkyU&Y!bE0@1XXs<(z-zRdOd!VaH+^QIo*5%L&by4btmr z-nRMhSL=$N5DSp;`}Lthf$ModM?9(h%&jyCNwKXd(P;F2fYY}${}S}az0D$DY^HYk z9i{uVEZR40B(-a|m(@d)o_^}5g@Up!0RWVUit)32HWraMnzMS4;z6&yrVj7MVk@ zlis(V)Q+8Amp#B4+ppI)Y~6agx1v%6036F@(v5X>buoaJTbgGZunqLGB1;jB+9*ae ziUfn$9Ua&mvA&Ydqs2Ci#SPn^c-2W+NdiE4DjKO?A|g3}cNZsdcr@U%*N)$|t-EY&Yio<<~q^{$RmO3BZlZ7P1AIF z*X4|OJU+`-`6eQFQgE#IaU-A*hd_5L#=lkUqlRUjj7Fmg=sIAr45LBRxKgmM16c@M z<>ch!Ou&%9jR3Fr0{!~dW`x7__|1a=Z5fV8DglL)ZaOalo*9l{Dgja5<)(8bz(D^1 zSi=%gd0`o%o7|MH1em7z8t~g;38}2mjizb7?xyYQ1BqZP(YYP;hM^CvEZ7~bscsUH zteb)7!;pw%qBY?r;9)*q%fSNO&{y)%kB_9{@%T(zBfz=%tJSkVzrYQb-1rQ<0(F6!i=n}CZmd4P_1a#f{c7{w??F^W-)!W;hw X&pZp00Urub00000NkvXXu0mjfwz)2c literal 0 HcmV?d00001 diff --git a/resources/icons/tray-light-more.png b/resources/icons/tray-light-more.png new file mode 100644 index 0000000000000000000000000000000000000000..cc880cd34e63a0a797b584d30c4f2352c9908330 GIT binary patch literal 2008 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 literal 0 HcmV?d00001 diff --git a/resources/icons/tray.png b/resources/icons/tray.png deleted file mode 100644 index 70755194f19838a9829506e056341e97aa8489d6..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2633 zcmV-P3byr$P)C)<5g`Qk4G*mE92$m_2SZR=cCLv=pVK zr5LFv3~PYb>!lqX9jv~-KKalizggdW=(C!0=Q?FIqH;f)=b*8j4p>F;|xIy$6SEXJo5Orte5&*AplZx@mzkwhW^Eki?qF$SGZ2hO?p z^w6hd)263z^ypD;G#W`>-URZ~1q)X1+WB&w*Xz{*NDVbH4rzeb>(%*uzU0*REX?&YnH)2B||{r>w0C;R>WB!I|JR_Qv-njnKysF zSg>G$=U3V=)C=?>0P%y8n>zoUK2m}IyhWxMtI2wxN#NsiN z5CRnsR6vp>B1A^kJ7aB;@4SMKYNJ=VaJ!J8Tc2Ix4uPK~WSaiULj3 z#*G1-b66}EVYAu5xdurh!*<=Y-eKcG__q;4K$0ZLvW(G&ZDwEMR}mq^sN#YDV*}D9 zYe1$0$TV34G95ss$r_O905VP1fJ_IFX|e`nI)JN5I(wM^TLU=fNTpK1C?*VR0Oy=g z$`Fl4p{gn(x3p^2!<~~|uKkK)Ch>TjY}mK~(P$KUz5bds zLDMua#=scE#*G_DFc>0MtCdWhGWB3d$&7Z7$0Gw6R2dp%fZy-eii(Or2oX2@XvS-K zdAa9}CKG9CY2oYEKL#NL%w{t*O#|mV?aT}CO_pXXZAQBv=Nu_51(VSTgTa96>P`6Y z;D^F!G$JP_CpzQ$>t9^DbZJDd*VCbjq{B^usmjVq-NudUkIkAjdsAL+E{sMaIdI?r zce|INwY3#Cn+=MhK$4`d%VWcd;{2lVvMfWEWk`|)Ns=xr*GLF~BuOxtOvuTxfWT}vi-Ff`x@HVqn@XjKs;cO^(1pEw_K>&tzl~5RBut8l+H>rfJ$ugHM;==K zNKH*me54Zjh)?x;y;4(C6PlWui08L!RvkHf_^I>f&pX25FhZda3BPl_`1Miv)RBH8}o1&D1L-OAq0a# z(%07qtJMmt)e5K68M(RS=9hl4bWzRj-Mb@2MMdP2&Ib(Ob~iA)y^+?}*C(s1pP6;y z+kYmyp^8r0!Ki2qR-z~CUpAv#o zEv3=?{CvFC@D|ptT?3s?<|dPk&YU^3b>>YsZqc~t$=kp~m<=#W$^?Iq->(%wYPQFG6A>IGL!*AOh z6U4?%zCh0F#=3QnL6RhHFc@fY zanX@G?!2S?@v5q}l`B`8RaH${t=22HvUE5cqN8JyaJkxr&*vL-2x270TW!Cw+Mx#!oI{p%_~OVH zM3N+7Qj`(JUrSt(gb3#s|4>PiG@VX2Zo~supa+;tals^-5<&6 z@x0qA#*=v9G7NCJT-wf^JNfkKa}!ew3SKrFP0i0dQ{CEs$Z}kWzao75)YbP_i9Z+` r8XDLSRy=TVpOEPovSi7U` + : `` + const size = label && label.length > 1 ? 24 : 30 + const text = badge + ? `${label}` + : '' + return `${mask}${text}` +} + +mkdirSync(out, { recursive: true }) +const labels = [null, '1', '2', '3', '4', '5', '6', '7', '8', '9', '9+'] +for (const [name, ink] of [['light', '#f1f3f5'], ['dark', '#25282e']]) { + for (const label of labels) { + const file = join(out, `tray-${name}${label === null ? '' : `-${label === '9+' ? 'more' : label}`}.png`) + const tmp = `${file}.svg` + writeFileSync(tmp, svg(ink, label)) + execFileSync('rsvg-convert', ['-w', String(SIZE), '-h', String(SIZE), '-o', file, tmp]) + rmSync(tmp) + } +} +console.log('tray icons written to', out) diff --git a/scripts/tray-check.mjs b/scripts/tray-check.mjs new file mode 100644 index 0000000..c9945e0 --- /dev/null +++ b/scripts/tray-check.mjs @@ -0,0 +1,159 @@ +#!/usr/bin/env node +/** + * Checks the tray against the running app: what its icon, tooltip and menu say + * for what is waiting, what its entries do, and that "Restart moho" brings back + * moho and a daemon of its own. + * + * A tray cannot be photographed, so this reads what main last gave it through a + * hook that exists only under MOHO_UI_SHOTS, and presses its entries the same + * way. Main is reached over the Node inspector the app is started with. + * + * npm run test:tray (builds first; needs the daemon built too) + */ +import fs from 'node:fs' +import { cdp, freePort, launchApp, sleep, until } from './lib/harness.mjs' + +let failures = 0 +const check = (ok, what, detail = '') => { + console.log(`${ok ? 'ok ' : 'FAIL'} ${what}${ok ? '' : detail ? ` - ${detail}` : ''}`) + if (!ok) failures++ +} + +async function mainProcess(inspectPort) { + for (let i = 0; i < 60; i++) { + try { + const list = await (await fetch(`http://127.0.0.1:${inspectPort}/json/list`)).json() + const target = list.find((t) => t.webSocketDebuggerUrl) + if (target) return cdp(target.webSocketDebuggerUrl) + } catch { + /* not up yet */ + } + await sleep(500) + } + throw new Error('main never offered its inspector') +} + +/** The nobilis processes that belong to this run, by the runtime directory in their environment. */ +function daemons(scratch) { + const out = [] + for (const entry of fs.readdirSync('/proc')) { + if (!/^\d+$/.test(entry)) continue + try { + const exe = fs.readlinkSync(`/proc/${entry}/exe`) + if (!exe.endsWith('/nobilis')) continue + if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`XDG_RUNTIME_DIR=${scratch}/run`)) out.push(Number(entry)) + } catch { + /* gone, or not ours */ + } + } + return out +} + +// A run that hangs is a failure, and must not leave an app behind it. +setTimeout(() => { + console.log('FAIL timed out') + process.exit(1) +}, 150000).unref() + +const inspectPort = await freePort() +const { page, stop, port } = await launchApp({ env: { MOHO_UI_SHOTS: '1' }, args: [`--inspect=${inspectPort}`] }) +let scratchDir = '' +try { + const main = await mainProcess(inspectPort) + const tray = (expr) => main.evaluate(`(() => { const t = globalThis.__mohoTray; return ${expr} })()`) + await until(main, 'globalThis.__mohoTray', 'the tray') + scratchDir = await main.evaluate('process.env.HOME') + + // Nothing waiting. + await tray('t.update({ unread: 0, dms: 0, mentions: 0 })'.replace('t.', 't.')) + check(/^tray-(light|dark)\.png$/.test(await tray('t.icon()')), 'idle: the plain bubble', await tray('t.icon()')) + check((await tray('t.menu()[0].label')) === 'Nothing new', 'idle: the menu says nothing is new') + check((await tray('t.tooltip()')) === 'moho', 'idle: the tooltip is just moho') + + // Something waiting: the count is on the icon, and the menu says what. + await tray('t.update({ unread: 3, dms: 2, mentions: 1 })') + check(/^tray-(light|dark)-3\.png$/.test(await tray('t.icon()')), 'waiting: the icon carries the count', await tray('t.icon()')) + check((await tray('t.tooltip()')) === 'moho - 2 direct messages, 1 mention', 'waiting: the tooltip says what', await tray('t.tooltip()')) + check((await tray('t.menu()[0].label')) === '2 direct messages, 1 mention - open', 'waiting: the menu says what', await tray('t.menu()[0].label')) + await tray('t.update({ unread: 40, dms: 40, mentions: 0 })') + check(/-more\.png$/.test(await tray('t.icon()')), 'a great many: the icon says 9+') + await tray('t.update({ unread: 0, dms: 0, mentions: 0 })') + + // The entries, and that none of them is daemon plumbing. + const labels = await tray('t.menu().map((i) => i.label ?? "-")') + check(!labels.some((l) => /daemon/i.test(l)), 'the menu offers no daemon plumbing', labels.join(' | ')) + check(labels.includes('Restart moho'), 'the menu offers Restart moho') + + // Settings opens in the window. + await tray('t.click(["Settings"])') + await until(page, 'window.__mohoShots.state().activePanel === "settings"', 'Settings to open') + check(true, 'Settings opens the settings page') + + // A status goes to the window to carry out. + await page.evaluate('window.__trayGot = []; window.moho.onTrayCommand((c, a) => window.__trayGot.push([c, a])); true') + await tray('t.click(["Status", "Do not disturb"])') + await until(page, 'window.__trayGot.length', 'the status to reach the window') + check(JSON.stringify(await page.evaluate('window.__trayGot[0]')) === '["status","dnd"]', 'a status is handed to the window') + + // Desktop notifications is a real setting, shared with Settings. + await tray('t.click(["Desktop notifications"])') + await until(page, 'window.moho.prefs.getAll().then((p) => p["notifications.desktop"] === false)', 'the setting to change') + check(true, 'Desktop notifications toggles the setting') + check((await tray('t.menu().find((i) => i.label === "Desktop notifications").checked')) === false, 'and the menu shows it off') + + // Restart brings back moho and a daemon of its own. + const before = daemons(scratchDir) + const pidBefore = await main.evaluate('process.pid') + check(before.length === 1, 'one daemon is running', String(before)) + // Not waited for: the app quits under this call, and the inspector with it. + void tray('t.click(["Restart moho"])').catch(() => {}) + // And the inspector let go, because a process that is being inspected waits + // for the inspector to disconnect before it will exit. + await sleep(500) + main.close() + let pidAfter = pidBefore + let again = null + for (let i = 0; i < 80 && pidAfter === pidBefore; i++) { + await sleep(500) + try { + again = await mainProcess(inspectPort) + pidAfter = await again.evaluate('process.pid') + } catch { + /* between the two */ + } + } + check(pidAfter !== pidBefore, 'moho came back as a new process', `${pidBefore} -> ${pidAfter}`) + await sleep(4000) + const after = daemons(scratchDir) + check(after.length === 1 && !before.includes(after[0]), 'with a daemon of its own, the old one gone', `${before} -> ${after}`) + // Whatever it started has to go with this run. + try { + for (const pid of daemons(scratchDir)) process.kill(pid, 'SIGTERM') + } catch { + /* gone */ + } +} catch (e) { + console.log('FAIL', e.message) + failures++ +} finally { + await stop() + // The relaunched copy is not the one the harness started, so it is not in the + // group stop() ended. It is found by the home it was given, which everything + // it starts - its daemon, and the helpers Chromium runs that name no port - + // carries in its environment; twice, since it may still be starting. + for (let i = 0; i < 2; i++) { + await sleep(1500) + for (const entry of fs.readdirSync('/proc')) { + if (!/^\d+$/.test(entry) || Number(entry) === process.pid || !scratchDir) continue + try { + if (fs.readFileSync(`/proc/${entry}/environ`, 'utf8').includes(`HOME=${scratchDir}\0`)) process.kill(Number(entry), 'SIGKILL') + } catch { + /* gone, or not ours to read */ + } + } + } + await sleep(500) + if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true }) +} +console.log(failures ? `${failures} failed` : 'all passed') +process.exit(failures ? 1 : 0) diff --git a/src/main/index.ts b/src/main/index.ts index d5721fa..f46b5ca 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -11,18 +11,21 @@ import { globalShortcut, ipcMain, nativeImage, + nativeTheme, net, session, Menu, screen, shell, - Tray + Tray, + type MenuItemConstructorOptions } from 'electron' import { electronApp, is, optimizer } from '@electron-toolkit/utils' import { defaultSocketPath, NobilisClient } from './nobilis-client' import { NobilisProcess } from './nobilis-process' import { Prefs } from './prefs' import { Notifier } from './notifications' +import { NOTHING_WAITING, trayIconFile, trayMenu, trayTooltip, type TrayState, type TrayStatus } from './tray' import { browserLogin, LOGIN_FLOWS } from './browser-login' import { EDIT_ACTIONS, IPC, POPOUT_FLAG, UI_SHOTS_FLAG, type EditAction, type EditMenuRequest, type PopoutState } from '../shared/ipc' import { clearnetLinks } from '../shared/clearnet' @@ -190,6 +193,52 @@ function sniffImage(head: Buffer): string | null { } /** Every window with a renderer in it, main and popped-out conversations alike. */ +/** + * Sets a preference and tells whoever holds a copy of it. + * + * `except` is the window that made the change, which already knows and has + * drawn it. The tray's own entries come through here too, so a setting flipped + * from the tray moves its switch in Settings. + */ +function applyPref(key: string, value: unknown, except?: Electron.WebContents): void { + prefs.set(key, value) + // The badge and the flash are decided from these as they stand. + if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastTray) + if (key === 'notifications.desktop') refreshTrayMenu() + // Every window keeps its own cache of these, so a setting changed in one + // is stale in the others until they are told. That is not cosmetic once + // there are several windows: muting a conversation, or switching the log + // to compact, would apply to whichever window happened to be asked. + for (const w of liveWindows()) { + if (w.webContents !== except) w.webContents.send(IPC.prefsChanged, key, value) + } + // Pins and mutes feed the tray/notification rules, which live here. + if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish() + if (key === 'hotkey.toggle') applyHotkey(String(value)) +} + +/** + * Brings up a conversation: a click on its notification, or on the tray's + * "open" entry. + * + * If it has a window of its own, that window is what the click asked for. + * Raising the main one and switching it would move somebody away from whatever + * they were reading in order to show them a conversation that was already open + * on their screen. + */ +function openConversation(bufferId: string): void { + const popout = popouts.get(bufferId) + if (popout && !popout.isDestroyed()) { + if (popout.isMinimized()) popout.restore() + popout.show() + popout.focus() + return + } + mainWindow?.show() + mainWindow?.focus() + mainWindow?.webContents.send(IPC.activateBuffer, bufferId) +} + function liveWindows(): BrowserWindow[] { const all = mainWindow ? [mainWindow, ...popouts.values()] : [...popouts.values()] return all.filter((w) => !w.isDestroyed()) @@ -353,6 +402,9 @@ function createWindow(): void { pendingDeepLink = null mainWindow?.webContents.send(IPC.deepLink, url) }) + // The tray's first action is to show or hide this window, and says which. + mainWindow.on('show', refreshTrayMenu) + mainWindow.on('hide', refreshTrayMenu) mainWindow.on('maximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, true)) mainWindow.on('unmaximize', () => mainWindow?.webContents.send(IPC.maximizeChanged, false)) mainWindow.on('closed', () => { @@ -562,61 +614,107 @@ function toggleWindow(): void { } } -function trayIcon(hasAlert: boolean): Electron.NativeImage { - const file = resourcePath('icons', hasAlert ? 'tray-alert.png' : 'tray.png') - const img = nativeImage.createFromPath(file) +function trayIcon(unread: number): Electron.NativeImage { + lastTrayIcon = trayIconFile(nativeTheme.shouldUseDarkColors, unread) + const img = nativeImage.createFromPath(resourcePath('icons', lastTrayIcon)) // A missing icon file would otherwise produce an invisible tray entry the // user can never click; fall back to the app icon so the entry still exists. return img.isEmpty() ? nativeImage.createFromPath(resourcePath('icons', 'moho.png')) : img } -function createTray(): void { - tray = new Tray(trayIcon(false)) - tray.setToolTip('moho') - tray.setContextMenu( - Menu.buildFromTemplate([ - { label: 'Show/hide', click: toggleWindow }, - { label: 'Restart daemon', click: () => nobilis.restart() }, - { - label: 'Stop daemon', - // Deliberately separate from Quit: stopping the daemon disconnects - // every account, which is worth asking for explicitly rather than - // making it a side effect of closing a window. - click: () => { - void stopDaemon() - } +/** What the window last said the accounts' status is, so the menu can check it. */ +let trayStatus: TrayStatus | null = null + +/** + * Asks the window to do something, showing it first: the tray's entries that + * are about moho itself - settings, and a status set across every account - + * are the window's to carry out, since it is what holds the accounts. + */ +function tellWindow(command: 'settings' | 'status', arg?: string): void { + if (!mainWindow) createWindow() + if (command === 'settings') { + mainWindow?.show() + mainWindow?.focus() + } + mainWindow?.webContents.send(IPC.trayCommand, command, arg) +} + +/** + * Starts moho again, daemon and all. + * + * Quitting already takes the daemon down cleanly - QUIT to every network, then + * gone - and the new process starts its own, so restarting is quit-and-relaunch. + * An AppImage has to be relaunched by the path it was started from: its + * executable is a temporary mount that disappears when it exits. + */ +function restartMoho(): void { + const appImage = process.env.APPIMAGE + app.relaunch(appImage ? { execPath: appImage, args: process.argv.slice(1) } : undefined) + app.quit() +} + +/** What the tray last offered and showed, kept for the UI harness to read. */ +let lastTrayMenu: MenuItemConstructorOptions[] = [] +let lastTrayIcon = '' +let lastTrayTooltip = 'moho' + +function refreshTrayMenu(): void { + if (!tray) return + lastTrayMenu = trayMenu( + { + state: lastTray, + windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(), + status: trayStatus, + notifications: prefs.get('notifications.desktop', true) + }, + { + toggleWindow, + openLatest: () => { + const id = notifier.latestUnread() + if (id) openConversation(id) }, - { type: 'separator' }, - { - label: 'Quit', - click: () => { - app.quit() - } - } - ]) + openSettings: () => tellWindow('settings'), + setStatus: (status) => { + trayStatus = status + tellWindow('status', status) + }, + setNotifications: (on) => applyPref('notifications.desktop', on), + restart: restartMoho, + quit: () => app.quit() + } ) + tray.setContextMenu(Menu.buildFromTemplate(lastTrayMenu)) +} + +function createTray(): void { + tray = new Tray(trayIcon(0)) + tray.setToolTip('moho') + refreshTrayMenu() tray.on('click', toggleWindow) + // The icon is drawn for the panel it sits on, so it is drawn again when the + // system changes colour scheme. + nativeTheme.on('updated', () => updateTray(lastTray)) } /** What the notifier last said, kept so a change of setting can redraw without waiting for the next message. */ -let lastUnread = 0 -let lastAlert = false +let lastTray: TrayState = NOTHING_WAITING -function updateTray(unreadCount: number, hasAlert: boolean): void { - lastUnread = unreadCount - lastAlert = hasAlert - // The unread badge: the tray's alert dot and count, and the launcher's - // count where the desktop has one. Off, the tray is just the tray. +function updateTray(state: TrayState): void { + lastTray = state + // The unread badge: the tray's count and the launcher's count where the + // desktop has one. Off, the tray is just the tray. const badge = prefs.get('notifications.badge', true) - app.setBadgeCount(badge ? unreadCount : 0) + app.setBadgeCount(badge ? state.unread : 0) // The taskbar flash: asking for attention while the window is not in front. const flash = prefs.get('notifications.flash', true) if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.flashFrame(flash && hasAlert && !mainWindow.isFocused()) + mainWindow.flashFrame(flash && state.unread > 0 && !mainWindow.isFocused()) } + refreshTrayMenu() if (!tray) return - tray.setImage(trayIcon(badge && hasAlert)) - tray.setToolTip(badge && unreadCount > 0 ? `moho - ${unreadCount} unread` : 'moho') + tray.setImage(trayIcon(badge ? state.unread : 0)) + lastTrayTooltip = trayTooltip(state, badge) + tray.setToolTip(lastTrayTooltip) send(IPC.link, client.linkUp) } @@ -667,21 +765,13 @@ function wireIpc(): void { }) ipcMain.handle(IPC.prefsGetAll, () => prefs.all()) - ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => { - prefs.set(key, value) - // The badge and the flash are decided from these as they stand. - if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastUnread, lastAlert) - // Every window keeps its own cache of these, so a setting changed in one - // is stale in the others until they are told. That is not cosmetic once - // there are several windows: muting a conversation, or switching the log - // to compact, would apply to whichever window happened to be asked. - // Not echoed to the window that set it - it already knows, and has drawn. - for (const w of liveWindows()) { - if (w.webContents !== e.sender) w.webContents.send(IPC.prefsChanged, key, value) - } - // Pins and mutes feed the tray/notification rules, which live here. - if (key === 'pinnedBuffers' || key === 'mutedBuffers') notifier.publish() - if (key === 'hotkey.toggle') applyHotkey(String(value)) + ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender)) + + // The window says what status its accounts are at, for the tray's menu to check. + ipcMain.on(IPC.trayStatus, (_e, status: TrayStatus | null) => { + if (status === trayStatus) return + trayStatus = status + refreshTrayMenu() }) ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId)) @@ -1140,22 +1230,7 @@ app.whenReady().then(() => { notifier = new Notifier( prefs, updateTray, - (bufferId) => { - // If this conversation has a window of its own, that window is what the - // click asked for. Raising the main one and switching it would move - // somebody away from whatever they were reading in order to show them a - // conversation that was already open on their screen. - const popout = popouts.get(bufferId) - if (popout && !popout.isDestroyed()) { - if (popout.isMinimized()) popout.restore() - popout.show() - popout.focus() - return - } - mainWindow?.show() - mainWindow?.focus() - mainWindow?.webContents.send(IPC.activateBuffer, bufferId) - }, + openConversation, () => mainWindow?.webContents ?? null, (bufferId) => popoutState().watched.includes(bufferId) ) @@ -1204,6 +1279,25 @@ app.whenReady().then(() => { createWindow() createTray() applyHotkey(prefs.get('hotkey.toggle')) + // For the UI harness, which has no way to look inside a tray: what it shows + // and offers, and the entries themselves to be clicked. Only when it asks. + if (process.env.MOHO_UI_SHOTS === '1') { + ;(globalThis as Record).__mohoTray = { + update: updateTray, + icon: () => lastTrayIcon, + tooltip: () => lastTrayTooltip, + menu: () => lastTrayMenu, + click: (path: string[]) => { + let items: MenuItemConstructorOptions[] = lastTrayMenu + let item: MenuItemConstructorOptions | undefined + for (const label of path) { + item = items.find((i) => i.label === label || (i.label ?? '').startsWith(label)) + items = (item?.submenu as MenuItemConstructorOptions[]) ?? [] + } + item?.click?.({ checked: !item.checked } as never, undefined, {} as never) + } + } + } app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/src/main/notifications.test.ts b/src/main/notifications.test.ts index 724e62b..7b195bf 100644 --- a/src/main/notifications.test.ts +++ b/src/main/notifications.test.ts @@ -19,13 +19,14 @@ vi.mock('electron', () => { }) import { Notifier } from './notifications' +import type { TrayState } from './tray' const payload = { accountId: 'a', bufferId: 'b', title: 'alice', body: 'hi' } -function notifier(settings: Record): { n: Notifier; alerts: [number, boolean][] } { - const alerts: [number, boolean][] = [] +function notifier(settings: Record): { n: Notifier; alerts: TrayState[] } { + const alerts: TrayState[] = [] const prefs = { get: (key: string, fallback: T): T => (key in settings ? (settings[key] as T) : fallback) } - const n = new Notifier(prefs as never, (count, alert) => alerts.push([count, alert]), () => {}) + const n = new Notifier(prefs as never, (state) => alerts.push(state), () => {}) return { n, alerts } } @@ -44,7 +45,7 @@ describe('notification settings', () => { const { n, alerts } = notifier({ 'notifications.desktop': false }) await n.handle(payload) expect(shown).toHaveLength(0) - expect(alerts.at(-1)?.[0]).toBe(1) + expect(alerts.at(-1)?.unread).toBe(1) }) it('makes them silent when the sound is off', async () => { @@ -52,3 +53,28 @@ describe('notification settings', () => { expect(shown[0].silent).toBe(true) }) }) + +describe('what the tray is told is waiting', () => { + const buffer = (id: string, kind: string): never => ({ id, kind }) as never + + it('counts direct messages and mentions apart, and lights for both', async () => { + const { n, alerts } = notifier({ 'notifications.desktop': false }) + n.trackBuffer(buffer('dm', 'dm'), false) + n.trackBuffer(buffer('chan', 'channel'), false) + await n.handle({ ...payload, bufferId: 'dm' }) + await n.handle({ ...payload, bufferId: 'chan' }) + expect(alerts.at(-1)).toEqual({ unread: 2, dms: 1, mentions: 1 }) + }) + + it('names the newest waiting conversation, and none once they are read', async () => { + const { n } = notifier({ 'notifications.desktop': false }) + expect(n.latestUnread()).toBeNull() + await n.handle({ ...payload, bufferId: 'first' }) + await n.handle({ ...payload, bufferId: 'second' }) + expect(n.latestUnread()).toBe('second') + n.clear('second') + expect(n.latestUnread()).toBe('first') + n.clearAll() + expect(n.latestUnread()).toBeNull() + }) +}) diff --git a/src/main/notifications.ts b/src/main/notifications.ts index 9df75c6..afefb83 100644 --- a/src/main/notifications.ts +++ b/src/main/notifications.ts @@ -4,6 +4,7 @@ import path from 'node:path' import { Notification, nativeImage, net } from 'electron' import type { WebContents } from 'electron' import type { Prefs } from './prefs' +import type { TrayState } from './tray' import type { Buffer as ChatBuffer } from '../shared/wire' /** @@ -48,7 +49,7 @@ export class Notifier { constructor( private prefs: Prefs, - private onAlertChange: (unreadCount: number, hasAlert: boolean) => void, + private onAlertChange: (state: TrayState) => void, private onActivate: (bufferId: string) => void, /** The renderer, borrowed only to decode formats nativeImage cannot. */ private renderer: () => WebContents | null = () => null, @@ -288,24 +289,25 @@ export class Notifier { } /** - * Whether the tray should show something is waiting. + * What the tray should say is waiting. * - * A direct message counts, and this is the change: the tray used to light up - * only for *pinned* buffers, on the reasoning that a DM had already had its - * desktop notification and the tray was for things important enough to pin. - * In practice that made the icon nearly inert - somebody messages you, and - * the one place still on screen after the notification has faded says - * nothing, unless you happened to have pinned that exact conversation. - * - * Pinned buffers still count, so a channel worth pinning can still raise it. - * There is no count here: a tray icon is around 22 pixels, which is room for - * "yes" and not for a number. + * Everything held here was worth a notification - a direct message, or + * somebody mentioning this account - so all of it lights the tray, and it + * says how many conversations and of which kind. It used to light only for + * direct messages and pinned conversations, on the reasoning that a mention + * had had its notification and the tray was for what mattered more; the + * effect was an icon that stayed dark while somebody was asking for you. */ publish(): void { - const pinned = this.prefs.get('pinnedBuffers', []) - const hasAlert = [...this.unread].some( - (id) => this.buffers.get(id)?.kind === 'dm' || pinned.includes(id) - ) - this.onAlertChange(this.unread.size, hasAlert) + let dms = 0 + for (const id of this.unread) if (this.buffers.get(id)?.kind === 'dm') dms++ + this.onAlertChange({ unread: this.unread.size, dms, mentions: this.unread.size - dms }) + } + + /** The conversation that has been waiting for you the shortest time. */ + latestUnread(): string | null { + let latest: string | null = null + for (const id of this.unread) latest = id + return latest } } diff --git a/src/main/tray.test.ts b/src/main/tray.test.ts new file mode 100644 index 0000000..1264171 --- /dev/null +++ b/src/main/tray.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it, vi } from 'vitest' +import { trayIconFile, trayMenu, trayTooltip, waitingSummary, NOTHING_WAITING, type TrayActions } from './tray' + +const acts = (): TrayActions => ({ + toggleWindow: vi.fn(), + openLatest: vi.fn(), + openSettings: vi.fn(), + setStatus: vi.fn(), + setNotifications: vi.fn(), + restart: vi.fn(), + quit: vi.fn() +}) + +describe('the tray icon', () => { + it('is a light bubble on a dark system and a dark one on a light system', () => { + expect(trayIconFile(true, 0)).toBe('tray-light.png') + expect(trayIconFile(false, 0)).toBe('tray-dark.png') + }) + + it('carries the count to nine and says more after that', () => { + expect(trayIconFile(true, 1)).toBe('tray-light-1.png') + expect(trayIconFile(true, 9)).toBe('tray-light-9.png') + expect(trayIconFile(false, 10)).toBe('tray-dark-more.png') + expect(trayIconFile(false, 400)).toBe('tray-dark-more.png') + }) +}) + +describe('what is said about what is waiting', () => { + it('names direct messages and mentions apart, and in the singular for one', () => { + expect(waitingSummary({ unread: 3, dms: 2, mentions: 1 })).toBe('2 direct messages, 1 mention') + expect(waitingSummary({ unread: 1, dms: 1, mentions: 0 })).toBe('1 direct message') + expect(waitingSummary({ unread: 4, dms: 0, mentions: 4 })).toBe('4 mentions') + }) + + it('leaves the tooltip plain with nothing waiting or the badge off', () => { + expect(trayTooltip(NOTHING_WAITING, true)).toBe('moho') + expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, false)).toBe('moho') + expect(trayTooltip({ unread: 2, dms: 1, mentions: 1 }, true)).toBe('moho - 1 direct message, 1 mention') + }) +}) + +describe('the menu', () => { + const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const, notifications: true } + const labels = (items: ReturnType): string[] => items.map((i) => i.label ?? '-') + + it('offers no daemon plumbing, and a restart that is of moho as a whole', () => { + const l = labels(trayMenu(model, acts())) + expect(l).not.toContain('Restart daemon') + expect(l).not.toContain('Stop daemon') + expect(l).toContain('Restart moho') + }) + + it('says nothing is new, and offers nothing to open, when that is so', () => { + const [first] = trayMenu(model, acts()) + expect(first.label).toBe('Nothing new') + expect(first.enabled).toBe(false) + }) + + it('says what is waiting and opens it', () => { + const a = acts() + const [first] = trayMenu({ ...model, state: { unread: 2, dms: 1, mentions: 1 } }, a) + expect(first.label).toBe('1 direct message, 1 mention - open') + first.click?.({} as never, undefined, {} as never) + expect(a.openLatest).toHaveBeenCalled() + }) + + it('asks for the window to be shown or hidden as it stands', () => { + expect(labels(trayMenu({ ...model, windowVisible: true }, acts()))).toContain('Hide moho') + expect(labels(trayMenu({ ...model, windowVisible: false }, acts()))).toContain('Show moho') + }) + + it('checks the status the accounts are at', () => { + const status = trayMenu(model, acts()).find((i) => i.label === 'Status') + const sub = status?.submenu as { label: string; checked: boolean }[] + expect(sub.filter((s) => s.checked).map((s) => s.label)).toEqual(['Idle']) + const none = trayMenu({ ...model, status: null }, acts()).find((i) => i.label === 'Status')?.submenu as { checked: boolean }[] + expect(none.some((s) => s.checked)).toBe(false) + }) +}) diff --git a/src/main/tray.ts b/src/main/tray.ts new file mode 100644 index 0000000..c3d3fed --- /dev/null +++ b/src/main/tray.ts @@ -0,0 +1,110 @@ +/** + * What the tray says and offers, apart from the Electron objects that show it. + * + * Kept as plain functions of plain data so that what the icon, the tooltip and + * the menu say about a given set of unread conversations can be checked + * without a desktop to look at. + */ +import type { MenuItemConstructorOptions } from 'electron' + +/** What is waiting, as the notifier counts it. */ +export interface TrayState { + /** Conversations with something unread that was worth a notification. */ + unread: number + /** Of those, direct messages. */ + dms: number + /** Of those, everything else: somebody mentioning this account in a channel. */ + mentions: number +} + +export const NOTHING_WAITING: TrayState = { unread: 0, dms: 0, mentions: 0 } + +export type TrayStatus = 'online' | 'idle' | 'dnd' | 'invisible' + +/** + * The icon file for a panel and a count. + * + * `dark` is the system's colour scheme, and a dark system wants the light + * bubble. The count is drawn into the icon up to nine; past that it says "9+", + * since a tray icon is about 22 pixels and has no room for a second digit. + */ +export function trayIconFile(dark: boolean, unread: number): string { + const ink = dark ? 'light' : 'dark' + if (unread <= 0) return `tray-${ink}.png` + return `tray-${ink}-${unread > 9 ? 'more' : unread}.png` +} + +const plural = (n: number, one: string, many: string): string => `${n} ${n === 1 ? one : many}` + +/** "2 direct messages, 1 mention" - what is waiting, in the words a person would use. */ +export function waitingSummary(s: TrayState): string { + const parts: string[] = [] + if (s.dms > 0) parts.push(plural(s.dms, 'direct message', 'direct messages')) + if (s.mentions > 0) parts.push(plural(s.mentions, 'mention', 'mentions')) + return parts.join(', ') +} + +export function trayTooltip(s: TrayState, badge: boolean): string { + return badge && s.unread > 0 ? `moho - ${waitingSummary(s)}` : 'moho' +} + +export interface TrayActions { + toggleWindow: () => void + openLatest: () => void + openSettings: () => void + setStatus: (status: TrayStatus) => void + setNotifications: (on: boolean) => void + restart: () => void + quit: () => void +} + +export interface TrayModel { + state: TrayState + windowVisible: boolean + /** The status the accounts are at, where the window has said. */ + status: TrayStatus | null + notifications: boolean +} + +const STATUSES: { id: TrayStatus; label: string }[] = [ + { id: 'online', label: 'Online' }, + { id: 'idle', label: 'Idle' }, + { id: 'dnd', label: 'Do not disturb' }, + { id: 'invisible', label: 'Invisible' } +] + +/** + * The right-click menu: what is new, how to get to moho, how to say what you + * are doing, and how to leave. Daemon plumbing - restart it, stop it - is + * deliberately not here; it is a thing for Settings, and "restart moho" + * covers what somebody reaching for it from a tray actually wants. + */ +export function trayMenu(model: TrayModel, act: TrayActions): MenuItemConstructorOptions[] { + const waiting = model.state.unread > 0 + return [ + waiting + ? { label: `${waitingSummary(model.state)} - open`, click: act.openLatest } + : { label: 'Nothing new', enabled: false }, + { type: 'separator' }, + { label: model.windowVisible ? 'Hide moho' : 'Show moho', click: act.toggleWindow }, + { + label: 'Status', + submenu: STATUSES.map((s) => ({ + label: s.label, + type: 'radio' as const, + checked: model.status === s.id, + click: () => act.setStatus(s.id) + })) + }, + { + label: 'Desktop notifications', + type: 'checkbox', + checked: model.notifications, + click: (item) => act.setNotifications(item.checked) + }, + { label: 'Settings', click: act.openSettings }, + { type: 'separator' }, + { label: 'Restart moho', click: act.restart }, + { label: 'Quit', click: act.quit } + ] +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 3a7afe6..ee6ba31 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -83,6 +83,18 @@ const api = { return () => ipcRenderer.off(IPC.activateBuffer, handler) }, + /** The tray menu asking this window to open Settings, or to set a status on every account. */ + onTrayCommand(cb: (command: 'settings' | 'status', arg?: string) => void): () => void { + const handler = (_e: unknown, command: 'settings' | 'status', arg?: string): void => cb(command, arg) + ipcRenderer.on(IPC.trayCommand, handler) + return () => ipcRenderer.off(IPC.trayCommand, handler) + }, + + /** Says what status the accounts are at, for the tray menu to show as checked. */ + setTrayStatus(status: string | null): void { + ipcRenderer.send(IPC.trayStatus, status) + }, + onMaximizeChange(cb: (maximized: boolean) => void): () => void { const handler = (_e: unknown, v: boolean): void => cb(v) ipcRenderer.on(IPC.maximizeChanged, handler) diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 53c0e90..445f971 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1179,6 +1179,18 @@ export class ChatStore { private set(patch: Partial): void { this.state = { ...this.state, ...patch } for (const l of this.listeners) l() + if (patch.accounts && !this.state.pinnedBufferId) this.reportTrayStatus() + } + + /** + * Tells main what status the accounts are at, so the tray menu's Status + * entry can show the one that is set. The first connected account's, since + * the menu sets one status on all of them and so they are alike unless one + * was changed on its own. + */ + private reportTrayStatus(): void { + const status = this.state.accounts.find((a) => a.state === 'connected')?.status + window.moho.setTrayStatus(status === 'online' || status === 'idle' || status === 'dnd' || status === 'invisible' ? status : null) } // --- lifecycle ------------------------------------------------------ @@ -1256,6 +1268,11 @@ export class ChatStore { // window gets them instead; main routes them there. if (!this.state.pinnedBufferId) { window.moho.onActivateBuffer((id) => void this.selectBuffer(id)) + // The tray's menu: Settings, and a status for every account. + window.moho.onTrayCommand((command, arg) => { + if (command === 'settings') this.setActivePanel('settings') + else if (arg === 'online' || arg === 'idle' || arg === 'dnd' || arg === 'invisible') void this.setStatusEverywhere(arg) + }) window.moho.onDeepLink((url) => this.followDeepLink(url)) } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5e18398..5164eae 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -52,7 +52,11 @@ export const IPC = { /** main -> window: a right click landed on somewhere text is typed. */ editMenu: 'moho:editMenu', deepLink: 'moho:deepLink', - popoutsChanged: 'moho:popout:changed' + popoutsChanged: 'moho:popout:changed', + /** main -> window: something from the tray menu that the window carries out. */ + trayCommand: 'moho:tray:command', + /** window -> main: the status the accounts are at, for the tray menu to check. */ + trayStatus: 'moho:tray:status' } as const /** From 53fb0f34cebf4e9759ba1c2ffd0101a2d2e8a699 Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 9 Oct 2026 11:07:50 -0400 Subject: [PATCH 3/7] Tray: no notifications switch of its own - Do not disturb is that, and now silences an account's desktop notifications and the taskbar flash (#330) Co-Authored-By: Claude Sonnet 5.5 --- scripts/tray-check.mjs | 24 ++++++++++++++---------- src/main/index.ts | 26 ++++++++++++++++---------- src/main/notifications.test.ts | 23 +++++++++++++++++++++-- src/main/notifications.ts | 13 ++++++++++++- src/main/tray.test.ts | 9 +++++++-- src/main/tray.ts | 11 ++--------- src/preload/index.ts | 9 ++++++--- src/renderer/src/state/store.ts | 12 ++++++------ src/shared/ipc.ts | 2 +- 9 files changed, 85 insertions(+), 44 deletions(-) diff --git a/scripts/tray-check.mjs b/scripts/tray-check.mjs index c9945e0..8a945d0 100644 --- a/scripts/tray-check.mjs +++ b/scripts/tray-check.mjs @@ -10,6 +10,7 @@ * * npm run test:tray (builds first; needs the daemon built too) */ +import { execFileSync } from 'node:child_process' import fs from 'node:fs' import { cdp, freePort, launchApp, sleep, until } from './lib/harness.mjs' @@ -95,11 +96,8 @@ try { await until(page, 'window.__trayGot.length', 'the status to reach the window') check(JSON.stringify(await page.evaluate('window.__trayGot[0]')) === '["status","dnd"]', 'a status is handed to the window') - // Desktop notifications is a real setting, shared with Settings. - await tray('t.click(["Desktop notifications"])') - await until(page, 'window.moho.prefs.getAll().then((p) => p["notifications.desktop"] === false)', 'the setting to change') - check(true, 'Desktop notifications toggles the setting') - check((await tray('t.menu().find((i) => i.label === "Desktop notifications").checked')) === false, 'and the menu shows it off') + // There is no notifications switch of its own: Do not disturb is that. + check(!labels.some((l) => /notification/i.test(l)), 'the menu has no notifications switch of its own', labels.join(' | ')) // Restart brings back moho and a daemon of its own. const before = daemons(scratchDir) @@ -136,13 +134,18 @@ try { console.log('FAIL', e.message) failures++ } finally { - await stop() // The relaunched copy is not the one the harness started, so it is not in the - // group stop() ended. It is found by the home it was given, which everything - // it starts - its daemon, and the helpers Chromium runs that name no port - - // carries in its environment; twice, since it may still be starting. + // group stop() ends. It is found by the debugging port it was told to use - + // it shows no environment to be found by - and what it started goes when it + // does. Ended first, and twice since it may still be starting, because it + // writes into the directory stop() is about to remove. for (let i = 0; i < 2; i++) { await sleep(1500) + try { + execFileSync('pkill', ['-9', '-f', `remote-debugging-port=${port}`]) + } catch { + /* none left */ + } for (const entry of fs.readdirSync('/proc')) { if (!/^\d+$/.test(entry) || Number(entry) === process.pid || !scratchDir) continue try { @@ -152,7 +155,8 @@ try { } } } - await sleep(500) + await sleep(1500) + await stop().catch(() => {}) if (scratchDir) fs.rmSync(scratchDir, { recursive: true, force: true }) } console.log(failures ? `${failures} failed` : 'all passed') diff --git a/src/main/index.ts b/src/main/index.ts index f46b5ca..575c5e5 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -204,7 +204,6 @@ function applyPref(key: string, value: unknown, except?: Electron.WebContents): prefs.set(key, value) // The badge and the flash are decided from these as they stand. if (key === 'notifications.badge' || key === 'notifications.flash') updateTray(lastTray) - if (key === 'notifications.desktop') refreshTrayMenu() // Every window keeps its own cache of these, so a setting changed in one // is stale in the others until they are told. That is not cosmetic once // there are several windows: muting a conversation, or switching the log @@ -622,8 +621,11 @@ function trayIcon(unread: number): Electron.NativeImage { return img.isEmpty() ? nativeImage.createFromPath(resourcePath('icons', 'moho.png')) : img } -/** What the window last said the accounts' status is, so the menu can check it. */ +/** What the window last said each connected account's status is. */ +let accountStatus: Record = {} +/** The status the menu shows as checked: the first account's, since the menu sets one on all of them. */ let trayStatus: TrayStatus | null = null +const isTrayStatus = (v: unknown): v is TrayStatus => v === 'online' || v === 'idle' || v === 'dnd' || v === 'invisible' /** * Asks the window to do something, showing it first: the tray's entries that @@ -664,8 +666,7 @@ function refreshTrayMenu(): void { { state: lastTray, windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(), - status: trayStatus, - notifications: prefs.get('notifications.desktop', true) + status: trayStatus }, { toggleWindow, @@ -678,7 +679,6 @@ function refreshTrayMenu(): void { trayStatus = status tellWindow('status', status) }, - setNotifications: (on) => applyPref('notifications.desktop', on), restart: restartMoho, quit: () => app.quit() } @@ -708,7 +708,7 @@ function updateTray(state: TrayState): void { // The taskbar flash: asking for attention while the window is not in front. const flash = prefs.get('notifications.flash', true) if (mainWindow && !mainWindow.isDestroyed()) { - mainWindow.flashFrame(flash && state.unread > 0 && !mainWindow.isFocused()) + mainWindow.flashFrame(flash && state.unread > 0 && trayStatus !== 'dnd' && !mainWindow.isFocused()) } refreshTrayMenu() if (!tray) return @@ -768,10 +768,15 @@ function wireIpc(): void { ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender)) // The window says what status its accounts are at, for the tray's menu to check. - ipcMain.on(IPC.trayStatus, (_e, status: TrayStatus | null) => { - if (status === trayStatus) return - trayStatus = status + ipcMain.on(IPC.trayStatus, (_e, statuses: Record) => { + accountStatus = statuses && typeof statuses === 'object' ? statuses : {} + const first = Object.values(accountStatus)[0] + const next = isTrayStatus(first) ? first : null + if (next === trayStatus) return + trayStatus = next refreshTrayMenu() + // Do not disturb is asking for quiet, including from the taskbar. + updateTray(lastTray) }) ipcMain.handle(IPC.markBufferRead, (_e, bufferId: string) => notifier.clear(bufferId)) @@ -1232,7 +1237,8 @@ app.whenReady().then(() => { updateTray, openConversation, () => mainWindow?.webContents ?? null, - (bufferId) => popoutState().watched.includes(bufferId) + (bufferId) => popoutState().watched.includes(bufferId), + (accountId) => accountStatus[accountId] === 'dnd' ) client.on('link', (up) => { diff --git a/src/main/notifications.test.ts b/src/main/notifications.test.ts index 7b195bf..b32dd8c 100644 --- a/src/main/notifications.test.ts +++ b/src/main/notifications.test.ts @@ -23,10 +23,10 @@ import type { TrayState } from './tray' const payload = { accountId: 'a', bufferId: 'b', title: 'alice', body: 'hi' } -function notifier(settings: Record): { n: Notifier; alerts: TrayState[] } { +function notifier(settings: Record, dnd: string[] = []): { n: Notifier; alerts: TrayState[] } { const alerts: TrayState[] = [] const prefs = { get: (key: string, fallback: T): T => (key in settings ? (settings[key] as T) : fallback) } - const n = new Notifier(prefs as never, (state) => alerts.push(state), () => {}) + const n = new Notifier(prefs as never, (state) => alerts.push(state), () => {}, () => null, () => false, (account) => dnd.includes(account)) return { n, alerts } } @@ -78,3 +78,22 @@ describe('what the tray is told is waiting', () => { expect(n.latestUnread()).toBeNull() }) }) + +describe('Do not disturb', () => { + beforeEach(() => { + shown.length = 0 + }) + + it('shows no notification for an account that is set to it, but still counts the conversation', async () => { + const { n, alerts } = notifier({}, ['a']) + await n.handle(payload) + expect(shown).toHaveLength(0) + expect(alerts.at(-1)?.unread).toBe(1) + }) + + it('leaves the other accounts alone', async () => { + const { n } = notifier({}, ['somebody-else']) + await n.handle(payload) + expect(shown).toHaveLength(1) + }) +}) diff --git a/src/main/notifications.ts b/src/main/notifications.ts index afefb83..0de3093 100644 --- a/src/main/notifications.ts +++ b/src/main/notifications.ts @@ -61,7 +61,16 @@ export class Notifier { * them what they can already see, and a tray badge counting it as unread * is simply wrong. */ - private isWatched: (bufferId: string) => boolean = () => false + private isWatched: (bufferId: string) => boolean = () => false, + /** + * Whether this account has been set to Do not disturb. + * + * That is the one switch for silence: an account that says so gets no popup + * and no sound, however its conversations behave. What it is sent still + * counts as unread - the tray and the badge keep their numbers, as Discord's + * own do - and is there to be read when it is looked at. + */ + private isDnd: (accountId: string) => boolean = () => false ) {} trackBuffer(buffer: ChatBuffer, removed: boolean): void { @@ -104,6 +113,8 @@ export class Notifier { this.publish() } + if (this.isDnd(payload.accountId)) return + // Switched off in Settings: the conversation still counts as unread above, // it just does not pop up. if (!this.prefs.get('notifications.desktop', true)) return diff --git a/src/main/tray.test.ts b/src/main/tray.test.ts index 1264171..778585f 100644 --- a/src/main/tray.test.ts +++ b/src/main/tray.test.ts @@ -6,7 +6,6 @@ const acts = (): TrayActions => ({ openLatest: vi.fn(), openSettings: vi.fn(), setStatus: vi.fn(), - setNotifications: vi.fn(), restart: vi.fn(), quit: vi.fn() }) @@ -40,7 +39,7 @@ describe('what is said about what is waiting', () => { }) describe('the menu', () => { - const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const, notifications: true } + const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const } const labels = (items: ReturnType): string[] => items.map((i) => i.label ?? '-') it('offers no daemon plumbing, and a restart that is of moho as a whole', () => { @@ -64,6 +63,12 @@ describe('the menu', () => { expect(a.openLatest).toHaveBeenCalled() }) + it('has no notifications switch of its own: Do not disturb is that', () => { + expect(labels(trayMenu(model, acts())).join('|')).not.toMatch(/notification/i) + const status = trayMenu(model, acts()).find((i) => i.label === 'Status') + expect((status?.submenu as { label: string }[]).map((s) => s.label)).toContain('Do not disturb') + }) + it('asks for the window to be shown or hidden as it stands', () => { expect(labels(trayMenu({ ...model, windowVisible: true }, acts()))).toContain('Hide moho') expect(labels(trayMenu({ ...model, windowVisible: false }, acts()))).toContain('Show moho') diff --git a/src/main/tray.ts b/src/main/tray.ts index c3d3fed..fec1366 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -53,7 +53,6 @@ export interface TrayActions { openLatest: () => void openSettings: () => void setStatus: (status: TrayStatus) => void - setNotifications: (on: boolean) => void restart: () => void quit: () => void } @@ -63,7 +62,6 @@ export interface TrayModel { windowVisible: boolean /** The status the accounts are at, where the window has said. */ status: TrayStatus | null - notifications: boolean } const STATUSES: { id: TrayStatus; label: string }[] = [ @@ -75,7 +73,8 @@ const STATUSES: { id: TrayStatus; label: string }[] = [ /** * The right-click menu: what is new, how to get to moho, how to say what you - * are doing, and how to leave. Daemon plumbing - restart it, stop it - is + * are doing - which includes Do not disturb, the one switch for silencing it - + * and how to leave. Daemon plumbing - restart it, stop it - is * deliberately not here; it is a thing for Settings, and "restart moho" * covers what somebody reaching for it from a tray actually wants. */ @@ -96,12 +95,6 @@ export function trayMenu(model: TrayModel, act: TrayActions): MenuItemConstructo click: () => act.setStatus(s.id) })) }, - { - label: 'Desktop notifications', - type: 'checkbox', - checked: model.notifications, - click: (item) => act.setNotifications(item.checked) - }, { label: 'Settings', click: act.openSettings }, { type: 'separator' }, { label: 'Restart moho', click: act.restart }, diff --git a/src/preload/index.ts b/src/preload/index.ts index ee6ba31..3078884 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -90,9 +90,12 @@ const api = { return () => ipcRenderer.off(IPC.trayCommand, handler) }, - /** Says what status the accounts are at, for the tray menu to show as checked. */ - setTrayStatus(status: string | null): void { - ipcRenderer.send(IPC.trayStatus, status) + /** + * Says what status each connected account is at: for the tray menu to show as + * checked, and for main to stay quiet for an account on Do not disturb. + */ + setAccountStatuses(statuses: Record): void { + ipcRenderer.send(IPC.trayStatus, statuses) }, onMaximizeChange(cb: (maximized: boolean) => void): () => void { diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 445f971..8cbc108 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1183,14 +1183,14 @@ export class ChatStore { } /** - * Tells main what status the accounts are at, so the tray menu's Status - * entry can show the one that is set. The first connected account's, since - * the menu sets one status on all of them and so they are alike unless one - * was changed on its own. + * Tells main what status each connected account is at. The tray menu checks + * the first one's, since it sets one status on all of them, and main stays + * quiet - no popup, no sound - for an account that is on Do not disturb. */ private reportTrayStatus(): void { - const status = this.state.accounts.find((a) => a.state === 'connected')?.status - window.moho.setTrayStatus(status === 'online' || status === 'idle' || status === 'dnd' || status === 'invisible' ? status : null) + const statuses: Record = {} + for (const a of this.state.accounts) if (a.state === 'connected' && a.status) statuses[a.id] = a.status + window.moho.setAccountStatuses(statuses) } // --- lifecycle ------------------------------------------------------ diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5164eae..df05cd3 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -55,7 +55,7 @@ export const IPC = { popoutsChanged: 'moho:popout:changed', /** main -> window: something from the tray menu that the window carries out. */ trayCommand: 'moho:tray:command', - /** window -> main: the status the accounts are at, for the tray menu to check. */ + /** window -> main: the status each connected account is at. */ trayStatus: 'moho:tray:status' } as const From 0dfff0d74bebe8f729e7dd6f706667a693729b2c Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 9 Oct 2026 11:15:38 -0400 Subject: [PATCH 4/7] nobilis: Matrix rate limits are waited out (status from the tray) Co-Authored-By: Claude Sonnet 5.5 --- nobilis | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nobilis b/nobilis index a34821e..43135c1 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit a34821e6b1cb9889ceb00d51d70f3e8480bfd4a6 +Subproject commit 43135c1005f117a15c48470938051303f4a32a78 From c1bcb72a38292036caa6ef8df11a81dd48a215be Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 9 Oct 2026 11:26:52 -0400 Subject: [PATCH 5/7] Status: the plaque and the tray offer what each service has; idle from the tray reaches Discord and IRC and leaves the rest online; do-not-disturb silences desktop notifications everywhere Co-Authored-By: Claude Sonnet 5.5 --- nobilis | 2 +- scripts/tray-check.mjs | 21 ++++++++++++- src/main/index.ts | 14 ++++----- src/main/tray.test.ts | 10 +++++- src/main/tray.ts | 4 ++- src/preload/index.ts | 9 +++--- src/renderer/src/components/StatusPopout.tsx | 3 +- src/renderer/src/lib/status.test.ts | 22 ++++++++++++++ src/renderer/src/lib/status.ts | 32 ++++++++++++++++++++ src/renderer/src/state/store.ts | 20 ++++++++---- 10 files changed, 115 insertions(+), 22 deletions(-) create mode 100644 src/renderer/src/lib/status.test.ts create mode 100644 src/renderer/src/lib/status.ts diff --git a/nobilis b/nobilis index 43135c1..a8441ab 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 43135c1005f117a15c48470938051303f4a32a78 +Subproject commit a8441ab3498b3055e4189eea50160044e50824a3 diff --git a/scripts/tray-check.mjs b/scripts/tray-check.mjs index 8a945d0..c84b07a 100644 --- a/scripts/tray-check.mjs +++ b/scripts/tray-check.mjs @@ -12,7 +12,7 @@ */ import { execFileSync } from 'node:child_process' import fs from 'node:fs' -import { cdp, freePort, launchApp, sleep, until } from './lib/harness.mjs' +import { cdp, fakeIrc, freePort, launchApp, openIrcChannel, sleep, until } from './lib/harness.mjs' let failures = 0 const check = (ok, what, detail = '') => { @@ -99,6 +99,25 @@ try { // There is no notifications switch of its own: Do not disturb is that. check(!labels.some((l) => /notification/i.test(l)), 'the menu has no notifications switch of its own', labels.join(' | ')) + // With an IRC account connected - which has away and back, and no invisible - + // the menu offers what IRC can be set to, and the daemon takes it. + const irc = await fakeIrc({ people: ['bob'], onJoin: (channel, _nick, send) => send(`:bob!b@h PRIVMSG ${channel} :hello there`) }) + try { + await openIrcChannel(page, { port: irc.address().port, nick: 'checker', channels: ['#lobby'], waitFor: 'hello there' }) + await until(main, 'globalThis.__mohoTray.menu().find((i) => i.label === "Status").submenu.length === 3', 'the menu to offer only IRC\'s statuses') + const offered = await tray('t.menu().find((i) => i.label === "Status").submenu.map((i) => i.label)') + check(JSON.stringify(offered) === '["Online","Idle","Do not disturb"]', 'IRC alone: no Invisible is offered', JSON.stringify(offered)) + const statusOf = () => page.evaluate('window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status)') + await tray('t.click(["Status", "Idle"])') + await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "idle")', 'IRC to go idle') + check(true, 'Idle from the tray sets the IRC account idle') + await tray('t.click(["Status", "Do not disturb"])') + await until(page, 'window.moho.rpc("listAccounts", {}).then((a) => a.find((x) => x.id.startsWith("checker")).status === "dnd")', 'IRC to go to Do not disturb') + check((await statusOf()) === 'dnd', 'Do not disturb from the tray sets it') + } finally { + irc.close() + } + // Restart brings back moho and a daemon of its own. const before = daemons(scratchDir) const pidBefore = await main.evaluate('process.pid') diff --git a/src/main/index.ts b/src/main/index.ts index 575c5e5..41de45d 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -623,8 +623,9 @@ function trayIcon(unread: number): Electron.NativeImage { /** What the window last said each connected account's status is. */ let accountStatus: Record = {} -/** The status the menu shows as checked: the first account's, since the menu sets one on all of them. */ +/** The status the menu shows as checked, and the ones it offers: what the connected accounts can be set to. */ let trayStatus: TrayStatus | null = null +let trayOffered: TrayStatus[] = [] const isTrayStatus = (v: unknown): v is TrayStatus => v === 'online' || v === 'idle' || v === 'dnd' || v === 'invisible' /** @@ -666,7 +667,8 @@ function refreshTrayMenu(): void { { state: lastTray, windowVisible: !!mainWindow && !mainWindow.isDestroyed() && mainWindow.isVisible(), - status: trayStatus + status: trayStatus, + offered: trayOffered }, { toggleWindow, @@ -768,12 +770,10 @@ function wireIpc(): void { ipcMain.handle(IPC.prefsSet, (e, key: string, value: unknown) => applyPref(key, value, e.sender)) // The window says what status its accounts are at, for the tray's menu to check. - ipcMain.on(IPC.trayStatus, (_e, statuses: Record) => { + ipcMain.on(IPC.trayStatus, (_e, statuses: Record, offered: string[], shown: string | null) => { accountStatus = statuses && typeof statuses === 'object' ? statuses : {} - const first = Object.values(accountStatus)[0] - const next = isTrayStatus(first) ? first : null - if (next === trayStatus) return - trayStatus = next + trayOffered = Array.isArray(offered) ? offered.filter(isTrayStatus) : [] + trayStatus = isTrayStatus(shown) ? shown : null refreshTrayMenu() // Do not disturb is asking for quiet, including from the taskbar. updateTray(lastTray) diff --git a/src/main/tray.test.ts b/src/main/tray.test.ts index 778585f..dd0ca1b 100644 --- a/src/main/tray.test.ts +++ b/src/main/tray.test.ts @@ -39,7 +39,7 @@ describe('what is said about what is waiting', () => { }) describe('the menu', () => { - const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const } + const model = { state: NOTHING_WAITING, windowVisible: true, status: 'idle' as const, offered: [] } const labels = (items: ReturnType): string[] => items.map((i) => i.label ?? '-') it('offers no daemon plumbing, and a restart that is of moho as a whole', () => { @@ -69,6 +69,14 @@ describe('the menu', () => { expect((status?.submenu as { label: string }[]).map((s) => s.label)).toContain('Do not disturb') }) + it('offers only what the connected accounts can be set to', () => { + const labelsOf = (offered: ('online' | 'idle' | 'dnd' | 'invisible')[]): string[] => + (trayMenu({ ...model, offered }, acts()).find((i) => i.label === 'Status')?.submenu as { label: string }[]).map((s) => s.label) + expect(labelsOf(['online', 'dnd'])).toEqual(['Online', 'Do not disturb']) + expect(labelsOf(['online', 'idle', 'dnd', 'invisible'])).toHaveLength(4) + expect(labelsOf([])).toHaveLength(4) + }) + it('asks for the window to be shown or hidden as it stands', () => { expect(labels(trayMenu({ ...model, windowVisible: true }, acts()))).toContain('Hide moho') expect(labels(trayMenu({ ...model, windowVisible: false }, acts()))).toContain('Show moho') diff --git a/src/main/tray.ts b/src/main/tray.ts index fec1366..db898ef 100644 --- a/src/main/tray.ts +++ b/src/main/tray.ts @@ -62,6 +62,8 @@ export interface TrayModel { windowVisible: boolean /** The status the accounts are at, where the window has said. */ status: TrayStatus | null + /** What the connected accounts can be set to; none reported yet means all of them. */ + offered: TrayStatus[] } const STATUSES: { id: TrayStatus; label: string }[] = [ @@ -88,7 +90,7 @@ export function trayMenu(model: TrayModel, act: TrayActions): MenuItemConstructo { label: model.windowVisible ? 'Hide moho' : 'Show moho', click: act.toggleWindow }, { label: 'Status', - submenu: STATUSES.map((s) => ({ + submenu: STATUSES.filter((s) => model.offered.length === 0 || model.offered.includes(s.id)).map((s) => ({ label: s.label, type: 'radio' as const, checked: model.status === s.id, diff --git a/src/preload/index.ts b/src/preload/index.ts index 3078884..187251c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -91,11 +91,12 @@ const api = { }, /** - * Says what status each connected account is at: for the tray menu to show as - * checked, and for main to stay quiet for an account on Do not disturb. + * Says what status each connected account is at, which statuses any of them + * can be set to, and the one to show as checked: for the tray menu, and for + * main to stay quiet for an account on Do not disturb. */ - setAccountStatuses(statuses: Record): void { - ipcRenderer.send(IPC.trayStatus, statuses) + setAccountStatuses(statuses: Record, offered: string[], shown: string | null): void { + ipcRenderer.send(IPC.trayStatus, statuses, offered, shown) }, onMaximizeChange(cb: (maximized: boolean) => void): () => void { diff --git a/src/renderer/src/components/StatusPopout.tsx b/src/renderer/src/components/StatusPopout.tsx index bf95f0e..d5602a7 100644 --- a/src/renderer/src/components/StatusPopout.tsx +++ b/src/renderer/src/components/StatusPopout.tsx @@ -6,6 +6,7 @@ import { useEscapeLayer } from '../lib/layers' import { presenceClass } from '../lib/presence' import { classes } from '../lib/util' import type { Account } from '../../../shared/wire' +import { supportsStatus } from '../lib/status' import type { Status } from './UserFooter' /** The statuses that can be chosen, with the dot each is drawn with. Signing out is separate, below them. */ @@ -89,7 +90,7 @@ export function StatusPopout({ aria-label="Set your status" style={pos ? { left: pos.left, bottom: pos.bottom } : { visibility: 'hidden' }} > - {CHOICES.map((c) => ( + {CHOICES.filter((c) => supportsStatus(account.service, c.id)).map((c) => ( diff --git a/src/renderer/src/components/UserFooter.tsx b/src/renderer/src/components/UserFooter.tsx index fe23556..07d5d06 100644 --- a/src/renderer/src/components/UserFooter.tsx +++ b/src/renderer/src/components/UserFooter.tsx @@ -4,6 +4,7 @@ import { Icon } from './Icon' import { Avatar } from './Avatar' import { useStore, useChat } from '../state/hooks' import { presenceLabel } from '../lib/presence' +import { statusName } from '../lib/status' import { serviceLabel } from '../lib/util' import type { Account } from '../../../shared/wire' @@ -34,8 +35,9 @@ export function effectiveStatus(account: Account): Status | 'connecting' { return (account.status as Status) || 'online' } -function label(status: Status | 'connecting'): string { - return status === 'connecting' ? 'Connecting…' : presenceLabel(status) +function label(status: Status | 'connecting', service: string): string { + if (status === 'connecting') return 'Connecting…' + return status === 'invisible' ? statusName(service, 'invisible') : presenceLabel(status) } export function UserFooter({ account }: { account?: Account }): JSX.Element { @@ -71,12 +73,12 @@ export function UserFooter({ account }: { account?: Account }): JSX.Element { aria-haspopup="menu" aria-expanded={open} onClick={() => setOpen(!open)} - title={detail ? `${name} — ${label(status)}\n${detail}` : `${name} — ${label(status)}`} + title={detail ? `${name} — ${label(status, account.service)}\n${detail}` : `${name} — ${label(status, account.service)}`} > {name} - {watching ? 'Watching, signed out' : label(status)} + {watching ? 'Watching, signed out' : label(status, account.service)} diff --git a/src/renderer/src/lib/buffermenu.test.ts b/src/renderer/src/lib/buffermenu.test.ts new file mode 100644 index 0000000..df9358a --- /dev/null +++ b/src/renderer/src/lib/buffermenu.test.ts @@ -0,0 +1,49 @@ +import { describe, expect, it, vi } from 'vitest' +import { bufferMenuEntries } from './buffermenu' + +const noop = (): void => {} + +function entries(over: Record) { + return bufferMenuEntries({ + buffer: { id: 'b', accountId: 'matrix:@me:x', name: '!r:x', kind: 'channel', serverMuted: true }, + account: { id: 'matrix:@me:x', service: 'matrix' }, + muted: false, + pinned: false, + filed: false, + inCall: false, + poppedOut: false, + canCall: false, + live: false, + watched: false, + onTogglePin: noop, + onToggleMute: noop, + onHide: noop, + onClose: noop, + onCall: noop, + onHangUp: noop, + onFile: noop, + onPopOut: noop, + onDock: noop, + ...over + } as never) as { label?: string; disabled?: boolean; onClick?: () => void }[] +} + +describe('a mute held on the account', () => { + it('can be taken off where the service lets it be, and that is what the entry does', () => { + const unmute = vi.fn() + const toggle = vi.fn() + const entry = entries({ onUnmuteAccount: unmute, onToggleMute: toggle }).find((e) => e.label === 'Unmute') + expect(entry).toBeDefined() + expect(entry?.disabled).toBeFalsy() + entry?.onClick?.() + expect(unmute).toHaveBeenCalled() + // Not the window's own mute, which would only have added a second one. + expect(toggle).not.toHaveBeenCalled() + }) + + it('says where it is where it cannot be taken off from here', () => { + const entry = entries({}).find((e) => (e.label ?? '').startsWith('Muted on')) + expect(entry?.label).toBe('Muted on Matrix') + expect(entry?.disabled).toBe(true) + }) +}) diff --git a/src/renderer/src/lib/buffermenu.ts b/src/renderer/src/lib/buffermenu.ts index 6c69d4a..df86937 100644 --- a/src/renderer/src/lib/buffermenu.ts +++ b/src/renderer/src/lib/buffermenu.ts @@ -32,6 +32,8 @@ export interface BufferMenuInput { spaces?: BufferGroup[] onTogglePin: () => void onToggleMute: () => void + /** Takes off a mute the account itself holds, where the service lets it be taken off from here. */ + onUnmuteAccount?: () => void /** * Filing the conversation on the account rather than in this window - * Matrix's room tags. Absent for every service that has no such idea, which @@ -86,6 +88,7 @@ export function bufferMenuEntries({ spaces, onTogglePin, onToggleMute, + onUnmuteAccount, onTag, onToggleAutojoin, onSpace, @@ -204,16 +207,20 @@ export function bufferMenuEntries({ } ] as MenuEntry[]) : []), - // A mute made on the account itself is not this window's to undo, and an - // "Unmute" that quietly did nothing would be worse than no entry at all - - // so it says where the mute is, and where to go and take it off. + // A mute made on the account itself - Matrix keeps it on the server, as a + // push rule - is the account's to take off, and where the service lets it + // be taken off from here that is what this does. Where it does not, the + // entry says where the mute is rather than offering an "Unmute" that quietly + // did nothing. buffer.serverMuted - ? { - label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account', - icon: 'notifications_off', - disabled: true, - onClick: () => {} - } + ? onUnmuteAccount + ? { label: 'Unmute', icon: 'notifications', onClick: onUnmuteAccount } + : { + label: account ? `Muted on ${serviceLabel(account.service)}` : 'Muted on this account', + icon: 'notifications_off', + disabled: true, + onClick: () => {} + } : { label: muted ? 'Unmute' : 'Mute', icon: muted ? 'notifications' : 'notifications_off', onClick: onToggleMute }, { separator: true }, // Filing is a drag onto the heading now, not an entry per heading. That diff --git a/src/renderer/src/lib/status.test.ts b/src/renderer/src/lib/status.test.ts index e0c9213..1d19b86 100644 --- a/src/renderer/src/lib/status.test.ts +++ b/src/renderer/src/lib/status.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { statusesFor, supportsStatus } from './status' +import { statusesFor, statusName, supportsStatus } from './status' describe('the statuses a service has', () => { it('gives Discord all four', () => { @@ -20,3 +20,11 @@ describe('the statuses a service has', () => { expect(supportsStatus('sneedchat', 'idle')).toBe(false) }) }) + +describe('what a status is called', () => { + it('calls Matrix\'s invisible what it is, offline presence', () => { + expect(statusName('matrix', 'invisible')).toBe('Offline') + expect(statusName('discord', 'invisible')).toBe('Invisible') + expect(statusName('matrix', 'dnd')).toBe('Do not disturb') + }) +}) diff --git a/src/renderer/src/lib/status.ts b/src/renderer/src/lib/status.ts index 762695b..0fef71b 100644 --- a/src/renderer/src/lib/status.ts +++ b/src/renderer/src/lib/status.ts @@ -30,3 +30,15 @@ export function supportsStatus(service: string, status: Choosable): boolean { export function statusesFor(service: string): Choosable[] { return ALL_STATUSES.filter((s) => supportsStatus(service, s)) } + +/** + * What a status is called on this service. + * + * Matrix has no invisible mode: what is sent for it is offline presence, which + * is what it is, so that is what it says there. Everything else is called what + * the others call it. + */ +export function statusName(service: string, status: Choosable): string { + if (status === 'invisible' && service === 'matrix') return 'Offline' + return { online: 'Online', idle: 'Idle', dnd: 'Do not disturb', invisible: 'Invisible' }[status] +} diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 69879af..cdcf703 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -1190,13 +1190,21 @@ export class ChatStore { * service that has no idle to be at - and main stays quiet, with no popup and * no sound, for an account that is on Do not disturb. */ + /** What main was last told, so an account list that changed in nothing that matters is not told again. */ + private lastTrayReport = '' + private reportTrayStatus(): void { const statuses: Record = {} const connected = this.state.accounts.filter((a) => a.state === 'connected') for (const a of connected) if (a.status) statuses[a.id] = a.status const offered = ALL_STATUSES.filter((s) => connected.some((a) => supportsStatus(a.service, s))) const widest = [...connected].sort((a, b) => statusesFor(b.service).length - statusesFor(a.service).length)[0] - window.moho.setAccountStatuses(statuses, offered, widest?.status ?? null) + // Invisible is what Discord calls it; where only Matrix has it, it is offline. + const invisibleName = connected.some((a) => supportsStatus(a.service, 'invisible') && a.service !== 'matrix') ? 'Invisible' : 'Offline' + const report = JSON.stringify([statuses, offered, widest?.status ?? null, invisibleName]) + if (report === this.lastTrayReport) return + this.lastTrayReport = report + window.moho.setAccountStatuses(statuses, offered, widest?.status ?? null, invisibleName) } // --- lifecycle ------------------------------------------------------ @@ -1262,8 +1270,14 @@ export class ChatStore { window.moho.onScreenPick(async (sources) => (await this.askForScreenSource(sources))?.id ?? null) window.moho.onLinkChange((up) => { + // Only the link coming up is news. Main says it again whenever the tray + // is redrawn, and taking each of those for a reconnection refreshed + // everything and put the pane back on the open conversation's server - + // which is how a Discord account retrying every few seconds kept pulling + // somebody off the IRC network they had just chosen. + const wasUp = this.state.linkUp this.set({ linkUp: up }) - if (up) { + if (up && !wasUp) { void this.refreshAll() void this.refreshNetSettings() } @@ -1367,7 +1381,10 @@ export class ChatStore { ]) // Not awaited: GitHub being slow is no reason to hold the window up. void this.checkForRelease() - if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId) + // The open conversation is read again, and subscribed to again, where it is. + // Not followed to its server: a refresh is not somebody choosing it, and + // the rail may have been moved to another since. + if (this.state.activeBufferId) await this.selectBuffer(this.state.activeBufferId, false) } /** From dc94dc3acada5bb97c1a543a19dee7685636787f Mon Sep 17 00:00:00 2001 From: Salastil Date: Fri, 9 Oct 2026 15:21:54 -0400 Subject: [PATCH 7/7] moho 1.0.0-rc.4 Fourth release candidate: a rebuilt tray, statuses by service, Do not disturb that silences notifications, and fixes to switching servers, Matrix rate limits and mutes, and Discord's connection. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.md | 19 +++++++++++++++++++ nobilis | 2 +- package-lock.json | 4 ++-- package.json | 2 +- 4 files changed, 23 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85c4a48..efeebe8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,25 @@ Each section has two parts: that made it, including dependency updates (`node scripts/changelog-deps.mjs ` lists those). +## [1.0.0-rc.4] + +### Major features + +- **A rebuilt tray:** an icon drawn for the system's colour scheme with the number of conversations waiting on it, a tooltip that says what is waiting, and a menu of what is new, Show/Hide, Status, Settings, Restart moho and Quit - Restart moho brings back both the window and the daemon +- **Status by service:** the plaque and the tray offer only the statuses each service has - Discord all four, IRC away and back, Matrix online and offline, Kick and Sneedchat none - and Do not disturb silences desktop notifications on every service, and is Discord's real mode there + +### Fixes, patches and changes + +- Reworked the tray icon and menu, and dropped Restart daemon and Stop daemon from it ([moho `24106e5`](https://github.com/Moho-Chat/moho/commit/24106e5)) +- Do not disturb now silences an account's desktop notifications and the taskbar flash; the tray no longer has a switch of its own for it ([moho `53fb0f3`](https://github.com/Moho-Chat/moho/commit/53fb0f3)) +- Idle from the tray reaches Discord and IRC and leaves Matrix, Kick and Sneedchat as they were; a service is no longer asked for a status it does not have ([moho `c1bcb72`](https://github.com/Moho-Chat/moho/commit/c1bcb72), [nobilis `a8441ab`](https://github.com/Moho-Chat/nobilis/commit/a8441ab)) +- Fixed Matrix's rate limit on changing status showing as M_LIMIT_EXCEEDED: a limited request now waits the time the server names, and a longer wait is applied when it is over ([moho `0dfff0d`](https://github.com/Moho-Chat/moho/commit/0dfff0d), [nobilis `43135c1`](https://github.com/Moho-Chat/nobilis/commit/43135c1)) +- Fixed the window being pulled back to the open conversation's server whenever you chose another: the daemon link coming up is acted on once, a refresh no longer follows the open conversation, and the tray is told only what changed ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a)) +- Matrix's invisible is called Offline in the plaque, its popout and the tray, which is what it sets ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a)) +- A room muted on a Matrix account can now be unmuted from the menu, including a mute set from Element ([moho `745641a`](https://github.com/Moho-Chat/moho/commit/745641a), [nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c)) +- Discord requests now give up on a connection that has gone quiet, a history catch-up always releases its place, and the gateway logs what it receives and why a message is dropped ([moho `b151ef8`](https://github.com/Moho-Chat/moho/commit/b151ef8), [nobilis `a34821e`](https://github.com/Moho-Chat/nobilis/commit/a34821e)) +- Fixed Discord reconnecting every few seconds after being told it is rate limited: it now waits out the minute Discord asks for ([nobilis `59db82c`](https://github.com/Moho-Chat/nobilis/commit/59db82c)) + ## [1.0.0-rc.3] ### Major features diff --git a/nobilis b/nobilis index 59db82c..f392d70 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 59db82c34bd4210a26fe96878375f1f98b1210b4 +Subproject commit f392d70c8d0175c3d206a4c0565d5d955cb2c906 diff --git a/package-lock.json b/package-lock.json index f785fba..5a234e9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "moho", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "moho", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "license": "GPL-3.0-or-later", "dependencies": { "@electron-toolkit/preload": "^3.0.1", diff --git a/package.json b/package.json index 36d1652..7b798e3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "moho", - "version": "1.0.0-rc.3", + "version": "1.0.0-rc.4", "description": "Unified desktop chat client for IRC, Discord, Sneedchat and Matrix", "author": "Salastil", "license": "GPL-3.0-or-later",