diff --git a/nobilis b/nobilis index 2f37c47..043df9b 160000 --- a/nobilis +++ b/nobilis @@ -1 +1 @@ -Subproject commit 2f37c476eb4ae1b659029200cdcf2950360d0964 +Subproject commit 043df9bc582e8f86f0a78c40f53f588716fd2b84 diff --git a/src/renderer/src/app.css b/src/renderer/src/app.css index e1bd774..f38b3b7 100644 --- a/src/renderer/src/app.css +++ b/src/renderer/src/app.css @@ -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; diff --git a/src/renderer/src/components/ConversationTools.tsx b/src/renderer/src/components/ConversationTools.tsx index f167b7a..f9ca990 100644 --- a/src/renderer/src/components/ConversationTools.tsx +++ b/src/renderer/src/components/ConversationTools.tsx @@ -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 = { + 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 (
+ {/* 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 && ( + + {buffer.channelModes} + + )} {isDiscord && isDm && ( call('joinBuffer', { accountId: account.id, name: nick })} /> +
) } +/** + * 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(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 ( +
+ Browse the network's channels +
+ setFilter(e.target.value)} + /> + +
+ + {asking && ( +

+ A busy network can take a minute to answer, and some refuse the request entirely. +

+ )} + + {channels && ( + <> +

+ {channels.length.toLocaleString()} channels, busiest first + {shown.length !== channels.length ? ` — ${shown.length.toLocaleString()} matching` : ''} +

+
+ {/* 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) => ( + + ))} +
+ + )} +
+ ) +} + +interface IrcChannelListing { + name: string + users: number + topic: string +} + +function join(accountId: string, name: string, store: ReturnType): 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 diff --git a/src/shared/wire.ts b/src/shared/wire.ts index 9011f00..50ce8c3 100644 --- a/src/shared/wire.ts +++ b/src/shared/wire.ts @@ -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. */