Skip to content

feat: IRCv3 draft/named-modes — PROP command, RPL_CHMODELIST, outbound helper#194

Merged
ValwareIRC merged 14 commits into
mainfrom
feat/named-modes
May 11, 2026
Merged

feat: IRCv3 draft/named-modes — PROP command, RPL_CHMODELIST, outbound helper#194
ValwareIRC merged 14 commits into
mainfrom
feat/named-modes

Conversation

@ValwareIRC
Copy link
Copy Markdown
Contributor

@ValwareIRC ValwareIRC commented May 7, 2026

Implements the IRCv3 draft/named-modes spec on the client end-to-end.

Server-side support for this lives in ObbyIRCd PR #8; both can ship together.

What lands

Phase 1 — protocol scaffolding (bd6c9c2..1aaf884)

  • Adds draft/named-modes to ourCaps so we negotiate it on connect.
  • New types NamedModeSpec / NamedModes on Server.
  • src/lib/irc/handlers/named-modes.ts parses six new numerics:
    • RPL_CHMODELIST (964) / RPL_UMODELIST (965) — long-form mode registry
    • RPL_PROPLIST (961) / RPL_ENDOFPROPLIST (960) — channel mode-state listing
    • RPL_LISTPROPLIST (963) / RPL_ENDOFLISTPROPLIST (962) — list-mode entries
  • Plus the PROP command itself.
  • Store-layer subscriber merges 964/965 entries into server.namedModes.

Phase 2 — inbound translation (1aaf884..6b9e9df)

  • Inbound PROP resolves long-form names to legacy MODE letters via the registry and re-fires a synthetic MODE event so every existing chanmode/usermode handler (channel state, prefix updates, etc.) keeps working unchanged.
  • PROP <chan> listing replies are buffered per (server, channel) and flushed into the same RPL_CHANNELMODEIS path the legacy MODE listing uses.
  • Name-only modes (no letter) surface only via the rich NAMED_MODES_PROP event — they have no MODE-letter equivalent to translate to.
  • Parser cleanup: handles both inline (964 me 5:op=o 5:voice=v) and trailing (964 me :5:op=o 5:voice=v) wire shapes uniformly.

Phase 3 — outbound helper (6b9e9df..90ab7b8)

  • IRCClient.sendNamedMode(serverId, target, items, registry?) chooses between PROP and MODE based on cap state + whether any item is name-only.
  • Decision matrix:
    • All items have letters → MODE (works anywhere)
    • Cap on + any item is name-only → PROP (only wire form that can carry these)
    • Cap off + name-only item → drop unreachable item, MODE fallback for the rest
  • Registry passed in by caller — keeps the IRC layer free of a store dependency.

Tests

13 new vitest cases in tests/protocol/named-modes.test.ts:

  • single-line and multi-line burst parsing
  • malformed and unknown-type entries dropped
  • name-only entries preserved
  • PROP item parsing including default-+ branch and space-packed trailing items
  • the full outbound decision matrix

Pipeline locally: 720 / 721 pass (was 707), build clean.

What's not in this PR

  • Migration of in-tree MODE-via-sendRaw call sites (ban list ops, channel-settings modal mode toggles) to sendNamedMode. Those keep working unchanged — they just won't get name-only mode reach until they switch over. Done as follow-ups so this PR stays focused on the spec implementation.
  • User-mode self-MODE → PROP echo for cap-holders. The server side does this (in ObbyIRCd PR Add actual unit tests #8), so the client receives it via the existing inbound translation; we just don't preempt-render PROP for our own self-MODE.

Test plan

  • CI green
  • Manual: connect to a draft/named-modes-aware server (obby.t3ks.com), confirm CAP REQ goes out and :server 964 me ... shows in raw log
  • Manual: another user does /mode #chan +o me from a non-cap client; we receive :them PROP #chan +op=us and the chat header / member list update
  • Manual: confirm legacy /mode from this client still works on the wire

Summary by CodeRabbit

  • New Features

    • Support for IRCv3 named-modes (long-form channel/user modes) with sending/translation between named and legacy letter modes.
    • UI: Advanced channel settings now edit and apply named channel modes when supported.
    • Adds a curated named-mode registry and a human-friendly display helper.
  • Tests

    • Comprehensive tests for named-modes parsing, buffering, and outbound transmission.
  • Chores

    • Server detection updated to treat an additional server family as compatible with existing chanmode UI.

ValwareIRC added 3 commits May 7, 2026 14:13
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.
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).
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.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Warning

