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:
+1
-1
Submodule nobilis updated: 2f37c476eb...043df9bc58
@@ -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'}
|
||||
|
||||
@@ -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'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
|
||||
|
||||
@@ -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. */
|
||||
|
||||
Reference in New Issue
Block a user