IRC networks by their own logos, and an account form that asks less

Issue #12: the networks now carry their own marks, taken from each
network's own site and bundled rather than fetched - an IRC network is
not a CDN, a rail of tiles should not be a rail of requests, and half of
these sites are plain HTTP. Libera, OFTC, Rizon, QuakeNet, Undernet,
hackint, DALnet and CoreIRC have one; EFnet, Snoonet and Abjects publish
none and keep their initials, which is a better answer than a
wrong-looking copy of somebody's artwork. The marks belong to the
networks that own them and identify those networks here and nothing else.

CoreIRC and Abjects are in the list now - the two this client's own users
are on. A network being small is exactly why nobody else's client offers
it ready-made, and typing "irc.coreirc.net" correctly is the step the
list exists to remove.

The form itself: the network is picked from those marks rather than from
a dropdown of names, and the server and port fields appear only for
"Other" - a chosen network has its address already, and a field showing
it invites editing something that is right. Sign-in is asked here now,
as SASL or NickServ, because a registered nick that never identifies is
a nick lost to the ghost of the last session, and that happens on the
first connection rather than after anybody visits a settings pane.

Autojoin left the form entirely. Which channels an account rejoins is a
property of the channels, so it is set on one: right-click a channel,
"Join automatically". It was a comma-separated field typed once and never
seen again.

