feat(bouncer-networks): management UI (PR C of 3)#216
Conversation
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.
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
✨ Finishing Touches🧪 Generate unit tests (beta)
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. Comment |
|
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.
* 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.
Summary
Final PR of the IRCv3
soju.im/bouncer-networksseries — 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 offFAIL BOUNCER'sattributepayload, 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 whenbouncer.supported && !server.bouncerNetid).bouncerNetworksModalServerIdUI slot +toggleBouncerNetworksModal(id)action, modal is mounted globally inApp.tsxdriven 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🤖 Generated with Claude Code