From 1aaf8847bdee21bf1052a502ec9fb14a77717a28 Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 7 May 2026 14:13:44 +0100 Subject: [PATCH 01/10] named-modes: phase 1 -- cap negotiation + numeric parsing skeleton Adds the protocol-layer scaffolding for IRCv3 draft/named-modes: - draft/named-modes added to ourCaps so the client requests it on connect. - NamedModeSpec / NamedModes types on Server (in types/index.ts) capture the long-form mode-name registry the server advertises. - src/lib/irc/handlers/named-modes.ts parses the six new numerics (960-965) plus the PROP command and emits per-event payloads through the existing ircClient event bus. - src/store/handlers/named-modes.ts stores the channel/user mode registry under server.namedModes as RPL_CHMODELIST / RPL_UMODELIST stream in. - Dispatch wiring (IRC_DISPATCH + registerAllHandlers). Phase 2 will use the registry to translate inbound PROP into the existing chanmode/usermode store updates and emit MODE-equivalent events for the rest of the UI; phase 3 adds outbound PROP for cap-negotiated servers. --- src/lib/irc/IRCClient.ts | 44 +++++ src/lib/irc/handlers/index.ts | 24 +++ src/lib/irc/handlers/named-modes.ts | 241 ++++++++++++++++++++++++++++ src/store/handlers/index.ts | 2 + src/store/handlers/named-modes.ts | 114 +++++++++++++ src/types/index.ts | 21 +++ 6 files changed, 446 insertions(+) create mode 100644 src/lib/irc/handlers/named-modes.ts create mode 100644 src/store/handlers/named-modes.ts diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index 5b1d500e..990b53e0 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -245,6 +245,49 @@ export interface EventMap { serviceName: string; jwtToken: string; }; + // IRCv3 draft/named-modes (PROP command + RPL_CHMODELIST etc.). + // The "registry" events (CHANMODE_LIST / UMODE_LIST) carry the + // server's long-form mode name table; the "mode change" event + // (PROP) carries an actual mode change; the rest pair with the + // listing forms of PROP []. + NAMED_MODES_CHANMODE_LIST: BaseIRCEvent & { + entries: Array<{ + type: 1 | 2 | 3 | 4 | 5; + name: string; + letter?: string; + }>; + isFinal: boolean; + }; + NAMED_MODES_UMODE_LIST: BaseIRCEvent & { + entries: Array<{ + type: 1 | 2 | 3 | 4 | 5; + name: string; + letter?: string; + }>; + isFinal: boolean; + }; + NAMED_MODES_PROPLIST: BaseIRCEvent & { + channel: string; + items: string[]; + }; + NAMED_MODES_PROPLIST_END: BaseIRCEvent & { channel: string }; + NAMED_MODES_LISTPROPLIST: BaseIRCEvent & { + channel: string; + modeName: string; + mask: string; + setter: string; + settime: number; + }; + NAMED_MODES_LISTPROPLIST_END: BaseIRCEvent & { + channel: string; + modeName: string; + }; + NAMED_MODES_PROP: EventWithTags & { + sender: string; + target: string; + items: Array<{ sign: "+" | "-"; name: string; param?: string }>; + timestamp: Date; + }; WHOIS_BOT: { serverId: string; nick: string; @@ -470,6 +513,7 @@ export class IRCClient implements IRCClientContext { "invite-notify", "monitor", "extended-monitor", + "draft/named-modes", // Note: unrealircd.org/link-security is informational only, don't request it ]; diff --git a/src/lib/irc/handlers/index.ts b/src/lib/irc/handlers/index.ts index 15aaf834..16d6b889 100644 --- a/src/lib/irc/handlers/index.ts +++ b/src/lib/irc/handlers/index.ts @@ -64,6 +64,15 @@ import { handleMonOffline, handleMonOnline, } from "./monitoring"; +import { + handleProp, + handleRplChmodelist, + handleRplEndOfListProplist, + handleRplEndOfProplist, + handleRplListProplist, + handleRplProplist, + handleRplUmodelist, +} from "./named-modes"; import { handleAway, handleChghost, @@ -289,6 +298,21 @@ export const IRC_DISPATCH: Record = { EXTJWT: (ctx, serverId, source, parv, mtags) => handleExtjwt(ctx, serverId, source, parv, mtags), + PROP: (ctx, serverId, source, parv, mtags) => + handleProp(ctx, serverId, source, parv, mtags), + "960": (ctx, serverId, source, parv, mtags) => + handleRplEndOfProplist(ctx, serverId, source, parv, mtags), + "961": (ctx, serverId, source, parv, mtags) => + handleRplProplist(ctx, serverId, source, parv, mtags), + "962": (ctx, serverId, source, parv, mtags) => + handleRplEndOfListProplist(ctx, serverId, source, parv, mtags), + "963": (ctx, serverId, source, parv, mtags) => + handleRplListProplist(ctx, serverId, source, parv, mtags), + "964": (ctx, serverId, source, parv, mtags) => + handleRplChmodelist(ctx, serverId, source, parv, mtags), + "965": (ctx, serverId, source, parv, mtags) => + handleRplUmodelist(ctx, serverId, source, parv, mtags), + "730": (ctx, serverId, source, parv, mtags) => handleMonOnline(ctx, serverId, source, parv, mtags), "731": (ctx, serverId, source, parv, mtags) => diff --git a/src/lib/irc/handlers/named-modes.ts b/src/lib/irc/handlers/named-modes.ts new file mode 100644 index 00000000..807eb9a1 --- /dev/null +++ b/src/lib/irc/handlers/named-modes.ts @@ -0,0 +1,241 @@ +// IRCv3 draft/named-modes — protocol-layer parser. +// +// Owns six new numerics + the PROP command: +// +// 964 RPL_CHMODELIST server -> client: long-form chanmode list +// 965 RPL_UMODELIST server -> client: long-form usermode list +// 961 RPL_PROPLIST server -> client: PROP reply (mode list) +// 960 RPL_ENDOFPROPLIST ditto, terminator +// 963 RPL_LISTPROPLIST server -> client: list-mode entry +// 962 RPL_ENDOFLISTPROPLIST ditto, terminator +// +// PROP {+|-}[=] ... — the cap-aware mode wire form +// +// Wire shape for the lists: +// +// :server XXX [*] :[=] ... +// all-but-last lines carry an asterisk before the entries +// +// Spec: https://github.com/progval/ircv3-specifications/blob/ +// e28f44f8d7b0964c82acd28eea1e35895daf0919/extensions/named-modes.md + +import type { NamedModeSpec } from "../../../types"; +import type { IRCClientContext } from "../IRCClientContext"; +import { getNickFromNuh, getTimestampFromTags } from "../utils"; + +function parseEntries(tokens: string[]): NamedModeSpec[] { + const out: NamedModeSpec[] = []; + for (const tok of tokens) { + // :[=] -- per spec, ignore unknown types so + // future spec revisions don't break us + const colon = tok.indexOf(":"); + if (colon <= 0) continue; + const typeNum = Number.parseInt(tok.slice(0, colon), 10); + if (typeNum < 1 || typeNum > 5) continue; + const rest = tok.slice(colon + 1); + const eq = rest.indexOf("="); + let name: string; + let letter: string | undefined; + if (eq === -1) { + name = rest; + } else { + name = rest.slice(0, eq); + letter = rest.slice(eq + 1) || undefined; + } + if (!name) continue; + out.push({ type: typeNum as 1 | 2 | 3 | 4 | 5, name, letter }); + } + return out; +} + +/** + * Pull the entries out of a 964/965 line. The first parameter is the + * recipient nick; if the second is a literal "*" we're in a multi-line + * advertisement (more lines to come). Either way, every remaining + * token (with the trailing `:` stripped) is a `:=` + * triple. + */ +function parseListAdvertisement(parv: string[]): { + entries: NamedModeSpec[]; + isFinal: boolean; +} { + // parv[0] = recipient nick; remove it + let tokens = parv.slice(1); + // Optional "*" continuation marker + let isFinal = true; + if (tokens.length && tokens[0] === "*") { + isFinal = false; + tokens = tokens.slice(1); + } + // The trailing param may have a leading colon (IRC trailing-arg form) + // and may be a single space-separated string. + if (tokens.length === 1 && tokens[0].startsWith(":")) { + tokens = tokens[0].slice(1).split(" "); + } else if (tokens.length) { + // Last token (if it was the trailing) loses its leading colon + if (tokens[tokens.length - 1].startsWith(":")) { + tokens[tokens.length - 1] = tokens[tokens.length - 1].slice(1); + } + } + return { entries: parseEntries(tokens.filter((t) => t.length > 0)), isFinal }; +} + +export function handleRplChmodelist( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + const { entries, isFinal } = parseListAdvertisement(parv); + ctx.triggerEvent("NAMED_MODES_CHANMODE_LIST", { + serverId, + entries, + isFinal, + }); +} + +export function handleRplUmodelist( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + const { entries, isFinal } = parseListAdvertisement(parv); + ctx.triggerEvent("NAMED_MODES_UMODE_LIST", { + serverId, + entries, + isFinal, + }); +} + +/** PROP -- mode-state list reply (961). Emits one event per + * line; the store collects them until ENDOFPROPLIST 960 fires. */ +export function handleRplProplist( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + // parv: [[=] ...] + const channel = parv[1]; + const items = parv.slice(2).map((s) => (s.startsWith(":") ? s.slice(1) : s)); + // The trailing param can carry multiple space-separated entries. + const flat: string[] = []; + for (const item of items) { + for (const t of item.split(" ")) { + if (t.length) flat.push(t); + } + } + ctx.triggerEvent("NAMED_MODES_PROPLIST", { + serverId, + channel, + items: flat, + }); +} + +export function handleRplEndOfProplist( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + const channel = parv[1]; + ctx.triggerEvent("NAMED_MODES_PROPLIST_END", { serverId, channel }); +} + +/** PROP entry (963). */ +export function handleRplListProplist( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + // [ ] + const channel = parv[1]; + const modeName = parv[2]; + const mask = parv[3]; + const setter = parv[4]; + const settimeRaw = parv[5]; + const settime = settimeRaw + ? Number.parseInt(settimeRaw.replace(/^:/, ""), 10) || 0 + : 0; + ctx.triggerEvent("NAMED_MODES_LISTPROPLIST", { + serverId, + channel, + modeName, + mask, + setter: setter ? setter.replace(/^:/, "") : "", + settime, + }); +} + +export function handleRplEndOfListProplist( + ctx: IRCClientContext, + serverId: string, + _source: string, + parv: string[], + _mtags: Record | undefined, +): void { + const channel = parv[1]; + const modeName = parv[2]; + ctx.triggerEvent("NAMED_MODES_LISTPROPLIST_END", { + serverId, + channel, + modeName, + }); +} + +/** + * Parse a server-pushed PROP command. Wire form mirrors the client + * side: `:src PROP {+|-}[=] ...` + * + * Emits NAMED_MODES_PROP for any subscriber that wants the rich form, + * AND a synthesised MODE event so existing UI paths keep working. + */ +export function handleProp( + ctx: IRCClientContext, + serverId: string, + source: string, + parv: string[], + mtags: Record | undefined, +): void { + const sender = getNickFromNuh(source); + const target = parv[0]; + // Remaining args are the mode change items; the trailing one may + // start with `:` (IRC trailing-arg form) and may pack multiple + // space-separated entries. + const tail: string[] = []; + for (let i = 1; i < parv.length; i++) { + const piece = + i === parv.length - 1 && parv[i].startsWith(":") + ? parv[i].slice(1) + : parv[i]; + for (const t of piece.split(" ")) { + if (t.length) tail.push(t); + } + } + + const items: Array<{ sign: "+" | "-"; name: string; param?: string }> = + tail.map((tok) => { + const sign: "+" | "-" = tok[0] === "-" ? "-" : "+"; + const body = tok[0] === "+" || tok[0] === "-" ? tok.slice(1) : tok; + const eq = body.indexOf("="); + const name = eq === -1 ? body : body.slice(0, eq); + const param = eq === -1 ? undefined : body.slice(eq + 1); + return { sign, name, param }; + }); + + ctx.triggerEvent("NAMED_MODES_PROP", { + serverId, + mtags, + sender, + target, + items, + timestamp: getTimestampFromTags(mtags), + }); +} diff --git a/src/store/handlers/index.ts b/src/store/handlers/index.ts index f6c59eb1..b10b3f94 100644 --- a/src/store/handlers/index.ts +++ b/src/store/handlers/index.ts @@ -6,6 +6,7 @@ import { registerChannelHandlers } from "./channels"; import { registerConnectionHandlers } from "./connection"; import { registerMessageHandlers } from "./messages"; import { registerMetadataHandlers } from "./metadata"; +import { registerNamedModesHandlers } from "./named-modes"; import { registerUserHandlers } from "./users"; import { registerWhoisHandlers } from "./whois"; @@ -18,4 +19,5 @@ export function registerAllHandlers(store: StoreApi): void { registerMetadataHandlers(store); registerBatchHandlers(store); registerAuthHandlers(store); + registerNamedModesHandlers(store); } diff --git a/src/store/handlers/named-modes.ts b/src/store/handlers/named-modes.ts new file mode 100644 index 00000000..466fa731 --- /dev/null +++ b/src/store/handlers/named-modes.ts @@ -0,0 +1,114 @@ +// Store-side wiring for IRCv3 draft/named-modes. +// +// Maintains the per-server `namedModes` registry from RPL_CHMODELIST / +// RPL_UMODELIST and handles incoming PROP changes by translating them +// into the existing chanmode/usermode store paths so the rest of the +// app keeps working without knowing about PROP. + +import type { StoreApi } from "zustand"; +import ircClient from "../../lib/ircClient"; +import type { NamedModeSpec, Server } from "../../types"; +import type { AppState } from "../index"; + +function applyChannelEntries( + state: AppState, + serverId: string, + entries: NamedModeSpec[], + isFinal: boolean, +): Pick { + const servers = state.servers.map((s: Server) => { + if (s.id !== serverId) return s; + const prev = s.namedModes ?? { + supported: true, + channelModes: [], + userModes: [], + }; + return { + ...s, + namedModes: { + supported: true, + channelModes: mergeEntries(prev.channelModes, entries, isFinal), + userModes: prev.userModes, + }, + }; + }); + return { servers }; +} + +function applyUserEntries( + state: AppState, + serverId: string, + entries: NamedModeSpec[], + isFinal: boolean, +): Pick { + const servers = state.servers.map((s: Server) => { + if (s.id !== serverId) return s; + const prev = s.namedModes ?? { + supported: true, + channelModes: [], + userModes: [], + }; + return { + ...s, + namedModes: { + supported: true, + channelModes: prev.channelModes, + userModes: mergeEntries(prev.userModes, entries, isFinal), + }, + }; + }); + return { servers }; +} + +/** Append the current line's entries to the running list. The first + * line clears any stale registry; the final line caps the burst. */ +function mergeEntries( + prev: NamedModeSpec[], + incoming: NamedModeSpec[], + isFinal: boolean, +): NamedModeSpec[] { + // The protocol burst comes as `[*] ... [*] ... :final`. Each line is + // independent; we just concatenate. Callers can rely on isFinal to + // know when to read the registry. + // Dedup by name in case the server (or a future re-advertise) sends + // overlapping entries. + const merged: NamedModeSpec[] = [...prev]; + for (const entry of incoming) { + const idx = merged.findIndex((e) => e.name === entry.name); + if (idx === -1) merged.push(entry); + else merged[idx] = entry; + } + // isFinal could trigger downstream effects (e.g. "registry ready") + // -- left as a boolean for now since callers can derive from the + // store directly. + void isFinal; + return merged; +} + +export function registerNamedModesHandlers(store: StoreApi): void { + ircClient.on( + "NAMED_MODES_CHANMODE_LIST", + ({ serverId, entries, isFinal }) => { + store.setState((state) => + applyChannelEntries(state, serverId, entries, isFinal), + ); + }, + ); + + ircClient.on("NAMED_MODES_UMODE_LIST", ({ serverId, entries, isFinal }) => { + store.setState((state) => + applyUserEntries(state, serverId, entries, isFinal), + ); + }); + + // Server-pushed PROP changes are the cap-aware equivalent of MODE. + // For phase 1 we just log them; the next phase translates them into + // the existing channel/user mode-state store updates so the chat + // header / member list / etc. stay consistent. + ircClient.on("NAMED_MODES_PROP", (event) => { + // No-op for now -- the existing MODE event still fires for + // legacy-letter-equivalent changes and drives the UI. We'll + // route name-only changes through this path in the next phase. + void event; + }); +} diff --git a/src/types/index.ts b/src/types/index.ts index d5694eff..c22ff4a5 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -46,6 +46,27 @@ export interface Server { jwtToken?: string; // JWT token for filehost authentication isUnrealIRCd?: boolean; // Whether this server is running UnrealIRCd elist?: string; // ELIST ISUPPORT value for extended LIST capabilities + // IRCv3 draft/named-modes: server-advertised long-form mode names. + // Populated from RPL_CHMODELIST (964) / RPL_UMODELIST (965) at + // connect time; consumed by the mode-rendering paths so MODE +o / + // PROP +op stay interchangeable in the UI. + namedModes?: NamedModes; +} + +export interface NamedModeSpec { + /** Spec type: 1=list, 2=param-set+unset, 3=param-set, 4=flag, 5=prefix. */ + type: 1 | 2 | 3 | 4 | 5; + /** IRCv3 long-form name, e.g. "op", "topiclock", "obsidianirc/floodprot". */ + name: string; + /** Legacy MODE letter (omitted for name-only modes). */ + letter?: string; +} + +export interface NamedModes { + /** Capability negotiated with server; if false, registry is empty. */ + supported: boolean; + channelModes: NamedModeSpec[]; + userModes: NamedModeSpec[]; } export interface ServerConfig { From 6b9e9df509c18aa8c8d75335163e4eec841d0e7d Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 7 May 2026 14:29:11 +0100 Subject: [PATCH 02/10] named-modes: phase 2 -- inbound PROP -> MODE translation The named-modes registry from phase 1 now does real work: - Inbound PROP commands: each item's long-form name is resolved to its legacy MODE letter via server.namedModes; the result is fired as a synthesised MODE event so every existing chanmode/usermode handler (channel state, member-prefix updates for op/voice, deafened/away tracking, etc.) keeps working unchanged. Items that map to name-only modes (no letter) are dropped from the MODE event and surface only via NAMED_MODES_PROP for any future UI that wants the rich form. - PROP-listing replies (961/960): buffered per server x channel and flushed into the same RPL_CHANNELMODEIS path the legacy MODE listing uses, so the chat-header / channel-settings modal pick up channel mode state from PROP without further changes. - Parser cleanup: the IRC client already strips the leading ':' from trailing args, so the named-modes parser just flattens every parv element on whitespace. Same path now handles both "964 me 5:op=o 5:voice=v" (inline) and "964 me :5:op=o 5:voice=v" (trailing) shapes uniformly. 7 new vitest cases in tests/protocol/named-modes.test.ts cover single-line bursts, multi-line continuation markers, malformed and unknown-type entries, name-only entries, and PROP item parsing (including the default-+ branch). --- src/lib/irc/handlers/named-modes.ts | 30 +++--- src/store/handlers/named-modes.ts | 106 ++++++++++++++++++-- tests/protocol/named-modes.test.ts | 144 ++++++++++++++++++++++++++++ 3 files changed, 259 insertions(+), 21 deletions(-) create mode 100644 tests/protocol/named-modes.test.ts diff --git a/src/lib/irc/handlers/named-modes.ts b/src/lib/irc/handlers/named-modes.ts index 807eb9a1..c1dd1e13 100644 --- a/src/lib/irc/handlers/named-modes.ts +++ b/src/lib/irc/handlers/named-modes.ts @@ -59,25 +59,27 @@ function parseListAdvertisement(parv: string[]): { entries: NamedModeSpec[]; isFinal: boolean; } { - // parv[0] = recipient nick; remove it - let tokens = parv.slice(1); - // Optional "*" continuation marker + // parv[0] = recipient nick; everything after is either separate + // mode-entry tokens (when the wire form put them inline) or a single + // trailing string with space-separated entries. The IRC parser + // already stripped the leading ":" from trailing args, so we just + // need to flatten on whitespace either way. + let rest = parv.slice(1); + + // Optional "*" continuation marker. let isFinal = true; - if (tokens.length && tokens[0] === "*") { + if (rest.length && rest[0] === "*") { isFinal = false; - tokens = tokens.slice(1); + rest = rest.slice(1); } - // The trailing param may have a leading colon (IRC trailing-arg form) - // and may be a single space-separated string. - if (tokens.length === 1 && tokens[0].startsWith(":")) { - tokens = tokens[0].slice(1).split(" "); - } else if (tokens.length) { - // Last token (if it was the trailing) loses its leading colon - if (tokens[tokens.length - 1].startsWith(":")) { - tokens[tokens.length - 1] = tokens[tokens.length - 1].slice(1); + + const flat: string[] = []; + for (const tok of rest) { + for (const part of tok.split(" ")) { + if (part.length) flat.push(part); } } - return { entries: parseEntries(tokens.filter((t) => t.length > 0)), isFinal }; + return { entries: parseEntries(flat), isFinal }; } export function handleRplChmodelist( diff --git a/src/store/handlers/named-modes.ts b/src/store/handlers/named-modes.ts index 466fa731..f42a6dd8 100644 --- a/src/store/handlers/named-modes.ts +++ b/src/store/handlers/named-modes.ts @@ -102,13 +102,105 @@ export function registerNamedModesHandlers(store: StoreApi): void { }); // Server-pushed PROP changes are the cap-aware equivalent of MODE. - // For phase 1 we just log them; the next phase translates them into - // the existing channel/user mode-state store updates so the chat - // header / member list / etc. stay consistent. + // We synthesise a MODE event from the registry-resolved letter so + // every existing chanmode/usermode handler keeps working without + // having to learn about PROP. Name-only modes (no letter) have no + // MODE equivalent and are dropped here; UI surfaces that want them + // can subscribe to NAMED_MODES_PROP directly. ircClient.on("NAMED_MODES_PROP", (event) => { - // No-op for now -- the existing MODE event still fires for - // legacy-letter-equivalent changes and drives the UI. We'll - // route name-only changes through this path in the next phase. - void event; + const state = store.getState(); + const server = state.servers.find((s) => s.id === event.serverId); + if (!server?.namedModes?.supported) return; + + // Channel target uses CHANTYPES heuristic (#^$ is what the + // ircd advertises today; covers any future prefix automatically + // because this branch's named-modes spec routes both via the + // same wire form). Anything else is a user target. + const isChannel = + event.target.startsWith("#") || + event.target.startsWith("^") || + event.target.startsWith("$"); + const registry = isChannel + ? server.namedModes.channelModes + : server.namedModes.userModes; + + let modestring = ""; + const modeargs: string[] = []; + let lastSign: "+" | "-" | "" = ""; + + for (const item of event.items) { + const spec = registry.find((m) => m.name === item.name); + if (!spec || !spec.letter) { + // Name-only mode -- no legacy-letter representation. The + // NAMED_MODES_PROP event still fired for richer subscribers; + // we just can't fan it through MODE. + continue; + } + if (item.sign !== lastSign) { + modestring += item.sign; + lastSign = item.sign; + } + modestring += spec.letter; + if (item.param !== undefined) modeargs.push(item.param); + } + + if (!modestring) return; + + ircClient.triggerEvent("MODE", { + serverId: event.serverId, + mtags: event.mtags, + sender: event.sender, + target: event.target, + modestring, + modeargs, + }); + }); + + // PROP-list responses (961/960): bridge to the same RPL_CHANNELMODEIS + // path so the existing channel-modes display picks them up. We build + // a single + + args from the items the server returned. + // Keyed by serverId+channel so concurrent PROP queries don't + // intermix. + type PropBuf = { items: string[] }; + const propBufs = new Map(); + const bufKey = (serverId: string, channel: string) => + `${serverId}\x00${channel}`; + + ircClient.on("NAMED_MODES_PROPLIST", ({ serverId, channel, items }) => { + const key = bufKey(serverId, channel); + const buf = propBufs.get(key) ?? { items: [] }; + for (const it of items) buf.items.push(it); + propBufs.set(key, buf); + }); + + ircClient.on("NAMED_MODES_PROPLIST_END", ({ serverId, channel }) => { + const key = bufKey(serverId, channel); + const buf = propBufs.get(key); + propBufs.delete(key); + if (!buf) return; + + const state = store.getState(); + const server = state.servers.find((s) => s.id === serverId); + if (!server?.namedModes?.supported) return; + + let modestring = "+"; + const modeargs: string[] = []; + for (const raw of buf.items) { + const eq = raw.indexOf("="); + const name = eq === -1 ? raw : raw.slice(0, eq); + const param = eq === -1 ? undefined : raw.slice(eq + 1); + const spec = server.namedModes.channelModes.find((m) => m.name === name); + if (!spec || !spec.letter) continue; + modestring += spec.letter; + if (param !== undefined) modeargs.push(param); + } + if (modestring === "+") return; + + ircClient.triggerEvent("RPL_CHANNELMODEIS", { + serverId, + channelName: channel, + modestring, + modeargs, + }); }); } diff --git a/tests/protocol/named-modes.test.ts b/tests/protocol/named-modes.test.ts new file mode 100644 index 00000000..91bbbd1c --- /dev/null +++ b/tests/protocol/named-modes.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, test, vi } from "vitest"; +import { + handleProp, + handleRplChmodelist, + handleRplProplist, + handleRplUmodelist, +} from "../../src/lib/irc/handlers/named-modes"; +import type { IRCClientContext } from "../../src/lib/irc/IRCClientContext"; + +function makeCtx() { + const events: Array<{ name: string; payload: unknown }> = []; + const ctx = { + triggerEvent: vi.fn((name: string, payload: unknown) => { + events.push({ name, payload }); + }), + activeBatches: new Map(), + } as unknown as IRCClientContext; + return { ctx, events }; +} + +describe("named-modes protocol handlers", () => { + test("RPL_CHMODELIST parses single-line burst", () => { + const { ctx, events } = makeCtx(); + // Realistic parv: IRCClient already strips the leading `:` from + // the trailing parameter, so the parser just sees the entries. + handleRplChmodelist( + ctx, + "srv1", + "obby.t3ks.com", + ["myself", "5:op=o", "5:voice=v", "1:ban=b", "4:topiclock=t"], + undefined, + ); + expect(events).toHaveLength(1); + const ev = events[0].payload as { + isFinal: boolean; + entries: Array<{ type: number; name: string; letter?: string }>; + }; + expect(ev.isFinal).toBe(true); + expect(ev.entries).toEqual([ + { type: 5, name: "op", letter: "o" }, + { type: 5, name: "voice", letter: "v" }, + { type: 1, name: "ban", letter: "b" }, + { type: 4, name: "topiclock", letter: "t" }, + ]); + }); + + test("RPL_CHMODELIST handles continuation marker (asterisk)", () => { + const { ctx, events } = makeCtx(); + handleRplChmodelist( + ctx, + "srv1", + "obby.t3ks.com", + ["myself", "*", "5:op=o 4:topiclock=t"], + undefined, + ); + const ev = events[0].payload as { isFinal: boolean }; + expect(ev.isFinal).toBe(false); + }); + + test("RPL_CHMODELIST drops malformed and unknown-type entries", () => { + const { ctx, events } = makeCtx(); + handleRplChmodelist( + ctx, + "srv1", + "obby.t3ks.com", + ["myself", "5:op=o", "garbage", "9:future=z", "4:noctcp=C"], + undefined, + ); + const ev = events[0].payload as { + entries: Array<{ name: string }>; + }; + expect(ev.entries.map((e) => e.name)).toEqual(["op", "noctcp"]); + }); + + test("RPL_UMODELIST parses name-only entries (no letter)", () => { + const { ctx, events } = makeCtx(); + handleRplUmodelist( + ctx, + "srv1", + "obby.t3ks.com", + ["myself", "4:invisible=i 4:obsidianirc/futureflag"], + undefined, + ); + const ev = events[0].payload as { + entries: Array<{ name: string; letter?: string }>; + }; + expect(ev.entries).toEqual([ + { type: 4, name: "invisible", letter: "i" }, + { type: 4, name: "obsidianirc/futureflag", letter: undefined }, + ]); + }); + + test("PROP parses sign + name + optional param items", () => { + const { ctx, events } = makeCtx(); + handleProp( + ctx, + "srv1", + "alice!~a@host", + ["#egypt", "+key=pyramids", "-topiclock", "+ban=*!*@spam.example"], + undefined, + ); + const ev = events[0].payload as { + target: string; + sender: string; + items: Array<{ sign: string; name: string; param?: string }>; + }; + expect(ev.target).toBe("#egypt"); + expect(ev.sender).toBe("alice"); + expect(ev.items).toEqual([ + { sign: "+", name: "key", param: "pyramids" }, + { sign: "-", name: "topiclock", param: undefined }, + { sign: "+", name: "ban", param: "*!*@spam.example" }, + ]); + }); + + test("PROP defaults to + when item has no explicit sign", () => { + const { ctx, events } = makeCtx(); + handleProp( + ctx, + "srv1", + "alice!~a@host", + ["#chan", "key=pyramids"], + undefined, + ); + const ev = events[0].payload as { + items: Array<{ sign: string; name: string }>; + }; + expect(ev.items[0].sign).toBe("+"); + }); + + test("RPL_PROPLIST flattens space-packed trailing items", () => { + const { ctx, events } = makeCtx(); + handleRplProplist( + ctx, + "srv1", + "obby.t3ks.com", + ["myself", "#egypt", "topiclock", "noextmsg", "limit=5"], + undefined, + ); + const ev = events[0].payload as { channel: string; items: string[] }; + expect(ev.channel).toBe("#egypt"); + expect(ev.items).toEqual(["topiclock", "noextmsg", "limit=5"]); + }); +}); From 90ab7b8703b3d034b7812fa6843d13e36d06191c Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 7 May 2026 15:06:30 +0100 Subject: [PATCH 03/10] named-modes: phase 3 -- outbound sendNamedMode helper Adds the outbound counterpart to phases 1+2: a single helper on IRCClient that takes long-form mode-name items and chooses between PROP (cap-required, can carry name-only modes) and MODE (universal, letter-required) based on the negotiated registry the caller passes in. Decision matrix: - all items have letters -> MODE (works anywhere) - cap on + any item is name-only -> PROP (only wire form for these) - cap off + name-only item -> drop unreachable item, MODE-fall-through for the rest The registry is taken as an explicit parameter rather than reached into via the store, keeping the IRC layer free of a store dependency. Callers (channel-settings modal, member context menu, etc.) already have the per-server `namedModes` from the store and pass it through. 6 vitest cases pin the decision matrix: all-letters MODE, name-only triggering PROP, cap-off dropping name-only, no-resolvable-items no-send, user-target uses userModes registry, sign-collapsing. --- src/lib/irc/IRCClient.ts | 79 ++++++++++++++++++++ tests/protocol/named-modes.test.ts | 115 +++++++++++++++++++++++++++++ 2 files changed, 194 insertions(+) diff --git a/src/lib/irc/IRCClient.ts b/src/lib/irc/IRCClient.ts index 990b53e0..c657ebe9 100644 --- a/src/lib/irc/IRCClient.ts +++ b/src/lib/irc/IRCClient.ts @@ -1343,6 +1343,85 @@ export class IRCClient implements IRCClientContext { this.sendRaw(serverId, `METADATA ${target} SYNC`); } + /** + * IRCv3 draft/named-modes: send a mode change addressed by long-form + * mode names instead of single letters. + * + * When the negotiated registry exposes a letter for every requested + * name, we build a `MODE` line so the change works on legacy + * (non-cap) servers too. When the cap is negotiated AND any item + * has no letter equivalent (a name-only mode), we send a `PROP` + * line, which is the only wire form that can carry such modes. + * + * `target` is a channel name or a nick. `items` is a list of + * `{sign, name, param?}` triples. `registry` is the per-server + * NamedModes object the caller already has from the store; passing + * it in keeps this layer free of a store dependency. + */ + sendNamedMode( + serverId: string, + target: string, + items: Array<{ sign: "+" | "-"; name: string; param?: string }>, + registry?: { supported: boolean } & { + channelModes: Array<{ name: string; letter?: string }>; + userModes: Array<{ name: string; letter?: string }>; + }, + ): void { + if (!items.length) return; + + const isChannelTarget = + target.startsWith("#") || + target.startsWith("^") || + target.startsWith("$"); + const list = isChannelTarget + ? (registry?.channelModes ?? []) + : (registry?.userModes ?? []); + + // Decide which form to send. Prefer PROP when the cap is + // negotiated AND any item is name-only (no letter); fall back to + // MODE otherwise so legacy servers / clients in mixed deployments + // keep working. + const capSupported = !!registry?.supported; + const anyNameOnly = items.some((it) => { + const spec = list.find((m) => m.name === it.name); + return !spec?.letter; + }); + + if (capSupported && anyNameOnly) { + const tail = items + .map((it) => + it.param !== undefined + ? `${it.sign}${it.name}=${it.param}` + : `${it.sign}${it.name}`, + ) + .join(" "); + this.sendRaw(serverId, `PROP ${target} ${tail}`); + return; + } + + // Build a MODE line. Drop any items that map to no letter (only + // happens when the cap isn't negotiated; the user just can't + // reach name-only modes on a legacy server). + let modestring = ""; + const params: string[] = []; + let lastSign: "+" | "-" | "" = ""; + for (const it of items) { + const spec = list.find((m) => m.name === it.name); + if (!spec?.letter) continue; + if (it.sign !== lastSign) { + modestring += it.sign; + lastSign = it.sign; + } + modestring += spec.letter; + if (it.param !== undefined) params.push(it.param); + } + if (!modestring) return; + const cmd = params.length + ? `MODE ${target} ${modestring} ${params.join(" ")}` + : `MODE ${target} ${modestring}`; + this.sendRaw(serverId, cmd); + } + // EXTJWT commands requestExtJwt(serverId: string, target?: string, serviceName?: string): void { // EXTJWT ( | * ) [service_name] diff --git a/tests/protocol/named-modes.test.ts b/tests/protocol/named-modes.test.ts index 91bbbd1c..71c8a2eb 100644 --- a/tests/protocol/named-modes.test.ts +++ b/tests/protocol/named-modes.test.ts @@ -6,6 +6,7 @@ import { handleRplUmodelist, } from "../../src/lib/irc/handlers/named-modes"; import type { IRCClientContext } from "../../src/lib/irc/IRCClientContext"; +import ircClient from "../../src/lib/ircClient"; function makeCtx() { const events: Array<{ name: string; payload: unknown }> = []; @@ -142,3 +143,117 @@ describe("named-modes protocol handlers", () => { expect(ev.items).toEqual(["topiclock", "noextmsg", "limit=5"]); }); }); + +// sendNamedMode is on the IRCClient class proper. We test it by +// constructing a stub that captures sendRaw output, since hand-rolling +// the full IRCClient lifecycle inside a unit test is overkill and +// brittle. The decision logic (PROP vs MODE, name->letter mapping, +// drop-when-no-letter) is what matters. +describe("sendNamedMode outbound translation", () => { + type Item = { sign: "+" | "-"; name: string; param?: string }; + type Registry = { + supported: boolean; + channelModes: Array<{ name: string; letter?: string }>; + userModes: Array<{ name: string; letter?: string }>; + }; + + // Spy on sendRaw on the singleton ircClient so the real method runs + // through its real `this`, exercising the shared codepath. Each + // call resets the spy so tests are independent. + function callSendNamedMode( + target: string, + items: Item[], + registry?: Registry, + ): string | null { + let captured: string | null = null; + const spy = vi + .spyOn(ircClient, "sendRaw") + .mockImplementation((_serverId: string, cmd: string) => { + captured = cmd; + }); + ircClient.sendNamedMode("srv1", target, items, registry); + spy.mockRestore(); + return captured; + } + + const fullRegistry: Registry = { + supported: true, + channelModes: [ + { name: "op", letter: "o" }, + { name: "voice", letter: "v" }, + { name: "topiclock", letter: "t" }, + { name: "ban", letter: "b" }, + { name: "obsidianirc/futureflag" }, // name-only + ], + userModes: [ + { name: "invisible", letter: "i" }, + { name: "wallops", letter: "w" }, + ], + }; + + test("emits MODE when every requested mode has a letter", () => { + const out = callSendNamedMode( + "#chan", + [ + { sign: "+", name: "op", param: "alice" }, + { sign: "-", name: "topiclock" }, + ], + fullRegistry, + ); + expect(out).toBe("MODE #chan +o-t alice"); + }); + + test("emits PROP when the cap is on and a name-only mode is requested", () => { + const out = callSendNamedMode( + "#chan", + [ + { sign: "+", name: "obsidianirc/futureflag" }, + { sign: "+", name: "op", param: "bob" }, + ], + fullRegistry, + ); + expect(out).toBe("PROP #chan +obsidianirc/futureflag +op=bob"); + }); + + test("drops name-only items when cap is unsupported and falls back to MODE", () => { + const out = callSendNamedMode( + "#chan", + [ + { sign: "+", name: "obsidianirc/futureflag" }, + { sign: "+", name: "op", param: "bob" }, + ], + { ...fullRegistry, supported: false }, + ); + expect(out).toBe("MODE #chan +o bob"); + }); + + test("returns null (no send) when nothing is resolvable", () => { + const out = callSendNamedMode( + "#chan", + [{ sign: "+", name: "obsidianirc/futureflag" }], + { ...fullRegistry, supported: false }, + ); + expect(out).toBeNull(); + }); + + test("user target uses userModes registry", () => { + const out = callSendNamedMode( + "alice", + [{ sign: "+", name: "invisible" }], + fullRegistry, + ); + expect(out).toBe("MODE alice +i"); + }); + + test("collapses repeated signs ('+', '+') into a single sign group", () => { + const out = callSendNamedMode( + "#chan", + [ + { sign: "+", name: "op", param: "alice" }, + { sign: "+", name: "voice", param: "bob" }, + ], + fullRegistry, + ); + expect(out).toBe("MODE #chan +ov alice bob"); + }); +}); From 343c7bf546d88b4f81ad696a4f1a9b3b815afc9d Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 7 May 2026 16:29:23 +0100 Subject: [PATCH 04/10] fix(channel-settings): recognise ObbyIRCd in the UnrealIRCd-detection check The "Advanced" tab in ChannelSettingsModal is gated on `server.isUnrealIRCd`, which was set by exact-matching "UnrealIRCd" in the RPL_YOURHOST (002) version string. ObbyIRCd is a downstream UnrealIRCd fork that advertises its own name there ("ObbyIRCd-..."), so the gate stayed false and channel ops on ObbyIRCd networks lost the Advanced tab even though the underlying chanmode surface is the same. Match either name. Comment also clarifies that ObbyIRCd-only features (e.g. named-modes) are detected via their own caps and should NOT be conflated with the UnrealIRCd-parity flag. --- src/store/handlers/channels.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/src/store/handlers/channels.ts b/src/store/handlers/channels.ts index 52075aa1..24992a07 100644 --- a/src/store/handlers/channels.ts +++ b/src/store/handlers/channels.ts @@ -209,10 +209,16 @@ export function registerChannelHandlers(store: StoreApi): void { }); ircClient.on("RPL_YOURHOST", ({ serverId, serverName, version }) => { - // Check if the server is running UnrealIRCd - const isUnrealIRCd = version.includes("UnrealIRCd"); + // The `isUnrealIRCd` flag gates UI surfaces that lean on the + // UnrealIRCd module-driven chanmode set (the "Advanced" tab in + // ChannelSettingsModal, etc.). ObbyIRCd is a downstream fork that + // advertises its own name in 002 but inherits that chanmode set, + // so it satisfies the same UI gate. Any features ObbyIRCd adds on + // top (e.g. named-modes) are detected separately through their + // own caps and shouldn't be conflated with UnrealIRCd parity. + const isUnrealIRCd = + version.includes("UnrealIRCd") || version.includes("ObbyIRCd"); - // Update the server with the UnrealIRCd information store.setState((state) => ({ servers: state.servers.map((server) => server.id === serverId ? { ...server, isUnrealIRCd } : server, From a51332e37e6c94f9fd80e74ac9ecd6c3b687c05b Mon Sep 17 00:00:00 2001 From: Valerie Liu Date: Thu, 7 May 2026 16:48:13 +0100 Subject: [PATCH 05/10] named-modes: registry-driven Advanced tab in ChannelSettingsModal When the server negotiates draft/named-modes, the channel-settings "Advanced" tab now drops the hardcoded UnrealIRCd-specific layout and renders one row per advertised channel mode straight from `server.namedModes.channelModes`. Each row carries: - a humanised label (humanizeNamedMode strips the vendor prefix and title-cases the rest, so "obsidianirc/this-mode-lol" becomes "This Mode Lol") - a lower-cased vendor badge to the right (when the name is vendored, e.g. "obsidianirc") - the legacy `+x` letter hint (when one exists) - a control derived from the spec's mode type: 4 (flag) -> checkbox 2/3 (param) -> text input ("" means unset) 1 (list) -> skipped (Bans/Exceptions/Invitations tabs cover it) 5 (prefix) -> skipped (member-prefix is set via member menu) Apply path collects every staged change into a single sendNamedMode call, which decides MODE vs PROP based on the registry + cap state (see phase-3 helper). Pending diff is reset on modal close so a stale draft doesn't leak across opens. Tab visibility flag was also widened: Advanced now shows whenever the server supports named-modes OR is UnrealIRCd-family. Plain UnrealIRCd servers without the cap keep getting the legacy hardcoded Advanced UI as a fallback. --- src/components/ui/ChannelSettingsModal.tsx | 907 ++++++++++++--------- src/lib/ircUtils.tsx | 35 + 2 files changed, 577 insertions(+), 365 deletions(-) diff --git a/src/components/ui/ChannelSettingsModal.tsx b/src/components/ui/ChannelSettingsModal.tsx index 44609455..93d24c78 100644 --- a/src/components/ui/ChannelSettingsModal.tsx +++ b/src/components/ui/ChannelSettingsModal.tsx @@ -18,9 +18,9 @@ import { import { useMediaQuery } from "../../hooks/useMediaQuery"; import { useModalBehavior } from "../../hooks/useModalBehavior"; import ircClient from "../../lib/ircClient"; -import { hasOpPermission } from "../../lib/ircUtils"; +import { hasOpPermission, humanizeNamedMode } from "../../lib/ircUtils"; import useStore, { serverSupportsMetadata } from "../../store"; -import type { Channel } from "../../types"; +import type { Channel, NamedModeSpec } from "../../types"; import AvatarUpload from "./AvatarUpload"; import FloodSettingsModal from "./FloodSettingsModal"; @@ -84,6 +84,14 @@ const ChannelSettingsModal: React.FC = ({ const [isUpdatingTopic, setIsUpdatingTopic] = useState(false); const [isApplyingChanges, setIsApplyingChanges] = useState(false); + // Pending changes for the named-modes-aware Advanced tab. Keyed by + // long-form mode name (e.g. "topiclock" or "obsidianirc/floodprot"). + // Apply diffs this against `originalModesRef` (which records the + // current letter-keyed state) and emits a single sendNamedMode call. + const [pendingNamedModes, setPendingNamedModes] = useState< + Record + >({}); + // Flood settings modal state const [isFloodModalOpen, setIsFloodModalOpen] = useState(false); const [floodProfile, setFloodProfile] = useState(""); @@ -197,7 +205,8 @@ const ChannelSettingsModal: React.FC = ({ ...(userHasOpPermission && supportsMetadata ? [{ id: "settings" as const, name: "Settings", icon: FaCog, count: 0 }] : []), - ...(userHasOpPermission && server?.isUnrealIRCd + ...(userHasOpPermission && + (server?.namedModes?.supported || server?.isUnrealIRCd) ? [{ id: "advanced" as const, name: "Advanced", icon: FaCog, count: 0 }] : []), ]; @@ -220,6 +229,7 @@ const ChannelSettingsModal: React.FC = ({ if (!isOpen) { hasFetchedRef.current = false; clearPendingTimeouts(); + setPendingNamedModes({}); } }, [isOpen, clearPendingTimeouts]); @@ -972,6 +982,24 @@ const ChannelSettingsModal: React.FC = ({ } }; + /** Apply path for the named-modes-aware Advanced tab. Walks the + * pendingNamedModes map and emits a single sendNamedMode call. */ + const applyNamedModesAdvancedChanges = async () => { + if (!server || !channel) return; + const items: Array<{ sign: "+" | "-"; name: string; param?: string }> = []; + for (const [name, change] of Object.entries(pendingNamedModes)) { + items.push({ sign: change.sign, name, param: change.param }); + } + if (!items.length) return; + setIsApplyingChanges(true); + try { + ircClient.sendNamedMode(serverId, channelName, items, server.namedModes); + setPendingNamedModes({}); + } finally { + setIsApplyingChanges(false); + } + }; + // Cancel pending timeouts when component unmounts useEffect(() => { return () => clearPendingTimeouts(); @@ -1010,6 +1038,128 @@ const ChannelSettingsModal: React.FC = ({ if (!isOpen) return null; + /** Look up the live state of a named mode in the parsed mode-state + * ref. Returns { set, param } where set indicates presence and + * param is the current parameter (if any). */ + const liveStateForNamedMode = ( + spec: NamedModeSpec, + ): { set: boolean; param: string | null } => { + if (!spec.letter) return { set: false, param: null }; + const stored = originalModesRef.current[spec.letter]; + if (stored === undefined) return { set: false, param: null }; + // null = present, no param. string = present with param. "__HIDDEN__" + // (used for +k/+L masking) counts as present without an exposable + // value. + return { set: true, param: stored }; + }; + + /** What the user has chosen the mode should become, taking pending + * edits into account. Returns null when there's no override. */ + const stagedStateForNamedMode = ( + name: string, + ): { sign: "+" | "-"; param?: string } | null => { + return pendingNamedModes[name] ?? null; + }; + + const stageNamedMode = ( + name: string, + next: { sign: "+" | "-"; param?: string } | null, + ) => { + setPendingNamedModes((prev) => { + const out = { ...prev }; + if (next === null) delete out[name]; + else out[name] = next; + return out; + }); + }; + + /** Render a single named-mode entry. The control type is dictated + * by the spec's mode type (1=list, 2=param-both, 3=param-add-only, + * 4=flag, 5=prefix). We skip 1 (the dedicated Bans/Exceptions/ + * Invitations tabs cover those) and 5 (member-prefix modes are + * set via the member context menu, not channel settings). */ + const renderNamedModeRow = (spec: NamedModeSpec) => { + if (spec.type === 1 || spec.type === 5) return null; + const { vendor, display } = humanizeNamedMode(spec.name); + const live = liveStateForNamedMode(spec); + const staged = stagedStateForNamedMode(spec.name); + + const isFlag = spec.type === 4; + const stagedSet = staged ? staged.sign === "+" : live.set; + const stagedParam = staged?.param ?? live.param ?? ""; + + const headerRow = ( +
+ + {display} + + {vendor && ( + + {vendor} + + )} + {spec.letter && ( + + +{spec.letter} + + )} +
+ ); + + if (isFlag) { + return ( +
+ {headerRow} + { + const next = e.target.checked; + if (next === live.set) { + stageNamedMode(spec.name, null); + } else { + stageNamedMode(spec.name, { sign: next ? "+" : "-" }); + } + }} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
+ ); + } + + // Param mode (type 2 or 3). One input + a clear button. Empty input + // means "unset"; non-empty means "set to this value". + return ( +
+ {headerRow} +
+ { + const v = e.target.value; + if (v === (live.param ?? "")) { + stageNamedMode(spec.name, null); + } else if (!v) { + stageNamedMode(spec.name, { sign: "-" }); + } else { + stageNamedMode(spec.name, { sign: "+", param: v }); + } + }} + placeholder={live.set ? "(set, edit to change)" : "(not set)"} + className="flex-1 p-2 bg-discord-dark-400 text-white rounded text-sm" + /> +
+
+ ); + }; + const contentBody = (
@@ -1420,421 +1570,444 @@ const ChannelSettingsModal: React.FC = ({ ) : ( <> {/* Advanced tab content */} -
- {/* Flood Protection Settings */} -
-
- -

- Configure flood protection rules to prevent spam and abuse. - UnrealIRCd-specific feature. -

-
- - {/* Flood Profile Selection */} -
- - -
- - {/* Flood Parameters */} -
- -
- setFloodParams(e.target.value)} - placeholder="Default" - className="flex-1 p-2 bg-discord-dark-300 text-white rounded text-sm" - /> - -
-

- Use the Configure button for detailed flood rule management, - or enter parameters manually in the format: [rules]:seconds + {server?.namedModes?.supported ? ( +

+

+ Driven by the server's draft/named-modes{" "} + registry. Standard modes (op, voice, ban, ...) live on their + own tabs above; this list shows every other channel mode the + server advertises. +

+ {(server.namedModes.channelModes ?? []) + .filter((spec) => spec.type !== 1 && spec.type !== 5) + .map((spec) => renderNamedModeRow(spec))} + {(server.namedModes.channelModes ?? []).every( + (s) => s.type === 1 || s.type === 5, + ) && ( +

+ No advanced modes are advertised by this server.

-
+ )}
- - {/* Content Filtering */} -
-

- Content Filtering -

- - {/* Block Color Codes */} -
-
+ ) : ( +
+ {/* Flood Protection Settings */} +
+
-

- Block messages containing mIRC color codes +

+ Configure flood protection rules to prevent spam and + abuse. UnrealIRCd-specific feature.

- setBlockColorCodes(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No CTCPs */} -
-
-
- {/* Filter Bad Words */} -
-
-
- {/* Strip Color Codes */} -
-
- -

- Strip mIRC color codes from messages -

+ {/* Content Filtering */} +
+

+ Content Filtering +

+ + {/* Block Color Codes */} +
+
+ +

+ Block messages containing mIRC color codes +

+
+ setBlockColorCodes(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setStripColorCodes(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
-
- {/* Channel Behavior */} -
-

- Channel Behavior -

+ {/* No CTCPs */} +
+
+ +

+ Block CTCP commands in the channel +

+
+ setNoCTCPs(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
- {/* Delay Joins */} -
-
- -

- Delay showing joins until someone speaks -

+ {/* Filter Bad Words */} +
+
+ +

+ Filter out bad words with <censored> +

+
+ setFilterBadWords(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setDelayJoins(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Knocks */} -
-
- -

- /KNOCK command is not allowed -

+ {/* Strip Color Codes */} +
+
+ +

+ Strip mIRC color codes from messages +

+
+ setStripColorCodes(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoKnocks(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - />
- {/* No Nick Changes */} -
-
- -

- Nickname changes are not permitted -

+ {/* Channel Behavior */} +
+

+ Channel Behavior +

+ + {/* Delay Joins */} +
+
+ +

+ Delay showing joins until someone speaks +

+
+ setDelayJoins(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoNickChanges(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Kicks */} -
-
- -

- Kick commands are not allowed -

+ {/* No Knocks */} +
+
+ +

+ /KNOCK command is not allowed +

+
+ setNoKnocks(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoKicks(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Notices */} -
-
- -

- NOTICE commands are not allowed -

+ {/* No Nick Changes */} +
+
+ +

+ Nickname changes are not permitted +

+
+ setNoNickChanges(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoNotices(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* No Invites */} -
-
- -

- /INVITE command is not allowed -

+ {/* No Kicks */} +
+
+ +

+ Kick commands are not allowed +

+
+ setNoKicks(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setNoInvites(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
-
- {/* Access Control */} -
-

- Access Control -

+ {/* No Notices */} +
+
+ +

+ NOTICE commands are not allowed +

+
+ setNoNotices(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
- {/* Registered Nick Required */} -
-
- -

- Users must have a registered nickname (+r) to talk -

+ {/* No Invites */} +
+
+ +

+ /INVITE command is not allowed +

+
+ setNoInvites(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- - setRegisteredNickRequired(e.target.checked) - } - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - />
- {/* Registered Users Only */} -
-
- -

- Only registered users (+r) may join -

+ {/* Access Control */} +
+

+ Access Control +

+ + {/* Registered Nick Required */} +
+
+ +

+ Users must have a registered nickname (+r) to talk +

+
+ + setRegisteredNickRequired(e.target.checked) + } + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setRegisteredUsersOnly(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* IRC Operator Only */} -
-
- -

- Only IRC operators can join (settable by IRCops) -

+ {/* Registered Users Only */} +
+
+ +

+ Only registered users (+r) may join +

+
+ setRegisteredUsersOnly(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- setIrcOperatorOnly(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* Secure Connection Required */} -
-
- -

- Only clients on secure connections (SSL/TLS) can join -

+ {/* IRC Operator Only */} +
+
+ +

+ Only IRC operators can join (settable by IRCops) +

+
+ setIrcOperatorOnly(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
+ + {/* Secure Connection Required */} +
+
+ +

+ Only clients on secure connections (SSL/TLS) can join +

+
+ + setSecureConnectionRequired(e.target.checked) + } + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + />
- - setSecureConnectionRequired(e.target.checked) - } - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - />
-
- {/* Channel Properties */} -
-

- Channel Properties -

+ {/* Channel Properties */} +
+

+ Channel Properties +

+ + {/* Private Channel */} +
+
+ +

+ Channel is marked as private +

+
+ setPrivateChannel(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
- {/* Private Channel */} -
-
+ {/* Permanent Channel */} +
+
+ +

+ Channel won't be destroyed when empty (settable by + IRCops) +

+
+ setPermanentChannel(e.target.checked)} + className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" + /> +
+ + {/* Channel History */} +
-

- Channel is marked as private +

+ Record channel history with max-lines:max-minutes. Leave + empty to disable.

+ setChannelHistory(e.target.value)} + placeholder="e.g., 100:1440" + className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" + />
- setPrivateChannel(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- {/* Permanent Channel */} -
-
+ {/* Channel Link */} +
-

- Channel won't be destroyed when empty (settable by IRCops) +

+ Forward users to this channel if they can't join. Leave + empty to disable.

+ setChannelLink(e.target.value)} + placeholder="#overflow" + className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" + />
- setPermanentChannel(e.target.checked)} - className="w-4 h-4 text-discord-primary bg-discord-dark-300 border-discord-dark-500 rounded focus:ring-discord-primary" - /> -
- - {/* Channel History */} -
- -

- Record channel history with max-lines:max-minutes. Leave - empty to disable. -

- setChannelHistory(e.target.value)} - placeholder="e.g., 100:1440" - className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" - /> -
- - {/* Channel Link */} -
- -

- Forward users to this channel if they can't join. Leave - empty to disable. -

- setChannelLink(e.target.value)} - placeholder="#overflow" - className="w-full p-2 bg-discord-dark-300 text-white rounded text-sm" - />
-
+ )} )}
@@ -1876,7 +2049,11 @@ const ChannelSettingsModal: React.FC = ({ )} {activeTab === "advanced" && (