And Sneedchat's rooms moved out of the account pane into the join panel,
now that the list comes from the site. That page is for joining things; a
room being ticked is exactly that. The account pane is for what an
account is.
This commit is contained in:
2026-09-03 12:36:36 -04:00
parent 39be60f39f
commit 4f6cd9c794
28 changed files with 622 additions and 258 deletions
+6 -6
View File
@@ -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
+1 -1
Submodule nobilis updated: 4c7f16a01e...cd18d6363e
+2 -2
View File
@@ -43,8 +43,8 @@
],
"extraResources": [
{
"from": "nobilis/resources/sockchat-smilies",
"to": "sockchat-smilies"
"from": "nobilis/resources/sneedchat-smilies",
"to": "sneedchat-smilies"
},
{
"from": "resources/icons",
+2 -2
View File
@@ -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')
}
/**
+27
View File
@@ -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. */
+102
View File
@@ -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;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 96.89 78.87">
<path d="M40.86 9.3h-.01a32.2 32.2 0 00-.65.14l-.22.04a39.48 39.48 0 00-.43.1l-.21.05a39.35 39.35 0 00-5.68 1.8l-.12.06L44.8 48.82l.47-.55 1.26-1.48zm14.98-.04l-4.1 31.45 3.32-3.88.66 1.05 7.36-26.51-.18-.07a37.97 37.97 0 00-6.55-1.94 39.84 39.84 0 00-.43-.09zm-35.33 10.9a34.93 34.93 0 00-3.03 3.42 41.1 41.1 0 00-1.8 2.48v.02L37.2 49.59l1.62-2.12.08.04zm55.45-.44l-15.91 25.1 1.81 2.9 19.26-21.8a35.29 35.29 0 00-2.9-3.82 38.85 38.85 0 00-2.26-2.38zM9.98 39.94a38.23 38.23 0 00-.72 7.54L32.2 56.1l1.79-2.33zm77.07.67L65.2 53.1l1.72 2.75 20.7-7.55v-.1a22.66 22.66 0 00.01-.66v-.44-.22-.14-.22l-.01-.21v-.22l-.01-.22-.01-.22-.01-.22-.01-.22-.02-.21-.01-.22-.02-.22-.01-.22-.02-.21-.02-.22-.02-.22-.02-.22-.02-.21a31.66 31.66 0 00-.37-2.6v-.04zM12.42 62.57a39.78 39.78 0 003.96 7.03h.01l6.73-1.48.14-.18h-.16l4.18-5.44zm58.83.21l3.24 5.39 6.05 1.36.05-.06a36.02 36.02 0 002.53-4.1A37.2 37.2 0 0084.27 63z" fill="#55f" paint-order="markers fill stroke"/>
<g fill="#f5d">
<path style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" d="M55.53 35.83L44.12 48.86l-5.22-2.3-16.98 21.82h1.48l15.88-20.32 5.17 2.29 10.9-12.45c6.04 10.27 12.55 20.15 18.47 30.49h1.48z" color="#000" font-weight="400" font-family="sans-serif" overflow="visible"/>
<path style="line-height:normal;font-variant-ligatures:normal;font-variant-position:normal;font-variant-caps:normal;font-variant-numeric:normal;font-variant-alternates:normal;font-variant-east-asian:normal;font-feature-settings:normal;font-variation-settings:normal;text-indent:0;text-align:start;text-decoration-line:none;text-decoration-style:solid;text-decoration-color:#000;text-transform:none;text-orientation:mixed;white-space:normal;shape-padding:0;shape-margin:0;inline-size:0;isolation:auto;mix-blend-mode:normal;solid-color:#000;solid-opacity:1" d="M55.32 39.73l-10.6 12.15-5.17-2.15-14.64 18.64h1.62l13.4-17.15 5.14 2.13L55.14 41.8l15.84 26.62 1.56-.03z" color="#000" font-weight="400" font-family="sans-serif" overflow="visible"/>
<path d="M28.1 68.36l12.23-15.59 5.24 2.13 9.51-10.92 14.28 24.4z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+226 -179
View File
@@ -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<boolean>('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 (
<div className="panel">
<div className="panel-section">
{accounts.length === 0 && (
<p className="muted">
No accounts yet. Pick a service below to connect one — nobilis keeps the connection alive
in the background, so it survives closing this window.
</p>
)}
{accounts.map((account) => (
<AccountRow key={account.id} account={account} />
))}
</div>
{/* 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. */}
<div className="panel-section">
<h3 className="panel-heading">Add an account</h3>
<div className="service-picker">
@@ -58,10 +84,44 @@ export function AccountsPanel(): JSX.Element {
{adding === 'irc' && <IrcForm onDone={() => setAdding(null)} />}
{adding === 'discord' && <DiscordForm />}
{adding === 'sockchat' && <SockChatForm />}
{adding === 'sneedchat' && <SneedChatForm />}
{adding === 'matrix' && <MatrixForm />}
{adding === 'kick' && <KickForm onDone={() => setAdding(null)} />}
</div>
<div className="panel-section">
{accounts.length === 0 && (
<p className="muted">
No accounts yet. Pick a service above to connect one — nobilis keeps the connection
alive in the background, so it survives closing this window.
</p>
)}
{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 (
<div key={service} className="account-group">
<button
type="button"
className="account-group-head"
aria-expanded={!shut}
onClick={() => setOpenGroups(service, shut)}
>
<Icon name={shut ? 'chevron_right' : 'expand_more'} size={18} />
{icon.mark ? <MaskIcon src={icon.mark} size={16} /> : <Icon name={icon.glyph!} size={16} />}
<span className="account-group-name">{serviceLabel(service)}</span>
{/* The count is what makes a folded heading worth reading. */}
<span className="small muted">{mine.length}</span>
</button>
{!shut && mine.map((account) => <AccountRow key={account.id} account={account} />)}
</div>
)
})}
</div>
</div>
)
}
@@ -314,8 +374,6 @@ function AccountRow({ account }: { account: Account }): JSX.Element {
</>
)}
{account.service === 'sockchat' && <SneedchatRooms account={account} call={call} />}
{account.service === 'discord' && <DiscordReauth account={account} />}
{account.service === 'matrix' && <MatrixAccountTools account={account} />}
@@ -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<string, unknown>) => 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 (
<div className="sasl-block">
<span className="small muted">
Channels{asking ? ' — asking the site…' : ''}
</span>
{rooms.map((room) => (
<label key={room.id} className="checkbox-row">
<input
type="checkbox"
checked={enabled.some((r) => r.id === room.id)}
onChange={(e) => toggle(room.id, e.target.checked)}
/>
<span>#{room.name}</span>
</label>
))}
{/* 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 && (
<span className="small muted">
Could not read the site&apos;s room list ({error}) — showing the rooms this client knows.
</span>
)}
{/* 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 && (
<span className="small muted">None chosen - this account uses #general.</span>
)}
</div>
)
}
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<void> => {
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 (
<div className="add-form">
{/* 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. */}
<div className="field">
<span className="small muted">Network</span>
{/* 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. */}
<div className="network-grid">
<button
type="button"
className={classes('network-tile', !ircNetworkFor(host) && 'active')}
onClick={() => setHost('')}
>
<span className="network-mark network-mark-other">
<Icon name="add" size={18} />
</span>
<span className="small ellipsis">Other</span>
</button>
{IRC_NETWORKS.map((n) => (
<button
key={n.id}
type="button"
className={classes('network-tile', ircNetworkFor(host)?.id === n.id && 'active')}
title={`${n.host}:${n.port}`}
onClick={() => {
setHost(n.host)
setPort(String(n.port))
setSsl(n.tls)
}}
>
<span className="network-mark" style={{ background: n.colour }}>
{n.logo ? <img src={n.logo} alt="" /> : n.mark}
</span>
<span className="small ellipsis">{n.name}</span>
</button>
))}
</div>
</div>
<div className="field-row">
<label className="field">
<span className="small muted">Nickname</span>
<input className="text-field" value={nick} onChange={(e) => setNick(e.target.value)} />
</label>
{/* 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. */}
<label className="field">
<span className="small muted">Network</span>
<select
className="text-field"
value={ircNetworkFor(host)?.id ?? ''}
onChange={(e) => {
const n = IRC_NETWORKS.find((x) => x.id === e.target.value)
if (!n) return
setHost(n.host)
setPort(String(n.port))
setSsl(n.tls)
}}
>
<option value="">Other…</option>
{IRC_NETWORKS.map((n) => (
<option key={n.id} value={n.id}>
{n.name}
</option>
))}
</select>
</label>
<label className="field">
<span className="small muted">Server</span>
{/* Only for a network that was not picked. A chosen one has its
address already, and a field showing it invites editing something
that is right. */}
{!chosen && (
<>
<label className="field">
<span className="small muted">Server</span>
<input
className="text-field"
placeholder="irc.example.net"
value={host}
onChange={(e) => setHost(e.target.value)}
/>
</label>
<label className="field port">
<span className="small muted">Port</span>
<input className="text-field" value={port} onChange={(e) => setPort(e.target.value)} />
</label>
</>
)}
</div>
{/* 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. */}
<div className="field">
<span className="small muted">Sign in</span>
<div className="field-row">
{(
[
['none', 'No account'],
['sasl', 'SASL'],
['nickserv', 'NickServ']
] as const
).map(([value, label]) => (
<label key={value} className="checkbox-row">
<input
type="radio"
name="irc-auth"
checked={auth === value}
onChange={() => setAuth(value)}
/>
<span>{label}</span>
</label>
))}
</div>
{auth !== 'none' && (
<input
className="text-field"
placeholder="irc.libera.chat"
value={host}
onChange={(e) => setHost(e.target.value)}
type="password"
placeholder={auth === 'sasl' ? 'Account password' : 'NickServ password'}
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
</label>
<label className="field port">
<span className="small muted">Port</span>
<input className="text-field" value={port} onChange={(e) => setPort(e.target.value)} />
</label>
)}
{auth === 'sasl' && (
<span className="small muted">
Identifies before joining anything, using your nickname as the account name.
</span>
)}
{auth === 'nickserv' && (
<span className="small muted">
Sent to NickServ after connecting, and used to reclaim your nick from a stale session.
</span>
)}
</div>
<label className="field">
<span className="small muted">Channels to join</span>
<input
className="text-field"
placeholder="#channel, #another"
value={autojoin}
onChange={(e) => setAutojoin(e.target.value)}
/>
</label>
<label className="checkbox-row">
<input type="checkbox" checked={ssl} onChange={(e) => setSsl(e.target.checked)} />
<span>Use TLS</span>
</label>
{!chosen && (
<label className="checkbox-row">
<input type="checkbox" checked={ssl} onChange={(e) => setSsl(e.target.checked)} />
<span>Use TLS</span>
</label>
)}
<button type="button" className="button" disabled={busy || !nick || !host} onClick={() => void submit()}>
Connect
</button>
@@ -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))
}
>
@@ -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 },
+3 -3
View File
@@ -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)
+101 -9
View File
@@ -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 <DiscordJoin account={account} />
case 'kick':
return <KickJoin account={account} />
case 'sockchat':
return (
<div className="panel">
<p className="muted">
Sneedchat&apos;s rooms are a fixed set — add or remove them from the Sneedchat category
in Settings rather than joining ad hoc here.
</p>
</div>
)
case 'sneedchat':
return <SneedchatRooms account={account} />
default:
return <div className="panel muted">Joining isn&apos;t supported for this service yet.</div>
}
@@ -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 (
<div className="panel join-panel">
<div className="field">
<span className="small muted">
Rooms{asking ? ' — asking the site…' : ''}
</span>
{rooms.map((room) => (
<label key={room.id} className="checkbox-row">
<input
type="checkbox"
checked={enabled.some((r) => r.id === room.id)}
onChange={(e) => toggle(room.id, e.target.checked)}
/>
<span>#{room.name}</span>
</label>
))}
{/* 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 && (
<span className="small muted">
Could not read the site&apos;s room list ({error}) — showing the rooms this client
knows.
</span>
)}
{/* 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 && (
<span className="small muted">None chosen - this account uses #general.</span>
)}
</div>
</div>
)
}
/**
* 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
+5 -5
View File
@@ -168,11 +168,11 @@ export function MessageRow({
// and the codes are removed instead of drawn.
const [ircColours] = usePref<boolean>('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
+1 -1
View File
@@ -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}
+8 -1
View File
@@ -117,7 +117,14 @@ function GroupFace({ group, customIcon }: { group: RailGroup; customIcon?: strin
if (network) {
return (
<span className="rail-initials rail-initials-filled" style={{ background: network.colour }}>
{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 ? (
<img className="rail-network-logo" src={network.logo} alt="" />
) : (
network.mark
)}
</span>
)
}
@@ -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 {
<div className="settings-content">
{selected === 'general' && <GeneralSettings />}
{selected === 'irc' && <IrcSettings />}
{selected === 'sockchat' && <SneedchatSettings />}
{selected === 'sneedchat' && <SneedchatSettings />}
{selected === 'tor' && <TorSettings />}
{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."
>
<UploadHostSetting service="sockchat" kind="images" />
<UploadHostSetting service="sockchat" kind="media" />
<UploadHostSetting service="sneedchat" kind="images" />
<UploadHostSetting service="sneedchat" kind="media" />
</SettingsSection>
</>
)
@@ -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 || '')
+4 -4
View File
@@ -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<string, { bufferId: string; name: string }>
export interface FormatOptions {
revealedSpoilers?: Record<number, boolean>
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 <i>sigh</i> 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
+36 -13
View File
@@ -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' }
]
/**
@@ -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' },
+2 -2
View File
@@ -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'
+47 -13
View File
@@ -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<boolean> {
*/
async function uploadHost(service: string | undefined, attachmentPath: string): Promise<string> {
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<SockchatSmilie[]>('listSockchatSmilies'),
window.moho.rpc<SneedchatSmilie[]>('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 })
+5 -5
View File
@@ -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
}