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. */}
+ 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. */}
+
+ {auth !== 'none' && (
setHost(e.target.value)}
+ type="password"
+ placeholder={auth === 'sasl' ? 'Account password' : 'NickServ password'}
+ value={password}
+ onChange={(e) => setPassword(e.target.value)}
/>
-
-
- Port
- setPort(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.
+
+ )}
-
- Channels to join
- setAutojoin(e.target.value)}
- />
-
-
- setSsl(e.target.checked)} />
- Use TLS
-
+
+ {!chosen && (
+
+ setSsl(e.target.checked)} />
+ Use TLS
+
+ )}
@@ -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) => (
+
+ r.id === room.id)}
+ onChange={(e) => toggle(room.id, e.target.checked)}
+ />
+ #{room.name}
+
+ ))}
+ {/* 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
}