Rate limit exceeded

@ValwareIRC has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 30 minutes and 6 seconds before requesting another review.

You’ve run out of usage credits. Purchase more in the billing tab.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6715deef-cdc3-4d68-afc8-a7bd2b3f4176

📥 Commits

Reviewing files that changed from the base of the PR and between c535c65 and 95a6f6b.

📒 Files selected for processing (5)
  • src/components/ui/ChannelSettingsModal.tsx
  • src/lib/irc/IRCClient.ts
  • src/lib/irc/handlers/index.ts
  • src/store/handlers/index.ts
  • src/types/index.ts
📝 Walkthrough

Walkthrough

Adds draft/named-modes support: types and registry, client API (sendNamedMode + events), protocol handlers for PROP and numerics 960–965, store wiring to accumulate registries and bridge to legacy MODE, UI for channel advanced named-mode editing, utilities, and tests.

Changes

Named-Modes Feature Implementation

Layer / File(s) Summary
Type Definitions
src/types/index.ts
New NamedModeSpec and NamedModes interfaces; Server gains optional namedModes field.
Client Events & API
src/lib/irc/IRCClient.ts
EventMap extended with seven named-modes event types; sendNamedMode() public method added; draft/named-modes capability negotiated.
Protocol Parsing
src/lib/irc/handlers/named-modes.ts
Parsing helpers (parseEntries, parseListAdvertisement) and handlers for PROP and numerics 960–965; emits structured events (NAMED_MODES_*).
Handler Dispatch
src/lib/irc/handlers/index.ts
Named-modes handlers imported and registered; IRC_DISPATCH table extended with PROP and numerics 960–965.
Store Integration
src/store/handlers/named-modes.ts, src/store/handlers/index.ts
Per-server namedModes registries accumulated; PROP bridged to legacy MODE; PROPLIST buffered and synthesized as RPL_CHANNELMODEIS.
Registry & Utilities
src/lib/namedModeRegistry.ts, src/lib/ircUtils.tsx
Curated NAMED_MODE_META and lookup helpers; humanizeNamedMode() for display labels and vendor extraction.
UI: Channel Settings
src/components/ui/ChannelSettingsModal.tsx
Advanced tab gains named-modes editor with staged pendingNamedModes, rendering/grouping from server registry, and applyNamedModesAdvancedChanges calling ircClient.sendNamedMode; falls back to legacy UI when unsupported.
Channels Gate Tweak
src/store/handlers/channels.ts
UnrealIRCd detection extended to treat ObbyIRCd as compatible for chanmode UI gating.
Tests & Validation
tests/protocol/named-modes.test.ts
Inbound handler tests (continuation, flattening, filtering); outbound tests (MODE vs PROP, capability fallback, user-target selection, sign grouping).

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • ObsidianIRC/ObsidianIRC#94: Touches ChannelSettingsModal.tsx mode-handling UI and CHANMODES apply logic; likely related UI/behavior overlap.

Suggested reviewers

  • matheusfillipe

Poem

🐰 Named modes hop into view,
Long names for modes, shiny and new!
CHMODELIST, PROP, and MODE in line,
The client learns to read and sign.
Hooray — a rabbit's patchwork, hop and chew!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 38.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately captures the main feature being introduced: IRCv3 draft/named-modes support with specific focus on the PROP command, RPL_CHMODELIST, and the outbound helper method.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/named-modes

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@github-actions
Copy link
Copy Markdown

github-actions Bot commented May 7, 2026

Pages Preview
Preview URL: https://feat-named-modes.obsidianirc.pages.dev

Automated deployment preview for the PR in the Cloudflare Pages.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/protocol/named-modes.test.ts (1)

