Show what a channel is set to, and browse what a network has

The mode letters sit beside the channel's name, titled with what they
mean - the letters are only obvious to somebody who already knew, and the
ones worth explaining are the handful that decide whether you can speak.
An unknown letter is shown as itself rather than guessed at.

The channel browser is a button rather than something that happens on
opening the page: a LIST on a real network answers with tens of thousands
of channels and some servers throttle or refuse it outright. Filtering is
local, because the daemon holds the whole answer and typing narrows it
instantly, where a server-side filter would mean a fresh LIST per
keystroke against something that just rate-limited you. Two hundred rows
drawn at most, since a filter matching nothing in particular still matches
forty thousand.

The server buffer it sends the command through is found rather than
assembled from the account id: the daemon names that buffer after the host
it actually connected to, which is not always the host that was typed.

Co-Authored-By: Claude Opus 5 <[email protected]>
This commit is contained in:
2026-09-02 00:26:16 -04:00
co-authored by Claude Opus 5
parent f47667928c
commit 5b98a1bb86
5 changed files with 215 additions and 1 deletions
+1 -1
Submodule nobilis updated: 2f37c476eb...043df9bc58
+56
View File
@@ -3253,6 +3253,62 @@ video.lightbox-media {
color: var(--error);
}
/* The network's channel list. Scrolls inside itself rather than growing the
page, because even a filtered list is hundreds of rows. */
.channel-browser {
display: flex;
flex-direction: column;
max-height: 320px;
overflow-y: auto;
border: 1px solid var(--outline-medium);
border-radius: var(--corner-radius);
background: var(--surface-container);
}
.channel-browser-row {
display: flex;
align-items: baseline;
gap: var(--spacing-s);
padding: var(--spacing-xs) var(--spacing-s);
background: none;
border: none;
text-align: left;
color: var(--surface-text);
}
.channel-browser-row:hover {
background: var(--primary-hover-light);
}
.channel-browser-name {
flex: none;
max-width: 40%;
font-weight: 500;
}
/* Right-aligned and tabular so the counts line up as a column somebody can
scan, which is the one thing this list is sorted by. */
.channel-browser-users {
flex: none;
min-width: 4ch;
text-align: right;
font-variant-numeric: tabular-nums;
}
.channel-browser-topic {
flex: 1;
min-width: 0;
}
.channel-modes {
padding: 1px var(--spacing-xs);
border-radius: var(--corner-radius-small);
background: var(--surface-container-high);
color: var(--surface-variant-text);
font-family: var(--font-mono, monospace);
letter-spacing: 0.04em;
}
.conversation-search {
display: flex;
align-items: center;
@@ -6,6 +6,28 @@ import { formatFullTime } from '../lib/util'
import type { BufferEntry } from '../state/store'
import type { Message } from '../../../shared/wire'
/**
* What a channel's mode letters mean, for the ones worth explaining.
*
* Not a complete table and not trying to be: every network invents its own,
* and the ones below are the handful that change whether you can say anything.
* An unknown letter is shown as itself rather than guessed at.
*/
function describeModes(modes: string): string {
const meanings: Record<string, string> = {
m: 'moderated — only voiced users may speak',
i: 'invite only',
t: 'topic locked to operators',
n: 'no messages from outside the channel',
s: 'secret — hidden from the channel list',
k: 'needs a key',
l: 'has a user limit',
r: 'registered users only'
}
const described = [...modes.replace(/[^a-z]/gi, '')].map((letter) => meanings[letter] ?? letter)
return described.length ? described.join(', ') : modes
}
/**
* The call button and search box at the head of a conversation.
*
@@ -109,6 +131,15 @@ export function ConversationTools({ buffer }: { buffer: BufferEntry }): JSX.Elem
return (
<div className="conversation-tools" ref={box}>
{/* What the channel itself is set to. Only where there is something to
say - an unmodded channel has no modes worth a badge - and titled
with the long form, since the letters are only obvious to people who
already knew. */}
{buffer.channelModes && (
<span className="channel-modes small" title={describeModes(buffer.channelModes)}>
{buffer.channelModes}
</span>
)}
{isDiscord && isDm && (
<IconButton
name={inCall ? 'call_end' : 'call'}
+119
View File
@@ -134,10 +134,129 @@ function IrcJoin({ account }: { account: Account }): JSX.Element {
// something is sent, so this joins a buffer named for the nick.
onSubmit={(nick) => call('joinBuffer', { accountId: account.id, name: nick })}
/>
<IrcChannelBrowser account={account} />
</div>
)
}
/**
* The network's own channel list.
*
* Asked for rather than shown, and that is the whole design: a LIST on a real
* network answers with tens of thousands of channels and some servers throttle
* or refuse it outright, so it is a button somebody presses rather than
* something that happens on opening this page.
*
* Filtered here rather than in the request. The daemon holds the whole answer
* and typing narrows it instantly, where a server-side filter would mean a new
* LIST per keystroke against something that just rate-limited you.
*/
function IrcChannelBrowser({ account }: { account: Account }): JSX.Element {
const store = useStore()
// The server buffer is where a command with no conversation of its own is
// typed. Found rather than assembled from the account id: the daemon names
// it after the host it actually connected to, which is not always the host
// that was typed into the account form.
const serverBuffer = useChat((s) => s.buffers).find(
(b) => b.accountId === account.id && b.kind === 'server'
)
const [channels, setChannels] = useState<IrcChannelListing[] | null>(null)
const [asking, setAsking] = useState(false)
const [filter, setFilter] = useState('')
useEffect(() => {
return window.moho.onEvent((frame) => {
if (frame.event !== 'ircChannelList') return
const data = frame.data as { accountId: string; channels: IrcChannelListing[] }
if (data.accountId !== account.id) return
setAsking(false)
setChannels(data.channels)
})
}, [account.id])
const ask = (): void => {
if (!serverBuffer) {
store.toast('error', 'Not connected to this network yet')
return
}
setAsking(true)
setChannels(null)
void window.moho.rpc('sendMessage', { bufferId: serverBuffer.id, body: '/list' }).catch((e: Error) => {
setAsking(false)
store.toast('error', e.message)
})
}
const shown = channels
? channels.filter((c) => {
const q = filter.trim().toLowerCase()
if (!q) return true
return c.name.toLowerCase().includes(q) || c.topic.toLowerCase().includes(q)
})
: []
return (
<div className="field">
<span className="small muted">Browse the network&apos;s channels</span>
<div className="field-row">
<input
className="text-field"
placeholder={channels ? 'filter by name or topic' : 'ask the server for its list first'}
value={filter}
disabled={!channels}
onChange={(e) => setFilter(e.target.value)}
/>
<button type="button" className="button" disabled={asking || !serverBuffer} onClick={ask}>
{asking ? 'Asking…' : channels ? 'Refresh' : 'List channels'}
</button>
</div>
{asking && (
<p className="small muted">
A busy network can take a minute to answer, and some refuse the request entirely.
</p>
)}
{channels && (
<>
<p className="small muted">
{channels.length.toLocaleString()} channels, busiest first
{shown.length !== channels.length ? ` — ${shown.length.toLocaleString()} matching` : ''}
</p>
<div className="channel-browser">
{/* Capped, because a filter that matches nothing in particular
still matches forty thousand rows, and drawing them would
freeze the window to no purpose. */}
{shown.slice(0, 200).map((c) => (
<button
key={c.name}
type="button"
className="channel-browser-row"
title={c.topic || c.name}
onClick={() => join(account.id, c.name, store)}
>
<span className="ellipsis channel-browser-name">{c.name}</span>
<span className="small muted channel-browser-users">{c.users.toLocaleString()}</span>
<span className="small muted ellipsis channel-browser-topic">{c.topic}</span>
</button>
))}
</div>
</>
)}
</div>
)
}
interface IrcChannelListing {
name: string
users: number
topic: string
}
function join(accountId: string, name: string, store: ReturnType<typeof useStore>): void {
void window.moho.rpc('joinBuffer', { accountId, name }).catch((e: Error) => store.toast('error', e.message))
}
/**
* Kick has nothing to join: a channel is a streamer, and naming one is all
* there is to it. So this is the shortest page here - a handle, and it starts
+8
View File
@@ -55,6 +55,14 @@ export interface Buffer {
avatarUrl?: string
/** Matrix only - absent, not false, for protocols with no encryption concept. */
encrypted?: boolean
/**
* An IRC channel's own modes, as the server writes them - `+mnt`.
*
* Absent until the server has said, which it does on join and on every
* change. Carried because it answers "why can I not speak here" before the
* question is asked: `+m` is moderated, `+i` invite-only.
*/
channelModes?: string
/** Which rail entry this buffer sits under. */
groupId?: string
/** The service's own heading for this buffer - a Discord category. */