From 4f6cd9c794ccaa7823797f9628dddee2fddf96f7 Mon Sep 17 00:00:00 2001 From: Salastil Date: Thu, 3 Sep 2026 12:36:36 -0400 Subject: [PATCH] IRC networks by their own logos, and an account form that asks less Issue #12: the networks now carry their own marks, taken from each network's own site and bundled rather than fetched - an IRC network is not a CDN, a rail of tiles should not be a rail of requests, and half of these sites are plain HTTP. Libera, OFTC, Rizon, QuakeNet, Undernet, hackint, DALnet and CoreIRC have one; EFnet, Snoonet and Abjects publish none and keep their initials, which is a better answer than a wrong-looking copy of somebody's artwork. The marks belong to the networks that own them and identify those networks here and nothing else. CoreIRC and Abjects are in the list now - the two this client's own users are on. A network being small is exactly why nobody else's client offers it ready-made, and typing "irc.coreirc.net" correctly is the step the list exists to remove. The form itself: the network is picked from those marks rather than from a dropdown of names, and the server and port fields appear only for "Other" - a chosen network has its address already, and a field showing it invites editing something that is right. Sign-in is asked here now, as SASL or NickServ, because a registered nick that never identifies is a nick lost to the ghost of the last session, and that happens on the first connection rather than after anybody visits a settings pane. Autojoin left the form entirely. Which channels an account rejoins is a property of the channels, so it is set on one: right-click a channel, "Join automatically". It was a comma-separated field typed once and never seen again. And Sneedchat's rooms moved out of the account pane into the join panel, now that the list comes from the site. That page is for joining things; a room being ticked is exactly that. The account pane is for what an account is. --- README.md | 12 +- nobilis | 2 +- package.json | 4 +- src/main/index.ts | 4 +- src/main/prefs.ts | 27 ++ src/renderer/src/app.css | 102 +++++ src/renderer/src/assets/irc/coreirc.png | Bin 0 -> 5571 bytes src/renderer/src/assets/irc/dalnet.png | Bin 0 -> 2707 bytes src/renderer/src/assets/irc/hackint.png | Bin 0 -> 10409 bytes src/renderer/src/assets/irc/libera.svg | 8 + src/renderer/src/assets/irc/oftc.png | Bin 0 -> 4063 bytes src/renderer/src/assets/irc/quakenet.png | Bin 0 -> 6283 bytes src/renderer/src/assets/irc/rizon.png | Bin 0 -> 10288 bytes src/renderer/src/assets/irc/undernet.png | Bin 0 -> 10979 bytes src/renderer/src/components/AccountsPanel.tsx | 405 ++++++++++-------- src/renderer/src/components/BufferList.tsx | 24 ++ src/renderer/src/components/Composer.tsx | 6 +- src/renderer/src/components/JoinPanel.tsx | 110 ++++- src/renderer/src/components/MessageRow.tsx | 10 +- src/renderer/src/components/NickList.tsx | 2 +- src/renderer/src/components/ServerRail.tsx | 9 +- .../src/components/settings/SettingsPanel.tsx | 20 +- src/renderer/src/lib/format.ts | 8 +- src/renderer/src/lib/networks.ts | 49 ++- .../src/lib/{sockchat.ts => sneedchat.ts} | 4 +- src/renderer/src/lib/util.ts | 4 +- src/renderer/src/state/store.ts | 60 ++- src/shared/wire.ts | 10 +- 28 files changed, 622 insertions(+), 258 deletions(-) create mode 100644 src/renderer/src/assets/irc/coreirc.png create mode 100644 src/renderer/src/assets/irc/dalnet.png create mode 100644 src/renderer/src/assets/irc/hackint.png create mode 100644 src/renderer/src/assets/irc/libera.svg create mode 100644 src/renderer/src/assets/irc/oftc.png create mode 100644 src/renderer/src/assets/irc/quakenet.png create mode 100644 src/renderer/src/assets/irc/rizon.png create mode 100644 src/renderer/src/assets/irc/undernet.png rename src/renderer/src/lib/{sockchat.ts => sneedchat.ts} (85%) diff --git a/README.md b/README.md index 914472b..fd83a29 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # moho -A unified desktop chat client: IRC, Discord, Sneedchat (SockChat, the Tor-only XenForo chat +A unified desktop chat client: IRC, Discord, Sneedchat (SneedChat, the Tor-only XenForo chat feature), and Matrix in one window, with XMPP/Slack landing protocol by protocol without frontend changes. The architecture is deliberately service-agnostic - the UI only ever sees a unified account/buffer/message model, never a protocol-specific concept. @@ -17,7 +17,7 @@ Keeping the protocol work in a separate long-lived daemon means connections, Tor Matrix sync survive the UI being closed, restarted, or reloaded during development. ``` -chatd/src/backend/ one module per protocol (irc, discord, sockchat, matrix, ...) +chatd/src/backend/ one module per protocol (irc, discord, sneedchat, matrix, ...) chatd/src/rpc/ the Unix-socket JSON-RPC server chatd/src/store.rs SQLite-backed scrollback persistence chatd/src/runtime.rs protocol-agnostic connection/buffer/message state @@ -147,12 +147,12 @@ Core methods: `listAccounts`, `listBuffers`, `listProtocols`, `addAccount`, `rem `setAccountConnected`, `joinBuffer`, `partBuffer`, `sendMessage`, `editMessage`, `deleteMessage`, `subscribe`/`unsubscribe`, `getBacklog` (SQLite-backed, persists across `chatd` restarts). Each protocol also has its own account-creation method (`addAccount` for IRC, `addDiscordAccount`, -`addSockChatAccount`, `addMatrixAccount`). +`addSneedChatAccount`, `addMatrixAccount`). Push events: `message`, `messageUpdated`, `messageDeleted`, `reactionsChanged`, `presenceChange`, `bufferListChange`, `connectionState`, `notification`, plus per-protocol login-flow events -(`discordLoginQr`/`discordLoginScanned`/`discordLoginResult`, `sockChatLoginStatus`/ -`sockChatLoginResult`, `matrixLoginStatus`/`matrixLoginResult`). `message`, `presenceChange`, +(`discordLoginQr`/`discordLoginScanned`/`discordLoginResult`, `sneedChatLoginStatus`/ +`sneedChatLoginResult`, `matrixLoginStatus`/`matrixLoginResult`). `message`, `presenceChange`, `messageUpdated`, `messageDeleted` and `reactionsChanged` are only delivered to clients that called `subscribe` for that buffer. @@ -161,7 +161,7 @@ called `subscribe` for that buffer. - **IRC** - TLS with SASL PLAIN, NickServ auto-identify, autojoin, optional SOCKS5 proxying. - **Discord** - official cross-device QR login (the same one discord.com/app offers), a real-time gateway client, message edit/delete/reactions/replies sync. -- **Sneedchat (SockChat)** - the Tor-only chat built into Kiwi Farms. Runs over an embedded Tor +- **Sneedchat (SneedChat)** - the Tor-only chat built into Kiwi Farms. Runs over an embedded Tor client (or an external SOCKS5 proxy), solves the site's own proof-of-work anti-bot gate, and connects to every configured room simultaneously (one persistent websocket per room, sharing a single login). Supports message edit/delete and avatars (fetched through the same Tor session diff --git a/nobilis b/nobilis index 4c7f16a..cd18d63 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 4c7f16a01eb31474c2653a784999d1ea5aec6d5e +Subproject commit cd18d6363e62374666ab22c53316e3f851373d48 diff --git a/package.json b/package.json index 06a50e1..a212829 100644 --- a/package.json +++ b/package.json @@ -43,8 +43,8 @@ ], "extraResources": [ { - "from": "nobilis/resources/sockchat-smilies", - "to": "sockchat-smilies" + "from": "nobilis/resources/sneedchat-smilies", + "to": "sneedchat-smilies" }, { "from": "resources/icons", diff --git a/src/main/index.ts b/src/main/index.ts index bfd1ed9..a0b1207 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -134,8 +134,8 @@ function resourcePath(...parts: string[]): string { */ function smiliesPath(): string { return app.isPackaged - ? path.join(process.resourcesPath, 'sockchat-smilies') - : path.join(app.getAppPath(), 'nobilis', 'resources', 'sockchat-smilies') + ? path.join(process.resourcesPath, 'sneedchat-smilies') + : path.join(app.getAppPath(), 'nobilis', 'resources', 'sneedchat-smilies') } /** diff --git a/src/main/prefs.ts b/src/main/prefs.ts index 00c1f96..5728e51 100644 --- a/src/main/prefs.ts +++ b/src/main/prefs.ts @@ -56,6 +56,33 @@ export class Prefs extends EventEmitter { // Missing or corrupt: start from defaults rather than refusing to run. this.data = {} } + this.renameSneedchat() + } + + /** + * Sneedchat account ids used to be spelled "sockchat:", after an + * implementation of the protocol rather than after the chat itself. + * + * Almost everything in here is keyed by buffer id, and a buffer id is built + * from the account id - so without this every pin, mute, category, read + * marker and popout size for Sneedchat would point at conversations that no + * longer exist under any name, and the rename would look like the client + * quietly forgetting a year of arrangement. + * + * A blunt string rewrite over the whole file, because the ids appear as + * keys, as values, and inside longer strings ("account:sockchat:name"), and + * "sockchat" is not a word that occurs in this file for any other reason. + */ + private renameSneedchat(): void { + const before = JSON.stringify(this.data) + if (!before.includes('sockchat')) return + // Both spellings it appears in: an account id ends in a colon, and the + // per-service upload host is keyed "uploads.sockchat.media". + this.data = JSON.parse( + before.replaceAll('sockchat:', 'sneedchat:').replaceAll('uploads.sockchat.', 'uploads.sneedchat.') + ) + log.info('prefs: renamed sockchat: ids to sneedchat:') + this.scheduleFlush() } /** Debounced - a burst of writes (scroll markers, collapse toggles) is one flush. */ diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index 50daf60..63934b9 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -4479,3 +4479,105 @@ video.lightbox-media { align-items: center; gap: var(--spacing-s); } + +/* --- IRC network picker -------------------------------------------------- + The networks, as their own marks. A dropdown of names asks somebody to + recognise a word; a wall of logos is how they actually find the network + they are on. */ +.network-grid { + display: grid; + /* Full width of the field it sits in, not the width of its own content: + inside a flex column it took its content width, so `auto-fill` had room + for exactly one column and eleven networks became a column 877px tall. */ + width: 100%; + grid-template-columns: repeat(auto-fill, minmax(4.5rem, 1fr)); + gap: var(--spacing-xs); +} + +.network-tile { + display: flex; + flex-direction: column; + align-items: center; + gap: var(--spacing-xxs); + padding: var(--spacing-xs); + border: 1px solid transparent; + border-radius: var(--corner-radius); + background: none; + color: var(--surface-variant-text); +} + +.network-tile:hover { + background: var(--surface-container-high); +} + +.network-tile.active { + border-color: var(--primary); + color: var(--surface-text); +} + +/* The mark sits on the network's own colour, because several of these logos + are wordmarks with transparent backgrounds drawn for a white page. */ +.network-mark { + display: flex; + align-items: center; + justify-content: center; + width: 34px; + height: 34px; + border-radius: var(--corner-radius); + color: #fff; + font-weight: 600; + overflow: hidden; +} + +.network-mark img { + /* Contain rather than cover: a wordmark cropped to a square is a wordmark + with its ends cut off. */ + width: 100%; + height: 100%; + object-fit: contain; + padding: 3px; +} + +.network-mark-other { + background: var(--surface-container-high); + color: var(--surface-variant-text); +} + +/* Same reasoning as the picker, at rail size. */ +.rail-network-logo { + width: 100%; + height: 100%; + object-fit: contain; + padding: 2px; +} + +/* --- accounts, by service ------------------------------------------------ + A dozen accounts across five services was one long column, so each service + folds. Open by default: somebody with one account of a kind should not have + to open anything to see it. */ +.account-group { + display: flex; + flex-direction: column; +} + +.account-group-head { + display: flex; + align-items: center; + gap: var(--spacing-s); + padding: var(--spacing-xs) var(--spacing-xs); + background: none; + border: none; + border-radius: var(--corner-radius-small); + color: var(--surface-variant-text); + text-align: left; +} + +.account-group-head:hover { + background: var(--surface-container-high); + color: var(--surface-text); +} + +.account-group-name { + flex: 1; + font-weight: 600; +} diff --git a/src/renderer/src/assets/irc/coreirc.png b/src/renderer/src/assets/irc/coreirc.png new file mode 100644 index 0000000000000000000000000000000000000000..d2cc5d04827852299afb15cb01a09ca1a2ddb4c3 GIT binary patch literal 5571 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 RwZs4b002ovPDHLkV1l-yc{>0A literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/irc/dalnet.png b/src/renderer/src/assets/irc/dalnet.png new file mode 100644 index 0000000000000000000000000000000000000000..de6d759f29694afd808f6bfae02877718260ada5 GIT binary patch literal 2707 zcmV;E3T*X>P)=@%F<=CY1I9G#j056ektQ)Jw=pE21V8{ocXtntT ziUmU|mEh3tKFj3U^QcIxv!U$O>DjoI^^YCmgA=FGct3}~`T|xGNUWWFZWZU(lehW^ z21LL#Nf5xj6%5iMT?`7%)tU%n+qN(=Hi9*l^+TgfJhGRyo41f&J4m#%omw)@!0w$) zzWh_Z^AA5@-ya-e;@Qvh(fNxkPEIm&<`j)u1@)?ESc9;EhOH1)i~RZ&h=8hK#lc7f z5#i})p5*J__-kJHldn)H_=uBcX{CbiH;AM33`@1MTU{Pc%V%@YI_H1r@0u-4+l679_`5JAO3BuXTn;-$a&3!eY0ukp>l z{T3(RJV7~EplBV2pE$t8?|%`~HAJn{APj5Re3s&c)2vQkMx_lWH9#gC;p(cxy77K? z?c2?@`DKbrHxaEjPa4|X0ugCNy1~Vu@dRHu@^^F`*vqTOPV&afuVLdcA{~P~c=%7r zEG=^KyFVhXb)sPbsjcFbm$A4c7z&S7KMn(lfQC;T4ox* zyh7-Eh*zavui!>q(2%fFA(&qvJA0FQNeFUPcJ8~EXc&@Pm_s#0QCtvE#lg_G`*AYi zFl#kJs+4E;gL636($+u7^iN(T)qf9-;#n%!&*8c-Fg%QeLew}o(I_H9wca38xJppT zAjYMUZ6o+-0DsMP-oM^XtYZh(ybDgy0-&JcU^+TU_U)!#ZqU~kr@C+fTW^3HC3p1_ z(XP7)%U7sm&LSdg+%&|`-}(s=$KVP$(o9#@D#db@Q)edmw;#UB(Vw59k*(nu=5f;- zFb<3l^)YkBN3@0$i?<)KxC~4@#LY@OgX6ult?NXV=9zix4Z^?!KOoiBOC@s_Ofv(n z?d##x`=`h(W$5kc#cMQZcplM6gmf~&T|*oA+`)Zp8QaA1V{g-_c?7i;8r2*x{_%6X z_x=>BU_=aVB1yFxkXpN*Fy;}IYgB6ifd+^TiFWi+%v?ke3@&0E4n6xc|MKm>=kD#> z_}roA*}h{hwSAU{NmS`I)4dc44?h?(5N+V9K}k4h~t25+>xfYZ@(Kc4iD_yf!FXb#zn*-S19pcuOH`` z=U!xLW)6(-J5TLr*UoL=M3873*;2r>Pk$Pd>cJ=h)W9c(sV-7qUZS#EBfq@D`r$E% zBvA>$24F+Pf{0<`*cfF`xp;k%)m#Bp3u+Oys8tH33g7i4McM$RI`<)Slhcgu+0VImkFw>#eOMH0EeZtP!_2SNn9GM;pF58g1#C;eDBzRc zG)m8-Pcc^sxq7q6uiiWkUY-5V9N_h-DznpBsw<1Q>Us1I4IxevRiDgs8mSe*#5sTb zI1>jx%jE0-$>^@fx%B=oP#fYW`dL}3AeyJRcpcPYu^2$a(mk?=i7y`^Q;(xzgR4LO zA*IY!1~+cP8QR0_nMG`F9=E)T>(|Tl^rQ&e#_{V7dgdOWUagTDx{uP#Tht19?%w?< z=ic}qde@J!oVkIYQwrec+PT|S2TB_!QBpu3(pNcRJe@!Ge(PT!_&^xSnf*=&aF)Jb~BKSk)b zv0-Q(Bijc#`h$PR4|{PsHWE#*C6egEiKh`afrE$d6=)PLP`dmML3IU393FrEFbx^t zSFfE!0LS>$mi`w|Tm!uKi#K_scP%C~tjtYQFBM62cY^g|3)mY}qVt=@#N(J~Z5;JPF_`)M25 zM$hQ|1Qr(F{wcLez?#vWc-eVM+3QH-CR)#7j3UOl<-nw)d%fQB1h;IRHXRHCE(|>M z1%l)_wS_sx3pZB|p(Tg8!}xsT%BxysuS+{U)5@_(R#KnsP?+q3`x N002ovPDHLkV1jeJL?r+K literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/irc/hackint.png b/src/renderer/src/assets/irc/hackint.png new file mode 100644 index 0000000000000000000000000000000000000000..00d0d963a8f7ff654acb3c054cfc0567fd7f151c GIT binary patch literal 10409 zcmV;aC|1{rP)003bK1^@s6QG2R900007bV*G`2kHp} z4>>d5ru0ex04QBaL_t(|+U=crcvaQ8|37Q*ea4Is2xBA=5(Jb{P>NJRa6p`@wQ6m9 z+uPeZwfDBQz4i9CwZpZwldY{jpi2)a8Ycz~D&n~@s06M&Ni%m$_dRT@uzy<2B-fDWNZ?@eH(-rg1ppxhzV z4}RdwIUqsrQViVK34H0|2j&B_1>6sO57@8&7Ste}3Y;k5lM)*5ko}JZtxgZ!Ea5ib z+rX%99zHMmtyU>^ifKuI56${UZh9YH&Tll^p}_|f0@I{ol#c=vHOp8zp3*=8w@Pmp z=|@@zJPSMzyj_q6My(=y0z;8z-JYZXxK^|Cb-=rtmG1#=2j)q*ME~6HRj@SRJ_lX~ z#scf}J>g%adU}xtHoKFZ1eq#ta2hLhW`gUE5-5KzmF|?9-nDAI0Jt2O1(Y1c_gbd4 z$3ktRtpk<<%YiKt!inciu&>K=Hfu($$?=+>0Jdr-l)z)azw6`2d$fJp-$Zm)OX;j$x{tCPXv?jELnf9+%t&4##XivDO_xMNP^8#!M zcWU)~J#gpI)F^$%fN6|#8t`TP^_A+EwgvcCU}FLZodA0x@Kya44n7L^7Y1GdexX&E zBlZ1#sfQPNn@qW?Iy|vNpQpN?X`wdYb(ACVWO`UdW)4sp@LAw%+I;9rwgG1W4Zs(H zub^zexBFvAEqOb4r5Wd1Mg6o3C*5p;1WLUvY z{ok4T0o}mY0@RWTz5wu_z#sYud}s##o=_^K%G85$n^Yf8LVh8Y$DL-2mID6;{HC9S z4~?%mz}=`I^EpDRTPMvv(Vq2Zv}5BYJ`KEtBeR|iZs~^%n25YJQ|f+EHgfFcY)t&vtfTi9h;~OiGWjq`*heS zU2Ie$;V&PA43+?RP$ttImjZv%Ur}di04~#NA~DE6)UN*fJ-|`zr2G?Dt-WV-{B9~L zh(1qyydQLV2DnkcrbNg_q%_u98iW;{RQV@>M?MGuv=Th6f4;N}@O1z`6tG67_Pr66 zqdXSu;?)dyx@O}9BL_7rA5U^N@PLGCCA=!q^r=vxoc~0bCZ%a+Qo{%3;fypdb~Gw{ z;wHZraKB8&2(JXbag0M52`SLbSAHCVFCkNb-wK#wlNs1IRI2owT4e(~0lf7=c`(aJ zeNmH8>9@}EGVmj*ZD5g#r5&$>-Ct!2a4YaBBQsRvSn#{Jik(i?CRA8{Pym?b9@U_t z=jjsG2LC&0Z1Z2hxB)yI6FCR?f&;gh+U_V&jOd_Zh)*V%u|F8U^?T}(ap_vZA%s98 zvC@K_L6>tpD&T2Ss&|Rr2ISZ|jsIrABNE;Y^4>EWwGFilE*;FfE$<=_I5-F?82a0x z9Q>DqhyuavlV@}2(4oA}yKUPxY{y0zA`x9V1yx+p2CSF1+<^&3Jax=dsEGVwU_0@e+&G#Sw7=+u{fd1s(1 zEQ<1dP$v#;`%N4>Z6qWJ0}6*Zm>zlDfG-vjv^RIsIT9=%6qMVDql3hCz`4Kyo7`#) zv%uF_m-t!?BvVnfg_E@Qj_c6YI$(vAvQg+8udb-h=lniYEb~(?yDSGTwf#axYG844J@vPZP=F=ZW5t6T=a>j1?We3rN^MTDwF3&mP zRfQ@^bCXFxr3Kw5sCAge&<~xrL_&hK4+Lqd^&rd?%!C1Ru5BYw>Kqr~6F^hF#fJY2 za=6h<0#Hf_4t5xKMg&7}w4ODH%7i?d?}3{)Z`SuKEQ!1c6-P9>;g6Gn+a+=@@QSa) zm(S%AC3xKuR8HP%bi(#j#N&RzW!pwYj=r6HLT^kW=}3@x zP^De@RDoC6hHVL%m%&*N)VV7P5?BtEs!t!?R?3{&X;@`M*0hYVUvzS_WN6a zcN@$M+fO=BwIIT%nX$eoM=8@`z}#)Wy1Il*J{~pyB_BB=g|zc|DME;YI+~I`v$6pd zOMU>E`@|bi^<~YZN`1O#zYf7V&3;Cjq7Wubyr2VLu|uRi`)e{xu>8{1la5Ea|N4Xg zPf+G`oMD3Lk;KC9%`G*(aCn!QVn2~$zd)&CKaWRd0WcmG$WH$$45WVcTy83eDhSD0 z>=GL@Vgk&h`=VAic|%Ac&0vmNUx1Vnf%uC=?g2icLww7fsPmTLHIlYnxAG;CbVN1) z%TSYea$~=jGuaQoFyO6!2H3vBk95&W-k{{P>sqOt6HRF|39IidWb0CY2E$~7N;36| z5GQ@CBME|4zb~ZrX@3?ooqSmcZ=q!E{RO=SpAazn^KI~Uwm`4L9Xie`XM0Q
  • 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 + + + + + + + diff --git a/src/renderer/src/assets/irc/oftc.png b/src/renderer/src/assets/irc/oftc.png new file mode 100644 index 0000000000000000000000000000000000000000..c8d994d8f77239645ce85464bce127b07d802729 GIT binary patch literal 4063 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 RgoywE002ovPDHLkV1nc$rHTLm literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/irc/quakenet.png b/src/renderer/src/assets/irc/quakenet.png new file mode 100644 index 0000000000000000000000000000000000000000..166dbc8cec71f6e9edf77a08de867043988543c4 GIT binary patch literal 6283 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 BHpl<~ literal 0 HcmV?d00001 diff --git a/src/renderer/src/assets/irc/rizon.png b/src/renderer/src/assets/irc/rizon.png new file mode 100644 index 0000000000000000000000000000000000000000..1a44e3c36dca4011dc4afd89e801174917dc394b GIT binary patch literal 10288 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 RR+s<)002ovPDHLkV1fbaJv;ya literal 0 HcmV?d00001 diff --git a/src/renderer/src/components/AccountsPanel.tsx b/src/renderer/src/components/AccountsPanel.tsx index 97ec38e..1a52366 100644 --- a/src/renderer/src/components/AccountsPanel.tsx +++ b/src/renderer/src/components/AccountsPanel.tsx @@ -1,15 +1,24 @@ import { useEffect, useState } from 'react' import { Icon, IconButton, MaskIcon } from './Icon' import { MatrixAccountTools } from './MatrixAccountTools' -import { useChat, usePref, useStore } from '../state/hooks' -import { bufferDisplayName, resolveMediaUrl, serviceIcon, serviceLabel } from '../lib/util' +import { useChat, useMapPref, usePref, useStore } from '../state/hooks' +import { bufferDisplayName, classes, resolveMediaUrl, serviceIcon, serviceLabel } from '../lib/util' import { IRC_NETWORKS, ircNetworkFor } from '../lib/networks' -import { KNOWN_SOCKCHAT_ROOMS } from '../lib/sockchat' import type { Account } from '../../../shared/wire' -const ADDABLE = ['irc', 'discord', 'sockchat', 'matrix', 'kick'] as const +const ADDABLE = ['irc', 'discord', 'sneedchat', 'matrix', 'kick'] as const type AddableService = (typeof ADDABLE)[number] +/** + * Account states that make a service's group open itself. + * + * "connecting" is in here as well as the outright failures: an account that + * is still trying is the other thing somebody opens this page to look at, and + * it settles into "connected" on its own a moment later - at which point the + * group folds away again without anybody doing anything. + */ +const TROUBLE_STATES = ['connecting', 'auth_failed', 'error'] + export function AccountsPanel(): JSX.Element { const accounts = useChat((s) => s.accounts) const pendingLink = useChat((s) => s.pendingLink) @@ -23,20 +32,37 @@ export function AccountsPanel(): JSX.Element { if (pendingLink) setAdding('irc') }, [pendingLink]) + /** + * Whether each service's accounts are shown, where somebody has said. + * + * A preference rather than component state: folding four IRC accounts away + * is a decision about how this page should look, and having to make it + * again on every visit is worse than not being able to make it at all. + * + * Absent means "decide for me", and the answer is closed - the pane is for + * the things you do rarely, and a dozen accounts across five services made + * it one long column - except for a service with an account in trouble. + * Those open themselves, because an account that cannot connect is the one + * reason to be on this page that nobody navigated here for. + * + * An explicit choice outranks that, in both directions: closing a group + * that opened itself keeps it closed, which is somebody saying they know. + */ + const [openGroups, setOpenGroups] = useMapPref('accountGroupOpen') + + // Grouped by service, in the order the picker offers them, so the two + // halves of this page agree about what order services come in. + const byService = ADDABLE.map((service) => ({ + service, + accounts: accounts.filter((a) => a.service === service) + })).filter((group) => group.accounts.length > 0) + return (
    -
    - {accounts.length === 0 && ( -

    - No accounts yet. Pick a service below to connect one — nobilis keeps the connection alive - in the background, so it survives closing this window. -

    - )} - {accounts.map((account) => ( - - ))} -
    - + {/* Adding comes first now. It is the thing somebody opens this page to + do that has no other route - an existing account can be reached by + its own rail tile, and reading down a list of them to find the add + section was the page's shape rather than anybody's intent. */}

    Add an account

    @@ -58,10 +84,44 @@ export function AccountsPanel(): JSX.Element { {adding === 'irc' && setAdding(null)} />} {adding === 'discord' && } - {adding === 'sockchat' && } + {adding === 'sneedchat' && } {adding === 'matrix' && } {adding === 'kick' && setAdding(null)} />}
    + +
    + {accounts.length === 0 && ( +

    + No accounts yet. Pick a service above to connect one — nobilis keeps the connection + alive in the background, so it survives closing this window. +

    + )} + + {byService.map(({ service, accounts: mine }) => { + const icon = serviceIcon(service) + // A service worth looking at even unasked: something in it is not + // working, or is on its way to working. + const troubled = mine.some((a) => TROUBLE_STATES.includes(a.state)) + const shut = !(openGroups[service] ?? troubled) + return ( +
    + + {!shut && mine.map((account) => )} +
    + ) + })} +
    ) } @@ -314,8 +374,6 @@ function AccountRow({ account }: { account: Account }): JSX.Element { )} - {account.service === 'sockchat' && } - {account.service === 'discord' && } {account.service === 'matrix' && } @@ -493,111 +551,6 @@ function IrcSasl({ ) } -/** - * Which Sneedchat rooms this account stays connected to. - * - * On the account rather than in Settings, because it was never a preference - - * it is part of an account's configuration, and the daemon has always stored - * and addressed it per account. The Settings pane drew one copy and resolved - * it with `accounts.find(service === 'sockchat')`, so with two Sneedchat - * accounts it silently edited the first and gave the second no way in at all. - * - * Each enabled room keeps its own permanent connection, but they share the one - * embedded Tor circuit, so enabling another costs very little. - */ -function SneedchatRooms({ - account, - call -}: { - account: Account - call: (method: string, params: Record) => void -}): JSX.Element { - const enabled = account.sockchatRooms ?? [] - /** - * The catalogue, from the site. - * - * It used to be six rooms written into this client, which is how #lolcows - * came to be unreachable without editing the source. The daemon reads the - * room switcher off the chat page - the same list a person sees down the - * side of the site - and this asks for it when the account's settings are - * opened. - * - * The built-in list is the fallback rather than the source: the site is - * behind a proof-of-work gate over Tor and the fetch can fail, and six - * rooms somebody can still tick is a better answer than none. - */ - const [rooms, setRooms] = useState(KNOWN_SOCKCHAT_ROOMS) - const [asking, setAsking] = useState(true) - const [error, setError] = useState('') - - useEffect(() => { - let live = true - setAsking(true) - // The answer to this is whatever the daemon read last, which may be - // nothing on a fresh start; the live read happens behind it and arrives - // as an event, because it takes about fifteen seconds through Tor and the - // site's gate and the daemon answers one request at a time. - void window.moho - .rpc<{ rooms: { id: number; name: string }[] }>('listSockChatRooms', { accountId: account.id }) - .then((r) => live && r.rooms?.length && setRooms(r.rooms)) - .catch((e: Error) => live && setError(e.message)) - - const stop = window.moho.onEvent((frame) => { - if (frame.event !== 'sockchatRooms') return - const data = frame.data as { accountId: string; rooms: { id: number; name: string }[] } - if (!live || data.accountId !== account.id || !data.rooms.length) return - setRooms(data.rooms) - setAsking(false) - setError('') - }) - return () => { - live = false - stop() - } - }, [account.id]) - - const toggle = (id: number, on: boolean): void => { - const next = on - ? enabled.some((r) => r.id === id) - ? enabled - : [...enabled, rooms.find((r) => r.id === id)!] - : enabled.filter((r) => r.id !== id) - call('setSockChatRooms', { accountId: account.id, rooms: next }) - } - - return ( -
    - - Channels{asking ? ' — asking the site…' : ''} - - {rooms.map((room) => ( - - ))} - {/* Said rather than hidden: this list is the one this client shipped - with, and the site may well have rooms that are not in it. */} - {error && ( - - Could not read the site's room list ({error}) — showing the rooms this client knows. - - )} - {/* An account with none ticked still connects to #general - the daemon - falls back to it so a freshly added account is usable before anybody - has been here. Worth saying, or an empty list reads as "connected to - nothing". */} - {enabled.length === 0 && ( - None chosen - this account uses #general. - )} -
    - ) -} - function LabeledInput({ label, defaultValue, @@ -638,8 +591,29 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element { const [host, setHost] = useState(link?.host ?? '') const [port, setPort] = useState(String(link?.port ?? (link?.tls === false ? 6667 : 6697))) const [ssl, setSsl] = useState(link?.tls ?? true) - const [autojoin, setAutojoin] = useState(link?.channels.join(',') ?? '') + // What a link asked to join, carried straight through. There is no field + // for it: which channels an account joins on connect is a property of the + // channels, and is set on them - see the buffer list's own menu. + const autojoin = link?.channels.join(',') ?? '' + /** + * How this account proves who it is, if at all. + * + * Asked here rather than left to the account's settings afterwards, because + * a nick that is registered and not identified is a nick you lose to the + * ghost of your last session - which happens on the first connection, well + * before anybody goes looking for a settings pane. + * + * SASL and NickServ are both offered because networks differ: SASL + * identifies before anything else happens, which is what a channel with + * +r wants, and the older networks have only NickServ. + */ + const [auth, setAuth] = useState<'none' | 'sasl' | 'nickserv'>('none') + const [password, setPassword] = useState('') const [busy, setBusy] = useState(false) + // Only a network nobody picked from the tiles needs its address typed, and + // showing those fields for one that was picked invites editing a hostname + // that is already right. + const chosen = ircNetworkFor(host) const submit = async (): Promise => { if (!nick || !host) return @@ -650,8 +624,18 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element { host, port: Number(port) || undefined, ssl, - autojoin + autojoin, + // SASL's own password field is the account password; NickServ's is + // sent as a message after connecting, which is a different thing and + // a different call. + ...(auth === 'sasl' ? { sasl: true, saslUser: nick, password } : {}) }) + if (auth === 'nickserv' && password) { + await window.moho.rpc('setAccountNickservPassword', { + accountId: `${nick}@${host}`, + password + }) + } await store.refreshAccounts() store.clearPendingLink() onDone() @@ -664,64 +648,127 @@ function IrcForm({ onDone }: { onDone: () => void }): JSX.Element { return (
    + {/* Typing a hostname and port correctly is the one step here where a + small mistake looks like the server being down, so the networks + people actually join are offered ready-made. Choosing one fills + the fields rather than hiding them: they stay editable, and a + network not on the list is typed in exactly as before. */} +
    + Network + {/* Tiles rather than a dropdown, now that these carry the networks' + own logos: a list of names says nothing a person recognises, + and a wall of marks is how anybody actually finds the network + they are on. "Other" stays first, because a network not on the + list is typed in exactly as before. */} +
    + + {IRC_NETWORKS.map((n) => ( + + ))} +
    +
    +
    - {/* Typing a hostname and port correctly is the one step here where a - small mistake looks like the server being down, so the networks - people actually join are offered ready-made. Choosing one fills - the fields rather than hiding them: they stay editable, and a - network not on the list is typed in exactly as before. */} - -
    + {/* How to identify. Three choices rather than two switches, because + they are alternatives: a network wants one or the other, and asking + for both would be asking somebody to type their password twice. */} +
    + Sign in +
    + {( + [ + ['none', 'No account'], + ['sasl', 'SASL'], + ['nickserv', 'NickServ'] + ] as const + ).map(([value, label]) => ( + + ))} +
    + {auth !== 'none' && ( setHost(e.target.value)} + type="password" + placeholder={auth === 'sasl' ? 'Account password' : 'NickServ password'} + value={password} + onChange={(e) => setPassword(e.target.value)} /> - - + )} + {auth === 'sasl' && ( + + Identifies before joining anything, using your nickname as the account name. + + )} + {auth === 'nickserv' && ( + + Sent to NickServ after connecting, and used to reclaim your nick from a stale session. + + )}
    - - + + {!chosen && ( + + )} @@ -1072,9 +1119,9 @@ function KickForm({ onDone }: { onDone: () => void }): JSX.Element { * site's proof-of-work gate, so it can take anywhere from instant to over a * minute - hence the same async-kickoff shape as Discord's QR flow. */ -function SockChatForm(): JSX.Element { +function SneedChatForm(): JSX.Element { const store = useStore() - const status = useChat((s) => s.sockChatLoginStatus) + const status = useChat((s) => s.sneedChatLoginStatus) const [username, setUsername] = useState('') const [password, setPassword] = useState('') const [totpSecret, setTotpSecret] = useState('') @@ -1106,12 +1153,12 @@ function SockChatForm(): JSX.Element { disabled={!username || !password} onClick={() => void window.moho - .rpc('addSockChatAccount', { + .rpc('addSneedChatAccount', { username, password, ...(totpSecret ? { totpSecret } : {}) }) - .then(() => store.setSockChatLoginStatus('Bootstrapping Tor…')) + .then(() => store.setSneedChatLoginStatus('Bootstrapping Tor…')) .catch((e: Error) => store.toast('error', e.message)) } > diff --git a/src/renderer/src/components/BufferList.tsx b/src/renderer/src/components/BufferList.tsx index 3d09f13..7d83ab2 100644 --- a/src/renderer/src/components/BufferList.tsx +++ b/src/renderer/src/components/BufferList.tsx @@ -261,6 +261,12 @@ export function BufferList(): JSX.Element { onSelect={() => !isJoining(b) && void store.selectBuffer(b.id, false)} onTogglePin={() => togglePin(b.id)} onToggleMute={() => toggleMute(b.id)} + onToggleAutojoin={ + b.kind === 'channel' && accounts.find((a) => a.id === b.accountId)?.service === 'irc' + ? () => store.toggleAutojoin(b.accountId, bufferDisplayName(b.name)) + : undefined + } + autojoins={store.autojoins(b.accountId, bufferDisplayName(b.name))} onHide={() => hideBuffer(b.id)} onClose={() => void store.closeBuffer(b.id)} inCall={voiceSessions.some((s) => s.bufferId === b.id)} @@ -660,6 +666,10 @@ interface BufferRowProps { onSelect: () => void onTogglePin: () => void onToggleMute: () => void + /** IRC channels only: rejoin this on every connect, or stop doing so. */ + onToggleAutojoin?: () => void + /** Whether it is in that list now. */ + autojoins?: boolean onHide: () => void onClose: () => void onCall: () => void @@ -693,6 +703,8 @@ function BufferRow({ onSelect, onTogglePin, onToggleMute, + onToggleAutojoin, + autojoins, onHide, onClose, onCall, @@ -755,6 +767,18 @@ function BufferRow({ ? { label: 'Close its window', icon: 'close_fullscreen', onClick: onDock } : { label: 'Open in a new window', icon: 'open_in_new', onClick: onPopOut }, { separator: true }, + // Which channels an account rejoins on connect is a property of the + // channels, so it is set on one - it used to be a comma-separated field + // in the add-account form, typed once and never seen again. + ...(onToggleAutojoin + ? ([ + { + label: autojoins ? 'Do not join automatically' : 'Join automatically', + icon: autojoins ? 'bookmark_remove' : 'bookmark_add', + onClick: onToggleAutojoin + } + ] as MenuEntry[]) + : []), { label: pinned ? 'Unpin' : 'Pin', icon: 'push_pin', onClick: onTogglePin }, { label: muted ? 'Unmute' : 'Mute', icon: muted ? 'notifications' : 'notifications_off', onClick: onToggleMute }, { separator: true }, diff --git a/src/renderer/src/components/Composer.tsx b/src/renderer/src/components/Composer.tsx index 3521020..ce54076 100644 --- a/src/renderer/src/components/Composer.tsx +++ b/src/renderer/src/components/Composer.tsx @@ -97,7 +97,7 @@ export function Composer(): JSX.Element | null { // which is what people do by hand on IRC anyway, and is why it is offered // here rather than left as the one service where the button is dead. const supportsAttachments = - service === 'discord' || service === 'sockchat' || service === 'matrix' || service === 'irc' + service === 'discord' || service === 'sneedchat' || service === 'matrix' || service === 'irc' // Refocus on buffer switch so typing works immediately after clicking a // channel, without a second click into the field. @@ -166,7 +166,7 @@ export function Composer(): JSX.Element | null { const insertEmoji = (token: string): void => { const el = inputRef.current if (!el) return - const preview = emojiPreview(token, service === 'sockchat' ? smilies : []) + const preview = emojiPreview(token, service === 'sneedchat' ? smilies : []) if (preview) { const img = document.createElement('img') img.className = 'composer-emoji' @@ -403,7 +403,7 @@ export function Composer(): JSX.Element | null { accountId={account?.id} // Smilies are Sneedchat-only; posting a shortcode anywhere else // would just send literal text nobody renders. - smilies={service === 'sockchat' ? smilies : []} + smilies={service === 'sneedchat' ? smilies : []} onSelect={(emoji) => { insertEmoji(emoji) setPickerOpen(false) diff --git a/src/renderer/src/components/JoinPanel.tsx b/src/renderer/src/components/JoinPanel.tsx index c4a966e..ab8be6b 100644 --- a/src/renderer/src/components/JoinPanel.tsx +++ b/src/renderer/src/components/JoinPanel.tsx @@ -1,6 +1,7 @@ import { useEffect, useState } from 'react' import { Icon } from './Icon' import { RoomSearch } from './RoomSearch' +import { KNOWN_SNEEDCHAT_ROOMS } from '../lib/sneedchat' import { useChat, useStore } from '../state/hooks' import { classes, resolveMediaUrl } from '../lib/util' import type { Account, DiscordFriend } from '../../../shared/wire' @@ -67,15 +68,8 @@ export function JoinPanel(): JSX.Element { return case 'kick': return - case 'sockchat': - return ( -
    -

    - Sneedchat's rooms are a fixed set — add or remove them from the Sneedchat category - in Settings rather than joining ad hoc here. -

    -
    - ) + case 'sneedchat': + return default: return
    Joining isn't supported for this service yet.
    } @@ -543,6 +537,104 @@ function FindRoomButton({ account }: { account: Account }): JSX.Element { ) } +/** + * Which Sneedchat rooms this account stays connected to. + * + * Here rather than in the account's own settings, now that the list comes + * from the site: this is the page for "join something", and a room being + * ticked is exactly that. The account pane is for what an account *is* - + * its name, its picture, how it signs in - and a catalogue of rooms was + * never that. + * + * Each room keeps its own permanent connection, but they share the one + * embedded Tor circuit, so joining another costs very little. + */ +function SneedchatRooms({ account }: { account: Account }): JSX.Element { + const store = useStore() + const enabled = account.sneedchatRooms ?? [] + /** + * The catalogue, from the site. + * + * The daemon reads the room switcher off the chat page - the same list a + * person sees down the side of the site - and answers with whatever it read + * last, then sends the fresh one as an event a few seconds later. The + * built-in list is the fallback rather than the source: the site is behind + * a proof-of-work gate over Tor and the read can fail, and rooms somebody + * can still tick beat an empty page. + */ + const [rooms, setRooms] = useState(KNOWN_SNEEDCHAT_ROOMS) + const [asking, setAsking] = useState(true) + const [error, setError] = useState('') + + useEffect(() => { + let live = true + setAsking(true) + void window.moho + .rpc<{ rooms: { id: number; name: string }[] }>('listSneedChatRooms', { accountId: account.id }) + .then((r) => live && r.rooms?.length && setRooms(r.rooms)) + .catch((e: Error) => live && setError(e.message)) + + const stop = window.moho.onEvent((frame) => { + if (frame.event !== 'sneedchatRooms') return + const data = frame.data as { accountId: string; rooms: { id: number; name: string }[] } + if (!live || data.accountId !== account.id || !data.rooms.length) return + setRooms(data.rooms) + setAsking(false) + setError('') + }) + return () => { + live = false + stop() + } + }, [account.id]) + + const toggle = (id: number, on: boolean): void => { + const next = on + ? enabled.some((r) => r.id === id) + ? enabled + : [...enabled, rooms.find((r) => r.id === id)!] + : enabled.filter((r) => r.id !== id) + void window.moho + .rpc('setSneedChatRooms', { accountId: account.id, rooms: next }) + .catch((e: Error) => store.toast('error', e.message)) + } + + return ( +
    +
    + + Rooms{asking ? ' — asking the site…' : ''} + + {rooms.map((room) => ( + + ))} + {/* Said rather than hidden: this list is the one this client shipped + with, and the site may well have rooms that are not in it. */} + {error && ( + + Could not read the site's room list ({error}) — showing the rooms this client + knows. + + )} + {/* An account with none ticked still connects to #general - the daemon + falls back to it so a freshly added account is usable before + anybody has been here. Worth saying, or an empty list reads as + "connected to nothing". */} + {enabled.length === 0 && ( + None chosen - this account uses #general. + )} +
    +
    + ) +} + /** * This backend runs on a user token, not a bot, so joining a server means * accepting an invite exactly as a human would. There's no guild-wide member diff --git a/src/renderer/src/components/MessageRow.tsx b/src/renderer/src/components/MessageRow.tsx index 7b78936..e2eade2 100644 --- a/src/renderer/src/components/MessageRow.tsx +++ b/src/renderer/src/components/MessageRow.tsx @@ -168,11 +168,11 @@ export function MessageRow({ // and the codes are removed instead of drawn. const [ircColours] = usePref('irc.renderColours', true) - const isSockchat = service === 'sockchat' + const isSneedchat = service === 'sneedchat' // Discord, Sneedchat and Matrix support editing and deleting your own // messages; IRC has no such concept. const canEditDelete = - !!message.isOwn && (service === 'discord' || service === 'sockchat' || service === 'matrix') + !!message.isOwn && (service === 'discord' || service === 'sneedchat' || service === 'matrix') const canReact = service === 'discord' || service === 'matrix' const isSystem = !isChatKind(message.kind) // Said to you rather than to the room. Drawn differently on purpose: the @@ -225,7 +225,7 @@ export function MessageRow({ message.html ?? formatMessage(body, { revealedSpoilers: revealed, - isSockchat, + isSneedchat, smilies: smilieIndex, channels, // Only IRC's own traffic is drawn with IRC's codes. Elsewhere they @@ -236,7 +236,7 @@ export function MessageRow({ codeBlocks, quoteBlocks } - }, [message.body, message.html, contentSniffing, sniffed, revealed, isSockchat, smilieIndex, channels, service, ircColours]) + }, [message.body, message.html, contentSniffing, sniffed, revealed, isSneedchat, smilieIndex, channels, service, ircColours]) const entries: MenuEntry[] = [ // First, above what to do about the message: the question a right click @@ -254,7 +254,7 @@ export function MessageRow({ { label: 'Reply', icon: 'reply', onClick: () => reply() }, // Beside Reply because it is the same gesture aimed somewhere else, and // only where the service has whispers at all. - ...(service === 'sockchat' && !message.isOwn && message.from && !isSystem + ...(service === 'sneedchat' && !message.isOwn && message.from && !isSystem ? ([{ label: `Whisper ${message.from}`, icon: 'lock', onClick: () => store.startWhisper(message.from) }] as MenuEntry[]) : []), // From the message rather than from a member list, because the person you diff --git a/src/renderer/src/components/NickList.tsx b/src/renderer/src/components/NickList.tsx index e9a2855..18267fc 100644 --- a/src/renderer/src/components/NickList.tsx +++ b/src/renderer/src/components/NickList.tsx @@ -211,7 +211,7 @@ export function NickList(): JSX.Element { blockKey={`${buffer?.accountId}|${member.nick}`} blocked={isBlocked(`${buffer?.accountId}|${member.nick}`)} canOpenDm={hasDirectMessages(account?.service)} - canWhisper={account?.service === 'sockchat'} + canWhisper={account?.service === 'sneedchat'} canSendFile={account?.service === 'irc'} canCall={canCall && !!member.userId} perms={perms} diff --git a/src/renderer/src/components/ServerRail.tsx b/src/renderer/src/components/ServerRail.tsx index 3c7e656..614af8e 100644 --- a/src/renderer/src/components/ServerRail.tsx +++ b/src/renderer/src/components/ServerRail.tsx @@ -117,7 +117,14 @@ function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: strin if (network) { return ( - {network.mark} + {/* The network's own logo where it has one. On the network's own + colour rather than on nothing: several of these are wordmarks + with a transparent background, drawn for a white page. */} + {network.logo ? ( + + ) : ( + network.mark + )} ) } diff --git a/src/renderer/src/components/settings/SettingsPanel.tsx b/src/renderer/src/components/settings/SettingsPanel.tsx index 538f14f..75be215 100644 --- a/src/renderer/src/components/settings/SettingsPanel.tsx +++ b/src/renderer/src/components/settings/SettingsPanel.tsx @@ -20,7 +20,7 @@ import type { Account, DccPrefs } from '../../../../shared/wire' const CATEGORIES = [ { id: 'general', label: 'General', available: true }, { id: 'irc', label: 'IRC', available: true }, - { id: 'sockchat', label: 'Sneedchat', available: true }, + { id: 'sneedchat', label: 'Sneedchat', available: true }, { id: 'tor', label: 'Tor', available: true }, { id: 'matrix', label: 'Matrix', available: true }, { id: 'kick', label: 'Kick', available: true }, @@ -58,7 +58,7 @@ function UploadHostSetting({ service, kind }: { - service: 'irc' | 'sockchat' + service: 'irc' | 'sneedchat' kind: 'images' | 'media' }): JSX.Element { // The old single setting, kept as the starting point so nobody who chose a @@ -70,7 +70,7 @@ function UploadHostSetting({ const legacyUsable = legacy === 'postimg' ? kind === 'images' : !!legacy const fallback = legacyUsable ? legacy - : kind === 'images' && service === 'sockchat' + : kind === 'images' && service === 'sneedchat' ? 'postimg' : 'catbox' @@ -137,7 +137,7 @@ export function SettingsPanel(): JSX.Element {
    {selected === 'general' && } {selected === 'irc' && } - {selected === 'sockchat' && } + {selected === 'sneedchat' && } {selected === 'tor' && } {selected === 'matrix' && ( <> @@ -524,14 +524,14 @@ function TransferSettings(): JSX.Element { ) } -function useSockchatAccount(): Account | undefined { - return useChat((s) => s.accounts).find((a) => a.service === 'sockchat') +function useSneedchatAccount(): Account | undefined { + return useChat((s) => s.accounts).find((a) => a.service === 'sneedchat') } /** * Sneedchat's own settings are per account and live on the account card. * - * They used to be here, resolved with accounts.find(service === 'sockchat') - + * They used to be here, resolved with accounts.find(service === 'sneedchat') - * which silently edited the first Sneedchat account and left a second one with * no way in at all. Which rooms an account is connected to is part of that * account's configuration rather than an application preference, and the @@ -550,8 +550,8 @@ function SneedchatSettings(): JSX.Element { title="Uploads" description="Sneedchat's own protocol carries no files at all, so one is uploaded elsewhere and posted as a link. postimg.cc is what the site's regulars use and gives a picture a page to click through to - it takes images only, which is why the two are chosen separately." > - - + + ) @@ -617,7 +617,7 @@ function KickSettings(): JSX.Element { */ function TorSettings(): JSX.Element { const store = useStore() - const account = useSockchatAccount() + const account = useSneedchatAccount() const [useProxy, setUseProxy] = useState(account?.torMode === 'proxy') const [proxy, setProxy] = useState(account?.torProxy || '') diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index d9c38c6..e4cbb54 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -1,4 +1,4 @@ -import type { SockchatSmilie } from '../../../shared/wire' +import type { SneedchatSmilie } from '../../../shared/wire' /** * Message body formatting, ported from the QML frontend's MessageList. @@ -145,7 +145,7 @@ export interface SmilieIndex { regex: RegExp | null } -export interface SmilieEntry extends SockchatSmilie { +export interface SmilieEntry extends SneedchatSmilie { url: string } @@ -171,7 +171,7 @@ export type ChannelIndex = Record export interface FormatOptions { revealedSpoilers?: Record - isSockchat?: boolean + isSneedchat?: boolean smilies?: SmilieIndex | null /** Resolves Discord's `<#id>` channel links; without it they stay literal. */ channels?: ChannelIndex | null @@ -402,7 +402,7 @@ export function formatMessage(text: string, opts: FormatOptions = {}): string { // below, since several real shortcodes ("*sigh*", "*YAWN*") are shaped like // markdown emphasis and would otherwise be mangled into sigh before // ever being recognised as a smiley. - if (opts.isSockchat && opts.smilies?.regex) { + if (opts.isSneedchat && opts.smilies?.regex) { out = out.replace(new RegExp(opts.smilies.regex.source, 'g'), (m: string) => { const s = opts.smilies!.byAlias[m] // An empty url only happens if resource-path resolution failed - leave diff --git a/src/renderer/src/lib/networks.ts b/src/renderer/src/lib/networks.ts index 11d055c..ee97d7b 100644 --- a/src/renderer/src/lib/networks.ts +++ b/src/renderer/src/lib/networks.ts @@ -1,3 +1,12 @@ +import liberaLogo from '../assets/irc/libera.svg' +import oftcLogo from '../assets/irc/oftc.png' +import rizonLogo from '../assets/irc/rizon.png' +import quakenetLogo from '../assets/irc/quakenet.png' +import undernetLogo from '../assets/irc/undernet.png' +import hackintLogo from '../assets/irc/hackint.png' +import dalnetLogo from '../assets/irc/dalnet.png' +import coreircLogo from '../assets/irc/coreirc.png' + /** * The IRC networks somebody is actually likely to be joining. * @@ -7,12 +16,17 @@ * for the networks people use, not a directory - "Other" is always there and * is what every network not on the list uses. * - * Colours are the network's own where it has a recognisable one, and are used - * to tell one server tile from another in the rail. Marks are initials rather - * than logos: those belong to the networks that own them, and a wrong-looking - * copy of somebody's logo is worse than two clean letters. Anybody who wants - * a network's real logo can import one - see the icon picker in Accounts, - * which outranks everything here. + * Each carries its network's own logo where that network publishes one, taken + * from the network's own site and bundled rather than fetched: an IRC network + * is not a CDN, a rail full of tiles should not be a rail full of requests, + * and half of these sites are plain HTTP. The marks belong to the networks + * that own them and are used here to identify those networks and nothing + * else. A network with no logo of its own keeps its initials, which is a + * better answer than a wrong-looking copy of somebody's artwork. + * + * Colours are the network's own where it has a recognisable one. They still + * matter with a logo present, because they fill the tile behind it - and + * several of these logos are wordmarks with transparent backgrounds. * * Each is deep enough to carry white initials at 4.6:1 or better, because * the tile is filled with it. Several were a shade or two brighter and were @@ -27,20 +41,29 @@ export interface IrcNetwork { host: string port: number tls: boolean - /** Two characters at most, for the rail tile. */ + /** Two characters at most, for a network with no logo. */ mark: string colour: string + /** The network's own logo, bundled. Absent where it publishes none. */ + logo?: string } export const IRC_NETWORKS: IrcNetwork[] = [ - { id: 'libera', name: 'Libera.Chat', host: 'irc.libera.chat', port: 6697, tls: true, mark: 'Li', colour: '#7f4bd8' }, - { id: 'oftc', name: 'OFTC', host: 'irc.oftc.net', port: 6697, tls: true, mark: 'OF', colour: '#2d7ab7' }, - { id: 'rizon', name: 'Rizon', host: 'irc.rizon.net', port: 6697, tls: true, mark: 'Ri', colour: '#c84b4b' }, - { id: 'quakenet', name: 'QuakeNet', host: 'irc.quakenet.org', port: 6667, tls: false, mark: 'QN', colour: '#9f6a20' }, + { id: 'libera', name: 'Libera.Chat', host: 'irc.libera.chat', port: 6697, tls: true, mark: 'Li', colour: '#7f4bd8', logo: liberaLogo }, + { id: 'oftc', name: 'OFTC', host: 'irc.oftc.net', port: 6697, tls: true, mark: 'OF', colour: '#2d7ab7', logo: oftcLogo }, + { id: 'rizon', name: 'Rizon', host: 'irc.rizon.net', port: 6697, tls: true, mark: 'Ri', colour: '#c84b4b', logo: rizonLogo }, + { id: 'quakenet', name: 'QuakeNet', host: 'irc.quakenet.org', port: 6667, tls: false, mark: 'QN', colour: '#9f6a20', logo: quakenetLogo }, { id: 'efnet', name: 'EFnet', host: 'irc.efnet.org', port: 6697, tls: true, mark: 'EF', colour: '#448060' }, - { id: 'undernet', name: 'Undernet', host: 'irc.undernet.org', port: 6697, tls: true, mark: 'UN', colour: '#3f6fa8' }, + { id: 'undernet', name: 'Undernet', host: 'irc.undernet.org', port: 6697, tls: true, mark: 'UN', colour: '#3f6fa8', logo: undernetLogo }, { id: 'snoonet', name: 'Snoonet', host: 'irc.snoonet.org', port: 6697, tls: true, mark: 'Sn', colour: '#c54f1f' }, - { id: 'hackint', name: 'hackint', host: 'irc.hackint.org', port: 6697, tls: true, mark: 'hi', colour: '#4b7d7e' } + { id: 'hackint', name: 'hackint', host: 'irc.hackint.org', port: 6697, tls: true, mark: 'hi', colour: '#2f4f4f', logo: hackintLogo }, + { id: 'dalnet', name: 'DALnet', host: 'irc.dal.net', port: 6697, tls: true, mark: 'DA', colour: '#1f4e79', logo: dalnetLogo }, + // The two this client's own users are on. Neither is large, and that is + // rather the point: a network being small is why nobody else's client + // offers it ready-made, and typing "irc.coreirc.net" correctly is exactly + // the step this list exists to remove. + { id: 'coreirc', name: 'CoreIRC', host: 'irc.coreirc.net', port: 6697, tls: true, mark: 'Co', colour: '#1d5c7a', logo: coreircLogo }, + { id: 'abjects', name: 'Abjects', host: 'irc.abjects.net', port: 6697, tls: true, mark: 'Ab', colour: '#4b4373' } ] /** diff --git a/src/renderer/src/lib/sockchat.ts b/src/renderer/src/lib/sneedchat.ts similarity index 85% rename from src/renderer/src/lib/sockchat.ts rename to src/renderer/src/lib/sneedchat.ts index 0127590..787c8d7 100644 --- a/src/renderer/src/lib/sockchat.ts +++ b/src/renderer/src/lib/sneedchat.ts @@ -2,7 +2,7 @@ * The Sneedchat rooms this client knows about without asking. * * No longer the catalogue: the daemon reads the real one off the site's own - * chat page (see listSockChatRooms), which is how #lolcows was reachable to + * chat page (see listSneedChatRooms), which is how #lolcows was reachable to * everybody using the site and to nobody using this. This is what shows while * that is being fetched, and what remains if it cannot be - the site sits * behind a proof-of-work gate over Tor, and rooms somebody can still tick beat @@ -12,7 +12,7 @@ * a property of an account, and two places disagreeing about which rooms exist * would be worse than either of them being incomplete. */ -export const KNOWN_SOCKCHAT_ROOMS: { id: number; name: string }[] = [ +export const KNOWN_SNEEDCHAT_ROOMS: { id: number; name: string }[] = [ { id: 1, name: 'general' }, { id: 8, name: 'gunt' }, { id: 15, name: 'keno-kasino' }, diff --git a/src/renderer/src/lib/util.ts b/src/renderer/src/lib/util.ts index 3839b57..8067c66 100644 --- a/src/renderer/src/lib/util.ts +++ b/src/renderer/src/lib/util.ts @@ -76,7 +76,7 @@ export function serviceIcon(service: string): ServiceIcon { case 'jabber': case 'xmpp': return { glyph: 'forum' } - case 'sockchat': + case 'sneedchat': return { mark: sneedchatMark, colour: true } // Coloured, like Sneedchat's: the mark is a green K on its own dark // ground, and drawing it as a monochrome mask would throw away the one @@ -96,7 +96,7 @@ export function serviceLabel(service: string): string { return 'Discord' case 'matrix': return 'Matrix' - case 'sockchat': + case 'sneedchat': return 'Sneedchat' case 'kick': return 'Kick' diff --git a/src/renderer/src/state/store.ts b/src/renderer/src/state/store.ts index 4f4803f..746582c 100644 --- a/src/renderer/src/state/store.ts +++ b/src/renderer/src/state/store.ts @@ -14,7 +14,7 @@ import type { Profile, Reaction, RoomPermissions, - SockchatSmilie, + SneedchatSmilie, VoiceChannel, VoicePrefs, VoiceSession @@ -98,6 +98,14 @@ export function isJoining(buffer: { id: string }): boolean { return buffer.id.startsWith(JOINING_PREFIX) } +/** The channels in an account's autojoin string, however it was written. */ +function autojoinList(autojoin: string | undefined): string[] { + return (autojoin ?? '') + .split(/[,\s]+/) + .map((c) => c.trim()) + .filter(Boolean) +} + export type ActivePanel = '' | 'accounts' | 'settings' | 'join' | 'downloads' export interface ChatState { @@ -250,7 +258,7 @@ export interface ChatState { discordMfa: { loginId: string; totp: boolean; sms: boolean; backup: boolean } | null /** The account being re-authenticated, or '' for a brand-new one. */ discordReauthAccountId: string - sockChatLoginStatus: string + sneedChatLoginStatus: string matrixLoginStatus: string } @@ -305,7 +313,7 @@ const INITIAL: ChatState = { discordLoginStatus: '', discordMfa: null, discordReauthAccountId: '', - sockChatLoginStatus: '', + sneedChatLoginStatus: '', matrixLoginStatus: '' } @@ -412,14 +420,14 @@ async function hostAccepts(hostId: string, path: string): Promise { */ async function uploadHost(service: string | undefined, attachmentPath: string): Promise { const kind = isImageFile(attachmentPath) ? 'images' : 'media' - const perService = service === 'sockchat' ? 'sockchat' : 'irc' + const perService = service === 'sneedchat' ? 'sneedchat' : 'irc' try { const prefs = await window.moho.prefs.getAll() const legacy = prefs['uploads.host'] as string | undefined // postimg takes images and nothing else, so a stored choice of it is // carried forward for pictures and not for anything else. const legacyUsable = legacy === 'postimg' ? kind === 'images' : !!legacy - const fallback = kind === 'images' && perService === 'sockchat' ? 'postimg' : 'catbox' + const fallback = kind === 'images' && perService === 'sneedchat' ? 'postimg' : 'catbox' const chosen = (prefs[`uploads.${perService}.${kind}`] as string | undefined) || (legacyUsable ? legacy : undefined) || @@ -1194,6 +1202,32 @@ export class ChatStore { this.set({ profile: null }) } + /** + * Whether an IRC account rejoins this channel when it connects. + * + * The daemon keeps it as one comma-separated string on the account, which + * is how IRC clients have always stored it; this reads that string as the + * list it is. + */ + autojoins(accountId: string, channel: string): boolean { + const account = this.state.accounts.find((a) => a.id === accountId) + return autojoinList(account?.autojoin).some((c) => c.toLowerCase() === channel.toLowerCase()) + } + + /** Adds this channel to that list, or takes it out. */ + toggleAutojoin(accountId: string, channel: string): void { + const account = this.state.accounts.find((a) => a.id === accountId) + if (!account) return + const current = autojoinList(account.autojoin) + const has = current.some((c) => c.toLowerCase() === channel.toLowerCase()) + const next = has ? current.filter((c) => c.toLowerCase() !== channel.toLowerCase()) : [...current, channel] + void window.moho + .rpc('setAccountAutojoin', { accountId, channels: next.join(',') }) + .then(() => this.refreshAccounts()) + .then(() => this.toast('info', has ? `Will not rejoin ${channel}` : `Will rejoin ${channel} on connect`)) + .catch((e: Error) => this.toast('error', e.message)) + } + private handleEvent(frame: NobilisEvent): void { const { event, data } = frame switch (event) { @@ -1396,11 +1430,11 @@ export class ChatStore { this.set({ discordLoginStatus: data.detail || '' }) break - case 'sockChatLoginStatus': - this.set({ sockChatLoginStatus: data.detail || '' }) + case 'sneedChatLoginStatus': + this.set({ sneedChatLoginStatus: data.detail || '' }) break - case 'sockChatLoginResult': - this.set({ sockChatLoginStatus: data.error || '' }) + case 'sneedChatLoginResult': + this.set({ sneedChatLoginStatus: data.error || '' }) // Refreshed either way. The daemon now saves the account before it // attempts the login, so a wrong password or a Tor bootstrap that // never finished leaves an account sitting there to correct and @@ -1720,7 +1754,7 @@ export class ChatStore { void this.refreshMatrixPermissions(bufferId) const service = this.accountFor(bufferId)?.service - if (service === 'sockchat') void this.fetchSmilies() + if (service === 'sneedchat') void this.fetchSmilies() // Both services answer the same question about the open conversation - // what can go in a message here that isn't text - so both ask it the same // way. Kick's answer also depends on who is asking, since it carries which @@ -1740,7 +1774,7 @@ export class ChatStore { this.smiliesFetched = true try { const [rows, dir] = await Promise.all([ - window.moho.rpc('listSockchatSmilies'), + window.moho.rpc('listSneedchatSmilies'), window.moho.smiliesDir() ]) const smilies: SmilieEntry[] = rows.map((s) => ({ @@ -2257,8 +2291,8 @@ export class ChatStore { clearDiscordMfa(): void { this.set({ discordMfa: null }) } - setSockChatLoginStatus(status: string): void { - this.set({ sockChatLoginStatus: status }) + setSneedChatLoginStatus(status: string): void { + this.set({ sneedChatLoginStatus: status }) } setMatrixLoginStatus(status: string): void { this.set({ matrixLoginStatus: status }) diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 6794de3..eb3f322 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -3,7 +3,7 @@ export interface Account { id: string - service: 'irc' | 'discord' | 'sockchat' | 'matrix' | 'kick' | 'jabber' | 'slack' + service: 'irc' | 'discord' | 'sneedchat' | 'matrix' | 'kick' | 'jabber' | 'slack' displayName: string /** * How the user is presenting: "online" | "idle". Distinct from @@ -41,14 +41,14 @@ export interface Account { ssl: boolean hasPassword: boolean avatarUrl?: string - sockchatRooms?: SockChatRoom[] + sneedchatRooms?: SneedChatRoom[] torMode?: string torProxy?: string useTor: boolean hasKeyBackup: boolean } -export interface SockChatRoom { +export interface SneedChatRoom { id: number name: string } @@ -475,10 +475,10 @@ export interface CustomEmoji { set?: string } -export interface SockchatSmilie { +export interface SneedchatSmilie { label: string aliases: string[] - /** Filename within the client's bundled sockchat-smilies/ resource dir. */ + /** Filename within the client's bundled sneedchat-smilies/ resource dir. */ file: string }