22-145: ⚡ Quick win

Add a regression test for registry replacement across bursts.

Current tests validate parsing and command selection well, but they don’t cover “old entry removed in later CHMODELIST/UMODELIST burst” behavior; adding this would prevent stale-mapping regressions.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/protocol/named-modes.test.ts` around lines 22 - 145, Add a regression
test that verifies registry replacement across multi-line bursts: use makeCtx()
and call handleRplChmodelist (and/or handleRplUmodelist) in a sequence that
simulates an initial burst containing an entry, then a subsequent burst that
omits that entry (with continuation marker handling), and finally the
terminating burst; after the final burst assert that the events/registry no
longer contains the removed entry and that the entries reflect the latest burst
only. Locate existing tests using makeCtx, handleRplChmodelist,
handleRplUmodelist and events to mirror their structure and assertions (check
ev.entries or mapped names) to confirm the old mapping was replaced rather than
retained.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/lib/irc/handlers/named-modes.ts`:
- Around line 209-223: Validate that parv[0] is present and a string before
using it as target and before any startsWith slicing/processing: in the block
around getNickFromNuh(source) and variable target, check typeof parv[0] ===
"string" (or truthiness) and if not, bail out (return) or skip emitting the PROP
event and optionally log a warning; apply the same guard to the similar code
block referenced at lines 235-242 so neither the mode parsing (tail assembly)
nor downstream store code will encounter undefined and call startsWith on it.

In `@src/store/handlers/named-modes.ts`:
- Around line 63-85: mergeEntries currently only upserts by name and never
removes stale entries; update mergeEntries to accept/handle a "first-of-burst"
signal (add a boolean parameter like isFirst or use an existing burst indicator)
and when that signal is true, clear or filter the previous registry before
merging: compute a Set of incoming names, start merged from an empty array (or
filter prev to only names present in incoming) and then upsert incoming entries
as before so entries missing from the new advertisement are removed; apply the
same change to the analogous routine referenced around lines 92-101 (the other
named-mode merge/registry function) so re-advertise reconnects won't retain
obsolete mappings.

---

Nitpick comments:
In `@tests/protocol/named-modes.test.ts`:
- Around line 22-145: Add a regression test that verifies registry replacement
across multi-line bursts: use makeCtx() and call handleRplChmodelist (and/or
handleRplUmodelist) in a sequence that simulates an initial burst containing an
entry, then a subsequent burst that omits that entry (with continuation marker
handling), and finally the terminating burst; after the final burst assert that
the events/registry no longer contains the removed entry and that the entries
reflect the latest burst only. Locate existing tests using makeCtx,
handleRplChmodelist, handleRplUmodelist and events to mirror their structure and
assertions (check ev.entries or mapped names) to confirm the old mapping was
replaced rather than retained.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: feaf07f1-e9a8-48a4-ba47-6d27f1544ef3

📥 Commits

Reviewing files that changed from the base of the PR and between 58799eb and 90ab7b8.

📒 Files selected for processing (7)
  • src/lib/irc/IRCClient.ts
  • src/lib/irc/handlers/index.ts
  • src/lib/irc/handlers/named-modes.ts
  • src/store/handlers/index.ts
  • src/store/handlers/named-modes.ts
  • src/types/index.ts
  • tests/protocol/named-modes.test.ts

Comment on lines +209 to +223
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);
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

Guard malformed PROP payloads before emitting events.

On Line 210, target is read from parv[0] without validation. If missing, downstream store code calls startsWith on undefined and can crash event processing.

