Skip to content

feat(bouncer-networks): management UI (PR C of 3)#216

Merged
ValwareIRC merged 6 commits into
bouncer-networks/bindfrom
bouncer-networks/ui
Jun 5, 2026
Merged

feat(bouncer-networks): management UI (PR C of 3)#216
ValwareIRC merged 6 commits into
bouncer-networks/bindfrom
bouncer-networks/ui

Conversation

@ValwareIRC
Copy link
Copy Markdown
Contributor

Summary

Final PR of the IRCv3 soju.im/bouncer-networks series — the user-facing UI.

Stacked on #215 (BIND lifecycle), which is stacked on #214 (protocol foundation).

  • BouncerNetworksModal — list / add / edit modes. State badges (green for connected, yellow-pulsing for connecting, gray for disconnected). Connected-first sort, then alphabetic. Inline-confirm delete (no separate modal). Success-highlight tick after the BOUNCER ack lands. Empty state with a "Add your first network" CTA.
  • BouncerNetworkForm — TLS-on by default, only-changed-attrs diff for edits (so we don't echo back the full attribute set), field-level errors keyed off FAIL BOUNCER's attribute payload, read-only attrs (state, error) never sent on save.
  • ServerList — adds a "Manage Networks" button (FaLayerGroup) next to Edit/Disconnect on bouncer-control servers (only shown when bouncer.supported && !server.bouncerNetid).
  • Store / AppbouncerNetworksModalServerId UI slot + toggleBouncerNetworksModal(id) action, modal is mounted globally in App.tsx driven by store state.

Test plan

  • BouncerNetworkForm.test.tsx — 14 tests: attrsToValues round-trip, valuesToAttrs diff (incl. TLS-default normalization, read-only filtering), form interaction (host requirement, edit-mode change-detection, error surfacing, delete confirmation flow)
  • BouncerNetworksModal.test.tsx — 7 tests: list rendering with sort + state labels, mode transitions, add submission wiring, edit prefill, close, empty state
  • Full suite: 842 passing
  • Build clean
  • All 18 non-English locales translated (lingui extract → 0 missing across all)
  • Manual: against a real soju bouncer, exercise add / edit / delete with both happy-path and FAIL BOUNCER scenarios

🤖 Generated with Claude Code

Adds the user-facing UI for the soju.im/bouncer-networks extension:

- BouncerNetworksModal: list/add/edit modes with state badges
  (green/yellow-pulsing/gray dots), connected-first sort,
  inline-confirm delete, success highlight on ack, empty state
- BouncerNetworkForm: TLS-on-default toggle, only-changed-attrs
  diff for edits, field-level error surfacing tied to FAIL BOUNCER,
  read-only attribute filtering
- ServerList: "Manage Networks" affordance on bouncer-control
  servers (only shown when supported && not a child binding)
- store: bouncerNetworksModalServerId UI slot + toggle action
- App: globally mounted modal driven by store state

Includes tests for form helpers (attrsToValues/valuesToAttrs
diff logic) and modal interactions, plus translations across
all 18 supported locales.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 14, 2026

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 6dbc590c-cf4b-41f1-a157-4f52c21ede5f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch bouncer-networks/ui

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

Pages Preview
Preview URL: https://bouncer-networks-ui.obsidianirc.pages.dev

Automated deployment preview for the PR in the Cloudflare Pages.

The auth-side CAP ACK splitter re-emits CAP_ACKNOWLEDGED once per cap
with the cap name in `key` (and any =value in `capabilities`). My
handler was treating the event as a bulk "ACK"/"NEW" tag with a
space-separated cap list, so `key !== "ACK"` always returned early
and `supported` never flipped to true — the Manage Networks button
never appeared even when soju acked the cap.

Fix: just check `key` against the two cap names directly. Update
test to fire one event per cap to match the real emission pattern.
Previously you could add a network in the management modal and watch
it sit there as a row, but there was no way to actually use the
connection -- the BIND lifecycle from PR B was wired up but never
dispatched.

Now: on every BOUNCER_NETWORK upsert from a control session, fire
bouncerConnectNetwork. The action is made idempotent (early-return
if a child server already exists for the bouncer+netid pair) so
this is safe to call on every snapshot, every notify update, and
on reconnects.

Result: networks added in the modal show up as their own server
bubbles in the sidebar, ready to /join channels on the upstream.
…tNetwork

The early-return for the already-bound case was returning childId (a
string) but the action's declared signature is Promise<Server | undefined>,
breaking tsc. Return undefined -- no caller inspects the value.
… URL fix

Replaces the floating layer-group icon + modal with a proper chat-area
panel: select the bouncer in the sidebar and the main pane becomes a
list of upstream networks with explicit Connect/Open buttons per row.
Auto-connect-everything is gone -- the user picks which networks to
bind to.

Also fixes the actual binding bug: child connections were inheriting
parent.host (the bare hostname after URL parsing) instead of the saved
WSS URL with its /socket path, so wss://host:6662/socket children were
hitting wss://host:6662/ and getting refused by soju. Use savedParent
to preserve the original URL form.

Drops:
- BouncerNetworksModal + its test (replaced by BouncerNetworksPanel)
- bouncerNetworksModalServerId UI slot + toggleBouncerNetworksModal action
- FaLayerGroup overlay button on ServerIcon

Adds:
- BouncerNetworksPanel rendered when selected server is bouncer-control
  and no channel is selected
- "Connect" button per row -> dispatches bouncerConnectNetwork +
  switches the sidebar selection to the new child server
- "Open" button when the child is already present (just selects it)
connectToSavedServers used to fire parent + child reconnects in parallel
at app startup.  Both opened WSS to the same soju endpoint and raced
their SASL/BIND handshakes against each other, surfacing as 'invalid
password' against the child sessions even though the credentials were
identical and the manual click path worked fine.

Split the reconnect into two phases: parents (and standalone servers)
go first, each child Server row is seeded into state immediately so the
UI doesn't blink, then a one-shot `ready` listener dispatches every
child of a parent as soon as that parent finishes registration.  If a
parent fails outright the listener detaches and its children are marked
disconnected so the UI stops spinning.

Tests pin: seeded child row, deferred dispatch, ready-fire dispatch,
and that unrelated ready events are ignored.
@ValwareIRC ValwareIRC merged commit ad77351 into bouncer-networks/bind Jun 5, 2026
8 checks passed
ValwareIRC added a commit that referenced this pull request Jun 5, 2026
* feat(bouncer-networks): BIND lifecycle + child connections (PR B of 3)

Stacks on PR #214 (protocol + store). Wires the connection lifecycle
for soju.im/bouncer-networks: one bouncer endpoint can now host N
child connections, each bound to a different upstream network via
BOUNCER BIND before CAP END. Still headless -- UI lands in PR C.

What's in:

- IRCClient.sendCapEnd(serverId): centralised CAP END sender that
  emits a queued 'BOUNCER BIND <netid>' first when the serverId was
  flagged as a bouncer child via setPendingBouncerBind(). All six
  CAP END call sites (timeout, no-caps-to-request, post-CAP-ACK,
  CAP NAK, SASL success/fail, LinkSecurityWarning resume, post-
  account-registration) now flow through this one helper.
- IRCClient.pendingConnections dedup keyed by serverId when one is
  given, falling back to host:port. Without this, multiple child
  connections sharing the bouncer endpoint would collapse onto a
  single Promise<Server>.
- Server / ServerConfig: bouncerServerId, bouncerNetid,
  isBouncerControl fields plumbed through the type, persistence,
  and the initial Server seed in connectToSavedServers.
- store.bouncerConnectNetwork(parentServerId, netid): the public
  API. Resolves the parent's credentials, derives a deterministic
  childId via uuidv5(parent:netid, CHANNEL_NAMESPACE), saves the
  child ServerConfig, seeds the child Server row in 'connecting'
  state, calls setPendingBouncerBind, and dispatches the IRCClient
  connect with the explicit childId.
- connectToSavedServers: detects bouncer children at restore time
  and bypasses the store-level connect() wrapper (which would
  resolve them onto the parent's id via host:port lookup), going
  direct to ircClient.connect() with the explicit child id and a
  setPendingBouncerBind call beforehand.

What's NOT in (deliberate; PR C):

- UI for managing bouncer networks (list / add / change / delete).
- Server-list rendering that groups child connections under their
  parent.
- Modal for picking which networks to connect to.

Coverage:

- tests/protocol/bouncerBind.test.ts -- sendCapEnd emits CAP END
  alone when no BIND queued, BOUNCER BIND <netid> immediately
  before CAP END when queued, BIND is consumed (single-use), BINDs
  are scoped per serverId.
- tests/store/bouncerConnect.test.ts -- queues BIND on a fresh
  childId before calling connect, seeds the child Server with
  parent linkage + 'connecting' state + friendly name from the
  BOUNCER NETWORK attributes, persists the child ServerConfig with
  the parent's SASL credentials, repeated calls are idempotent for
  persistence, returns undefined when the parent has no saved
  config.

Full suite green (821 / 1 skipped), build clean.

* feat(bouncer-networks): management UI (PR C of 3) (#216)

* feat(bouncer-networks): management UI (PR C of 3)

Adds the user-facing UI for the soju.im/bouncer-networks extension:

- BouncerNetworksModal: list/add/edit modes with state badges
  (green/yellow-pulsing/gray dots), connected-first sort,
  inline-confirm delete, success highlight on ack, empty state
- BouncerNetworkForm: TLS-on-default toggle, only-changed-attrs
  diff for edits, field-level error surfacing tied to FAIL BOUNCER,
  read-only attribute filtering
- ServerList: "Manage Networks" affordance on bouncer-control
  servers (only shown when supported && not a child binding)
- store: bouncerNetworksModalServerId UI slot + toggle action
- App: globally mounted modal driven by store state

Includes tests for form helpers (attrsToValues/valuesToAttrs
diff logic) and modal interactions, plus translations across
all 18 supported locales.

* fix(bouncer-networks): match CAP_ACKNOWLEDGED per-cap event shape

The auth-side CAP ACK splitter re-emits CAP_ACKNOWLEDGED once per cap
with the cap name in `key` (and any =value in `capabilities`). My
handler was treating the event as a bulk "ACK"/"NEW" tag with a
space-separated cap list, so `key !== "ACK"` always returned early
and `supported` never flipped to true — the Manage Networks button
never appeared even when soju acked the cap.

Fix: just check `key` against the two cap names directly. Update
test to fire one event per cap to match the real emission pattern.

* feat(bouncer-networks): auto-bind each upstream as a child server

Previously you could add a network in the management modal and watch
it sit there as a row, but there was no way to actually use the
connection -- the BIND lifecycle from PR B was wired up but never
dispatched.

Now: on every BOUNCER_NETWORK upsert from a control session, fire
bouncerConnectNetwork. The action is made idempotent (early-return
if a child server already exists for the bouncer+netid pair) so
this is safe to call on every snapshot, every notify update, and
on reconnects.

Result: networks added in the modal show up as their own server
bubbles in the sidebar, ready to /join channels on the upstream.

* fix(bouncer-networks): return undefined from idempotent bouncerConnectNetwork

The early-return for the already-bound case was returning childId (a
string) but the action's declared signature is Promise<Server | undefined>,
breaking tsc. Return undefined -- no caller inspects the value.

* feat(bouncer-networks): inline network browser in chat area + connect URL fix

Replaces the floating layer-group icon + modal with a proper chat-area
panel: select the bouncer in the sidebar and the main pane becomes a
list of upstream networks with explicit Connect/Open buttons per row.
Auto-connect-everything is gone -- the user picks which networks to
bind to.

Also fixes the actual binding bug: child connections were inheriting
parent.host (the bare hostname after URL parsing) instead of the saved
WSS URL with its /socket path, so wss://host:6662/socket children were
hitting wss://host:6662/ and getting refused by soju. Use savedParent
to preserve the original URL form.

Drops:
- BouncerNetworksModal + its test (replaced by BouncerNetworksPanel)
- bouncerNetworksModalServerId UI slot + toggleBouncerNetworksModal action
- FaLayerGroup overlay button on ServerIcon

Adds:
- BouncerNetworksPanel rendered when selected server is bouncer-control
  and no channel is selected
- "Connect" button per row -> dispatches bouncerConnectNetwork +
  switches the sidebar selection to the new child server
- "Open" button when the child is already present (just selects it)

* fix(bouncer-networks): gate child reconnects on parent ready event

connectToSavedServers used to fire parent + child reconnects in parallel
at app startup.  Both opened WSS to the same soju endpoint and raced
their SASL/BIND handshakes against each other, surfacing as 'invalid
password' against the child sessions even though the credentials were
identical and the manual click path worked fine.

Split the reconnect into two phases: parents (and standalone servers)
go first, each child Server row is seeded into state immediately so the
UI doesn't blink, then a one-shot `ready` listener dispatches every
child of a parent as soon as that parent finishes registration.  If a
parent fails outright the listener detaches and its children are marked
disconnected so the UI stops spinning.

Tests pin: seeded child row, deferred dispatch, ready-fire dispatch,
and that unrelated ready events are ignored.
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.

2 participants