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 0000000..d2cc5d0 Binary files /dev/null and b/src/renderer/src/assets/irc/coreirc.png differ diff --git a/src/renderer/src/assets/irc/dalnet.png b/src/renderer/src/assets/irc/dalnet.png new file mode 100644 index 0000000..de6d759 Binary files /dev/null and b/src/renderer/src/assets/irc/dalnet.png differ diff --git a/src/renderer/src/assets/irc/hackint.png b/src/renderer/src/assets/irc/hackint.png new file mode 100644 index 0000000..00d0d96 Binary files /dev/null and b/src/renderer/src/assets/irc/hackint.png differ diff --git a/src/renderer/src/assets/irc/libera.svg b/src/renderer/src/assets/irc/libera.svg new file mode 100644 index 0000000..d4562b1 --- /dev/null +++ b/src/renderer/src/assets/irc/libera.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/src/renderer/src/assets/irc/oftc.png b/src/renderer/src/assets/irc/oftc.png new file mode 100644 index 0000000..c8d994d Binary files /dev/null and b/src/renderer/src/assets/irc/oftc.png differ diff --git a/src/renderer/src/assets/irc/quakenet.png b/src/renderer/src/assets/irc/quakenet.png new file mode 100644 index 0000000..166dbc8 Binary files /dev/null and b/src/renderer/src/assets/irc/quakenet.png differ diff --git a/src/renderer/src/assets/irc/rizon.png b/src/renderer/src/assets/irc/rizon.png new file mode 100644 index 0000000..1a44e3c Binary files /dev/null and b/src/renderer/src/assets/irc/rizon.png differ diff --git a/src/renderer/src/assets/irc/undernet.png b/src/renderer/src/assets/irc/undernet.png new file mode 100644 index 0000000..b0c89ad Binary files /dev/null and b/src/renderer/src/assets/irc/undernet.png differ 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 }