Suggested fix
 export function handleProp(
   ctx: IRCClientContext,
   serverId: string,
   source: string,
   parv: string[],
   mtags: Record<string, string> | undefined,
 ): void {
+  if (!parv[0]) return;
   const sender = getNickFromNuh(source);
   const target = parv[0];

Also applies to: 235-242

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/lib/irc/handlers/named-modes.ts` around lines 209 - 223, Validate that
parv[0] is present and a string before using it as target and before any
startsWith slicing/processing: in the block around getNickFromNuh(source) and
variable target, check typeof parv[0] === "string" (or truthiness) and if not,
bail out (return) or skip emitting the PROP event and optionally log a warning;
apply the same guard to the similar code block referenced at lines 235-242 so
neither the mode parsing (tail assembly) nor downstream store code will
encounter undefined and call startsWith on it.

Comment on lines +63 to +85
/** 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;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Stale named-mode entries are never removed from registry.

On Line 75, merge logic only upserts by name and never clears entries missing from a new advertisement burst, so reconnect/re-advertise can keep obsolete mappings and generate incorrect MODE translations later.

Proposed direction
- function mergeEntries(prev, incoming, isFinal): NamedModeSpec[] { ...upsert... }
+ // Keep a per-server in-progress buffer and replace the registry on final burst.
+ // Pseudocode:
+ // - on first chunk: start empty buffer
+ // - on each chunk: append/upsert into buffer
+ // - on isFinal: assign `namedModes.channelModes = buffer` (or userModes), then clear buffer

Also applies to: 92-101

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/store/handlers/named-modes.ts` around lines 63 - 85, mergeEntries
currently only upserts by name and never removes stale entries; update
mergeEntries to accept/handle a "first-of-burst" signal (add a boolean parameter
like isFirst or use an existing burst indicator) and when that signal is true,
clear or filter the previous registry before merging: compute a Set of incoming
names, start merged from an empty array (or filter prev to only names present in
incoming) and then upsert incoming entries as before so entries missing from the
new advertisement are removed; apply the same change to the analogous routine
referenced around lines 92-101 (the other named-mode merge/registry function) so
re-advertise reconnects won't retain obsolete mappings.

… 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.
ValwareIRC added 5 commits May 7, 2026 16:48
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.
The explainer text inside the Advanced tab is noise -- the user is
already in a Channel Settings modal under an Advanced tab labelled
'Advanced', the visible content speaks for itself.
The vendor string is already lowercased in humanizeNamedMode but
the badge had a Tailwind 'uppercase' class on it, undoing that. Drop
the class and add an explicit 'lowercase' so any future caller who
passes a mixed-case vendor in stays uniform.
The whole point of named-modes is to surface human-readable mode
names. Carrying the legacy +letter alongside undermines that and
adds visual noise without giving the user anything actionable.
Replace the auto-derived humanizeNamedMode labels in the Advanced tab
with a curated registry (NAMED_MODE_META) that maps each known mode to
a proper label, description, parameter placeholder, and section group.

Auto-derivation produced rows like 'Floodprot' / 'Regonlyspeak' that
weren't useful UX. The registry covers the obsidianirc/* modes plus the
spec-table modes worth surfacing, hides modes already covered by other
tabs (op/voice/ban/topiclock/...) and server-managed flags
(issecure/isregistered/delayjoin-rejoinhide), and renders sectioned
groups (Flood / Filtering / Behavior / Access / Properties).

Unknown modes fall back to humanizeNamedMode for the label so newly
added vendor modes still render.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/components/ui/ChannelSettingsModal.tsx (1)

234-240: ⚠️ Potential issue | 🟠 Major | ⚡ Quick win

pendingNamedModes is not cleared when the channel changes.

The reset only fires on !isOpen (line 238). The effect at 1015 already supports channelName changing while the modal stays open (it resets modes and re-fetches). Staged named-mode edits from the previous channel persist across that switch and would be applied to the new channel via ircClient.sendNamedMode(serverId, channelName, items, ...) at line 1002 — i.e. edits intended for #a get sent to #b.

♻️ Suggested fix — also reset on channel change
   // biome-ignore lint/correctness/useExhaustiveDependencies: Using channelName instead of channel to avoid infinite loop from object reference changes
   useEffect(() => {
     if (isOpen && channel) {
       // Clear current modes and fetch new ones when channel changes
       setModes([]);
+      setPendingNamedModes({});
       hasFetchedRef.current = false;
       fetchChannelModes();
     }
   }, [isOpen, channelName, fetchChannelModes]);

Also applies to: 1014-1022

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/ChannelSettingsModal.tsx` around lines 234 - 240, The
useEffect that currently resets hasFetchedRef, clears timeouts and calls
setPendingNamedModes({}) only when !isOpen fails to clear staged edits when the
modal stays open but channelName changes; update that effect (the one
referencing hasFetchedRef.current, clearPendingTimeouts, setPendingNamedModes)
to also run/reset when channelName changes (add channelName to the dependency
list) so pendingNamedModes is cleared on channel switches, and make the same
change to the similar effect around the modes re-fetch (the block interacting
with ircClient.sendNamedMode) so staged named-mode edits cannot be sent to the
wrong channel.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/components/ui/ChannelSettingsModal.tsx`:
- Around line 1050-1060: liveStateForNamedMode is leaking the "__HIDDEN__"
sentinel into the UI by returning stored verbatim; change it to treat the
sentinel as "not exposable" and return param: null (or "" per callers) when
stored === "__HIDDEN__" so the input and equality checks (which compare
stagedParam to live.param ?? "") behave correctly; update references that use
liveStateForNamedMode (originalModesRef, stagedParam handling, and the equality
check around mode staging) to expect null/empty for hidden values rather than
the literal "__HIDDEN__".
- Around line 1170-1190: The empty-input handling should branch on live.set
instead of comparing to (live.param ?? ""): in the input onChange for
stagedParam, change the logic so that if v === "" you call
stageNamedMode(spec.name, { sign: "-" }) when live.set is true (to stage an
unset) and stageNamedMode(spec.name, null) when live.set is false (to clear
staging); otherwise, if v is non-empty use stageNamedMode(spec.name, { sign:
"+", param: v }), and if v matches the current live.param exactly clear staging
with stageNamedMode(spec.name, null). Update the onChange in the component
(referencing stagedParam, live, spec.name, and stageNamedMode) accordingly.

---

Outside diff comments:
In `@src/components/ui/ChannelSettingsModal.tsx`:
- Around line 234-240: The useEffect that currently resets hasFetchedRef, clears
timeouts and calls setPendingNamedModes({}) only when !isOpen fails to clear
staged edits when the modal stays open but channelName changes; update that
effect (the one referencing hasFetchedRef.current, clearPendingTimeouts,
setPendingNamedModes) to also run/reset when channelName changes (add
channelName to the dependency list) so pendingNamedModes is cleared on channel
switches, and make the same change to the similar effect around the modes
re-fetch (the block interacting with ircClient.sendNamedMode) so staged
named-mode edits cannot be sent to the wrong channel.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 974d274d-4184-4b17-a041-315e0b845349

📥 Commits

Reviewing files that changed from the base of the PR and between 90ab7b8 and c535c65.

📒 Files selected for processing (5)
  • src/components/ui/ChannelSettingsModal.tsx
  • src/lib/ircUtils.tsx
  • src/lib/namedModeRegistry.ts
  • src/store/handlers/channels.ts
  • src/store/handlers/named-modes.ts
✅ Files skipped from review due to trivial changes (3)
  • src/lib/ircUtils.tsx
  • src/store/handlers/channels.ts
  • src/lib/namedModeRegistry.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • src/store/handlers/named-modes.ts

Comment on lines +1050 to +1060
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 };
};
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

__HIDDEN__ sentinel leaks into the named-modes input and breaks unset behavior.

liveStateForNamedMode returns stored verbatim, so for hidden-value modes (e.g. key/H/L) the literal string "__HIDDEN__" flows through stagedParam (line 1110) into <input value={stagedParam}> and is rendered to the user. The comment at lines 1056–1058 explicitly says hidden values are "without an exposable value", but the implementation exposes them. The legacy path already handles this correctly (lines 395–417: parsedModes.k !== "__HIDDEN__" ? parsedModes.k || "" : "").

A second consequence is in the equality check at line 1175: when the live param is "__HIDDEN__", clearing the input produces v === (live.param ?? "")"" === "__HIDDEN__"false, and the code stages { sign: "-" }, silently unsetting a hidden mode the user never intended to remove.

🛡️ Suggested fix — treat the sentinel as not exposable
   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 };
+    // null = present, no param. string = present with param.
+    // "__HIDDEN__" (used for +k/+L masking) is set but not exposable —
+    // surface it as an opaque "set" so we don't render the sentinel
+    // and don't accidentally unset it on empty input.
+    if (stored === "__HIDDEN__") return { set: true, param: null };
+    return { set: true, param: stored };
   };

Also applies to: 1108-1110, 1170-1190

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/ChannelSettingsModal.tsx` around lines 1050 - 1060,
liveStateForNamedMode is leaking the "__HIDDEN__" sentinel into the UI by
returning stored verbatim; change it to treat the sentinel as "not exposable"
and return param: null (or "" per callers) when stored === "__HIDDEN__" so the
input and equality checks (which compare stagedParam to live.param ?? "") behave
correctly; update references that use liveStateForNamedMode (originalModesRef,
stagedParam handling, and the equality check around mode staging) to expect
null/empty for hidden values rather than the literal "__HIDDEN__".

Comment on lines +1170 to +1190
<input
type="text"
value={stagedParam}
onChange={(e) => {
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)"
: (placeholder ?? "(not set)")
}
className="flex-1 p-2 bg-discord-dark-400 text-white rounded text-sm"
/>
</div>
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Empty-input edge case can leave a parameterized mode set when the user wants it unset.

At line 1175, when live.set === true and live.param === null (a parameterized mode currently set without an exposed param), an empty input yields v === (null ?? "")true, so staging is cleared instead of staging { sign: "-" }. The user clears the field expecting an unset, but the apply path emits no diff for this mode.

♻️ Suggested fix — branch on live.set rather than live.param equality
               onChange={(e) => {
                 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 });
-                }
+                if (!v) {
+                  // Empty: revert to live state if not set, else stage unset.
+                  stageNamedMode(spec.name, live.set ? { sign: "-" } : null);
+                } else if (live.set && v === (live.param ?? "")) {
+                  stageNamedMode(spec.name, null);
+                } else {
+                  stageNamedMode(spec.name, { sign: "+", param: v });
+                }
               }}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<input
type="text"
value={stagedParam}
onChange={(e) => {
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)"
: (placeholder ?? "(not set)")
}
className="flex-1 p-2 bg-discord-dark-400 text-white rounded text-sm"
/>
</div>
<input
type="text"
value={stagedParam}
onChange={(e) => {
const v = e.target.value;
if (!v) {
// Empty: revert to live state if not set, else stage unset.
stageNamedMode(spec.name, live.set ? { sign: "-" } : null);
} else if (live.set && v === (live.param ?? "")) {
stageNamedMode(spec.name, null);
} else {
stageNamedMode(spec.name, { sign: "+", param: v });
}
}}
placeholder={
live.set
? "(set, edit to change)"
: (placeholder ?? "(not set)")
}
className="flex-1 p-2 bg-discord-dark-400 text-white rounded text-sm"
/>
</div>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/components/ui/ChannelSettingsModal.tsx` around lines 1170 - 1190, The
empty-input handling should branch on live.set instead of comparing to
(live.param ?? ""): in the input onChange for stagedParam, change the logic so
that if v === "" you call stageNamedMode(spec.name, { sign: "-" }) when live.set
is true (to stage an unset) and stageNamedMode(spec.name, null) when live.set is
false (to clear staging); otherwise, if v is non-empty use
stageNamedMode(spec.name, { sign: "+", param: v }), and if v matches the current
live.param exactly clear staging with stageNamedMode(spec.name, null). Update
the onChange in the component (referencing stagedParam, live, spec.name, and
stageNamedMode) accordingly.

# Conflicts:
#	src/lib/irc/IRCClient.ts
#	src/types/index.ts
# Conflicts:
#	src/lib/irc/IRCClient.ts
#	src/store/handlers/index.ts
#	src/types/index.ts
# Conflicts:
#	src/lib/irc/IRCClient.ts
#	src/types/index.ts
@ValwareIRC ValwareIRC merged commit 1e06fa5 into main May 11, 2026
4 checks passed
@ValwareIRC ValwareIRC deleted the feat/named-modes branch May 11, 2026 20:44
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant