Files
voice-cat/clients/windows/VoiceCat.App/Forms/MainForm.cs

685 lines
25 KiB
C#
Raw Normal View History

feat(M4): Windows WinForms client, TOFU identity pinning, VAD threshold + always-on mode Core ABI extensions (voicecat.h): - vc_list_channels / vc_list_users / vc_list_user_streams — pull-based snapshot getters for the channel-tree and user-list UI; session_model_mu_ guards cross-thread reads - VC_EVENT_JOIN_RESULT / vc_join_channel — channel join with optional password - VC_EVENT_SERVER_IDENTITY + vc_confirm_server_identity — TOFU gate that blocks io_thread_ until the UI approves or rejects; pins TLS leaf-cert SHA-256 (not declared Ed25519) - vc_get_server_identity_display — Ed25519 fingerprint for human-readable display only - VC_INPUT_ALWAYS_ON = 2 in vc_input_mode — transmit unconditionally, no VAD gate - vc_set_vad_threshold — live RMS threshold update (0.0–1.0); EnergyVadProcessor stores it atomically so the audio RT path reads without a lock C++ implementation: - SessionModel::apply_snapshot / apply_channel_event fixed to populate parent_id, password_protected, and max_users (were permanently zeroed) - TlsContext::peer_cert_fingerprint — SHA-256 of peer leaf cert DER via mbedTLS - TofuStore split into peek (read-only) + pin (write) so first-connect only persists after user approval; tofu_store_path in vc_config for per-user pin file location - TcpAcceptor uses dual-stack IPv6+IPv4 fallback (fixes localhost → ::1 on Windows) - windows-client CMake preset: Release shared DLL, static MinGW runtime, no tools/tests - New C++ tests: test_channel_user_list_abi, test_tofu_flow (14/14 green) Windows client (clients/windows/ — .NET 10 WinForms): - VoiceCat.Interop: LibraryImport P/Invoke surface, UnmanagedCallersOnly callbacks, Channel<VoiceCatEvent> event delivery drained by 30ms WinForms Timer - VoiceCat.App: ConnectDialog (saved servers, DPAPI password storage), ServerIdentity- Dialog (TOFU first-connect / mismatch warning), MainForm (channel TreeView, user ListBox, RichTextBox chat, voice controls, device pickers, VAD/PTT/always-on mode, per-user gain/mute/NR tuning, VAD sensitivity TrackBar, level meter ProgressBar) - PttKeyCaptureDialog — focus-scoped PTT key capture (documented limitation) - PerUserTuningDialog — real-time gain/mute/NR applied to all of a user's streams - Accessibility: explicit AccessibleName/Description on every control, & mnemonics, Activity log ListBox as durable screen-reader record, AutomationNotification for curated live announcements Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 00:35:16 +02:00
using VoiceCat.Interop;
namespace VoiceCat.App.Forms;
/// <summary>
/// Post-auth main window. Owns the VoiceCatClient for its entire lifetime.
/// Phase E: channel tree, user list, chat. Phase F: voice controls, device pickers, PTT,
/// per-user tuning.
/// </summary>
public partial class MainForm : Form
{
private readonly VoiceCatClient _client;
private readonly uint _selfUserId;
private readonly string _nickname;
private readonly System.Windows.Forms.Timer _pumpTimer = new() { Interval = 30 };
// Channel / user state
private uint _currentChannelId;
private List<ChannelInfo> _channels = [];
private readonly Dictionary<uint, UserInfo> _users = [];
private readonly HashSet<uint> _talkingUsers = [];
// Voice state
private uint _micStreamId; // 0 = not started
private Keys _pttKey = Keys.F8;
public MainForm(VoiceCatClient client, uint selfUserId, string nickname)
{
InitializeComponent();
_client = client;
_selfUserId = selfUserId;
_nickname = nickname;
Text = $"VoiceCat — {nickname}";
_client.EventReceived += OnEvent;
_client.LevelChanged += OnLevelChanged;
_pumpTimer.Tick += (_, _) => _client.PumpEvents();
_pumpTimer.Start();
// Channel tree
tvChannels.DoubleClick += TvChannels_DoubleClick;
tvChannels.KeyDown += TvChannels_KeyDown;
// User list — double-click or Enter for per-user tuning, right-click for context menu
var ctxUsers = new ContextMenuStrip();
var miTune = new ToolStripMenuItem("&Adjust volume and noise settings...");
miTune.Click += (_, _) => OpenUserTuning();
ctxUsers.Opening += (_, _) => miTune.Enabled = lstUsers.SelectedItem is UserListItem;
ctxUsers.Items.Add(miTune);
lstUsers.ContextMenuStrip = ctxUsers;
lstUsers.DoubleClick += (_, _) => OpenUserTuning();
lstUsers.KeyDown += (_, e) => { if (e.KeyCode == Keys.Enter) OpenUserTuning(); };
// Compose
txtCompose.KeyDown += TxtCompose_KeyDown;
btnSend.Click += (_, _) => SendText();
// Voice controls
btnMicToggle.Click += BtnMicToggle_Click;
chkMute.CheckedChanged += (_, _) => ApplySelfMute();
chkDeafen.CheckedChanged += (_, _) => ApplySelfMute();
radioVad.CheckedChanged += RadioVad_CheckedChanged;
radioPtt.CheckedChanged += RadioPtt_CheckedChanged;
radioAlwaysOn.CheckedChanged += RadioAlwaysOn_CheckedChanged;
trkVadThreshold.Scroll += TrkVadThreshold_Scroll;
btnChangePtt.Click += BtnChangePtt_Click;
btnRefreshDevices.Click += (_, _) => LoadInputDevices();
cboInputDevice.SelectedIndexChanged += CboInputDevice_SelectedIndexChanged;
// PTT (focus-scoped — works only while this form has focus; documented limitation)
KeyDown += MainForm_KeyDown;
KeyUp += MainForm_KeyUp;
Deactivate += (_, _) =>
{
if (_micStreamId != 0) _client.SetPushToTalk(false); // release PTT on focus loss
};
BootstrapFromServer();
}
// ── Startup ──────────────────────────────────────────────────────────────
private void BootstrapFromServer()
{
_channels = _client.ListChannels();
var users = _client.ListUsers();
_users.Clear();
foreach (var u in users)
{
_users[u.Id] = u;
if (u.Id == _selfUserId) _currentChannelId = u.ChannelId;
}
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
UpdateStatusLabel();
AddActivity($"Connected to server as {_nickname}");
}
protected override void OnLoad(EventArgs e)
{
base.OnLoad(e);
splitMain.SplitterDistance = Math.Min(220, splitMain.Width - 304);
splitLeft.SplitterDistance = Math.Min(260, splitLeft.Height - 84);
LoadInputDevices();
}
// ── Event dispatch ────────────────────────────────────────────────────────
private void OnEvent(VoiceCatEvent ev)
{
switch (ev.Type)
{
case VcEventType.ChannelList:
HandleChannelList();
break;
case VcEventType.UserJoined:
HandleUserJoined(ev);
break;
case VcEventType.UserLeft:
HandleUserLeft(ev);
break;
case VcEventType.UserUpdated:
HandleUserUpdated();
break;
case VcEventType.JoinResult:
HandleJoinResult(ev);
break;
case VcEventType.TextMessage:
HandleTextMessage(ev);
break;
case VcEventType.TalkState:
HandleTalkState(ev);
break;
case VcEventType.StreamStarted:
HandleStreamStarted(ev);
break;
case VcEventType.StreamStopped:
if (_users.TryGetValue(ev.UserId, out var stUser) &&
stUser.ChannelId == _currentChannelId)
AddActivity($"{stUser.Nickname} stopped a stream");
break;
case VcEventType.Disconnected:
HandleDisconnected(ev);
break;
}
}
// ── Event handlers ────────────────────────────────────────────────────────
private void HandleChannelList()
{
_channels = _client.ListChannels();
var users = _client.ListUsers();
_users.Clear();
foreach (var u in users)
{
_users[u.Id] = u;
if (u.Id == _selfUserId) _currentChannelId = u.ChannelId;
}
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
}
private void HandleUserJoined(VoiceCatEvent ev)
{
var user = new UserInfo(ev.UserId, ev.Text ?? $"User#{ev.UserId}", false, ev.ChannelId);
_users[ev.UserId] = user;
RefreshUserList();
RebuildScopeCombo();
if (ev.ChannelId == _currentChannelId && ev.UserId != _selfUserId)
AddActivity($"{user.Nickname} joined the channel");
}
private void HandleUserLeft(VoiceCatEvent ev)
{
if (!_users.TryGetValue(ev.UserId, out var user)) return;
bool wasHere = user.ChannelId == _currentChannelId && ev.UserId != _selfUserId;
_users.Remove(ev.UserId);
_talkingUsers.Remove(ev.UserId);
RefreshUserList();
RebuildScopeCombo();
if (wasHere) AddActivity($"{user.Nickname} left the channel");
}
private void HandleUserUpdated()
{
var users = _client.ListUsers();
_users.Clear();
foreach (var u in users)
{
_users[u.Id] = u;
if (u.Id == _selfUserId) _currentChannelId = u.ChannelId;
}
RefreshChannelTree();
RefreshUserList();
RebuildScopeCombo();
}
private void HandleJoinResult(VoiceCatEvent ev)
{
if (ev.Result == VcResult.Ok)
{
_currentChannelId = ev.ChannelId;
// Server doesn't echo UserJoined/UserUpdated back to the mover — patch our
// own entry in _users so RefreshUserList shows us in the new channel.
if (_users.TryGetValue(_selfUserId, out var self))
_users[_selfUserId] = self with { ChannelId = ev.ChannelId };
RefreshChannelTree();
RefreshUserList();
UpdateStatusLabel();
string chanName = _channels.FirstOrDefault(c => c.Id == ev.ChannelId)?.Name
?? $"Channel #{ev.ChannelId}";
AddActivity($"Joined {chanName}");
}
else
{
AddActivity($"Could not join channel: {ev.Text ?? ev.Result.ToString()}");
}
}
private void HandleTextMessage(VoiceCatEvent ev)
{
string time = ev.TimestampUnixMs > 0
? DateTimeOffset.FromUnixTimeMilliseconds((long)ev.TimestampUnixMs)
.LocalDateTime.ToString("HH:mm")
: DateTime.Now.ToString("HH:mm");
string sender = GetNickname(ev.UserId);
string prefix = ev.TextScope == VcTextScope.Private ? "(private) " : "";
rtbChat.AppendText($"[{time}] {prefix}{sender}: {ev.Text ?? ""}\n");
rtbChat.ScrollToCaret();
if (ev.TextScope == VcTextScope.Private && ev.UserId != _selfUserId)
AddActivity($"Private message from {sender}");
}
private void HandleTalkState(VoiceCatEvent ev)
{
bool talking = ev.U32a == 1;
if (talking) _talkingUsers.Add(ev.UserId);
else _talkingUsers.Remove(ev.UserId);
RefreshUserList();
if (talking && ev.UserId != _selfUserId &&
_users.TryGetValue(ev.UserId, out var tUser) &&
tUser.ChannelId == _currentChannelId)
AddActivity($"{tUser.Nickname} started talking");
}
private void HandleStreamStarted(VoiceCatEvent ev)
{
if (!_users.TryGetValue(ev.UserId, out var sUser) ||
sUser.ChannelId != _currentChannelId) return;
var streams = _client.ListUserStreams(ev.UserId);
var stream = streams.FirstOrDefault(s => s.StreamId == ev.StreamId);
string kind = stream?.Kind switch
{
VcStreamKind.ScreenAudio => "screen audio",
VcStreamKind.AuxDevice => "aux device",
_ => "microphone",
};
AddActivity($"{sUser.Nickname} started {kind} stream");
}
private void HandleDisconnected(VoiceCatEvent ev)
{
string msg = string.IsNullOrEmpty(ev.Text)
? "Disconnected from server."
: $"Disconnected: {ev.Text}";
lblStatus.Text = msg;
AddActivity(msg);
tvChannels.Nodes.Clear();
lstUsers.Items.Clear();
_users.Clear();
_talkingUsers.Clear();
_currentChannelId = 0;
_micStreamId = 0;
txtCompose.Enabled = false;
btnSend.Enabled = false;
btnMicToggle.Enabled = false;
}
// ── Level meter ───────────────────────────────────────────────────────────
private void OnLevelChanged(uint streamId, float rms)
{
if (streamId == _micStreamId)
pbLevel.Value = Math.Min(100, (int)(rms * 400));
}
// ── UI refresh helpers ────────────────────────────────────────────────────
private void RefreshChannelTree()
{
uint toSelect = tvChannels.SelectedNode?.Tag is uint s ? s : _currentChannelId;
tvChannels.BeginUpdate();
tvChannels.Nodes.Clear();
var byParent = _channels
.GroupBy(c => c.ParentId)
.ToDictionary(g => g.Key, g => g.ToList());
void AddChildren(TreeNodeCollection nodes, uint parentId)
{
if (!byParent.TryGetValue(parentId, out var kids)) return;
foreach (var ch in kids.OrderBy(c => c.Name))
{
var label = ch.Name;
if (ch.PasswordProtected) label += " [password]";
if (ch.Id == _currentChannelId) label += " ►";
var node = new TreeNode(label) { Tag = ch.Id };
nodes.Add(node);
AddChildren(node.Nodes, ch.Id);
}
}
AddChildren(tvChannels.Nodes, 0);
tvChannels.ExpandAll();
SeekAndSelect(tvChannels.Nodes, toSelect);
tvChannels.EndUpdate();
}
private bool SeekAndSelect(TreeNodeCollection nodes, uint channelId)
{
if (channelId == 0) return false;
foreach (TreeNode n in nodes)
{
if (n.Tag is uint id && id == channelId) { tvChannels.SelectedNode = n; return true; }
if (SeekAndSelect(n.Nodes, channelId)) return true;
}
return false;
}
private void RefreshUserList()
{
lstUsers.BeginUpdate();
lstUsers.Items.Clear();
foreach (var user in _users.Values
.Where(u => u.ChannelId == _currentChannelId)
.OrderBy(u => u.Nickname))
{
string label = user.Nickname;
if (user.Id == _selfUserId) label += " (you)";
if (_talkingUsers.Contains(user.Id)) label += " (talking)";
lstUsers.Items.Add(new UserListItem(user.Id, label));
}
lstUsers.EndUpdate();
}
private void RebuildScopeCombo()
{
uint prevTarget = (cboScope.SelectedItem is ScopeItem prev &&
prev.Scope == VcTextScope.Private)
? prev.TargetId : 0u;
cboScope.Items.Clear();
cboScope.Items.Add(new ScopeItem("Channel", VcTextScope.Channel, 0));
foreach (var u in _users.Values.OrderBy(u => u.Nickname))
{
if (u.Id == _selfUserId) continue;
cboScope.Items.Add(new ScopeItem($"Private: {u.Nickname}", VcTextScope.Private, u.Id));
}
if (prevTarget != 0)
{
for (int i = 1; i < cboScope.Items.Count; i++)
{
if (cboScope.Items[i] is ScopeItem si && si.TargetId == prevTarget)
{
cboScope.SelectedIndex = i;
return;
}
}
}
if (cboScope.Items.Count > 0) cboScope.SelectedIndex = 0;
}
private void UpdateStatusLabel()
{
if (_currentChannelId == 0)
{
lblStatus.Text = $"Connected as {_nickname} — not in a channel.";
return;
}
string chanName = _channels.FirstOrDefault(c => c.Id == _currentChannelId)?.Name
?? $"Channel #{_currentChannelId}";
int count = _users.Values.Count(u => u.ChannelId == _currentChannelId);
lblStatus.Text = $"Connected as {_nickname} — {chanName} ({count} user{(count == 1 ? "" : "s")})";
}
// ── Device management ─────────────────────────────────────────────────────
private void LoadInputDevices()
{
var devices = _client.ListDevices(VcDeviceKind.Input);
DeviceInfo? prevDevice = cboInputDevice.SelectedItem as DeviceInfo;
cboInputDevice.Items.Clear();
foreach (var d in devices) cboInputDevice.Items.Add(d);
// Restore selection or pick default
if (prevDevice is not null)
{
for (int i = 0; i < cboInputDevice.Items.Count; i++)
{
if (cboInputDevice.Items[i] is DeviceInfo d && d.Id == prevDevice.Id)
{
cboInputDevice.SelectedIndex = i;
return;
}
}
}
// Select default device
for (int i = 0; i < cboInputDevice.Items.Count; i++)
{
if (cboInputDevice.Items[i] is DeviceInfo d && d.IsDefault)
{
cboInputDevice.SelectedIndex = i;
return;
}
}
if (cboInputDevice.Items.Count > 0) cboInputDevice.SelectedIndex = 0;
}
// ── Voice controls ────────────────────────────────────────────────────────
private void BtnMicToggle_Click(object? sender, EventArgs e)
{
if (_micStreamId == 0)
{
var (result, streamId) = _client.StartStream(VcStreamKind.Mic, "Microphone");
if (result == VcResult.Ok)
{
_micStreamId = streamId;
// Apply selected device if not default
if (cboInputDevice.SelectedItem is DeviceInfo { IsDefault: false } dev)
_client.SetInputDevice(streamId, dev.Id);
// Apply current mode
_client.SetInputMode(CurrentInputMode());
if (radioVad.Checked) _client.SetVadThreshold(VadThresholdFromSlider());
btnMicToggle.Text = "Leave &Voice";
chkMute.Enabled = true;
chkDeafen.Enabled = true;
radioVad.Enabled = true;
radioPtt.Enabled = true;
radioAlwaysOn.Enabled = true;
AddActivity("Joined voice — microphone active");
}
else
{
AddActivity($"Failed to start microphone: {result}");
}
}
else
{
_client.SetPushToTalk(false); // release PTT if held
_client.StopStream(_micStreamId);
_micStreamId = 0;
pbLevel.Value = 0;
btnMicToggle.Text = "&Join Voice";
chkMute.Enabled = false;
chkDeafen.Enabled = false;
radioVad.Enabled = false;
radioPtt.Enabled = false;
radioAlwaysOn.Enabled = false;
AddActivity("Left voice");
}
}
private void ApplySelfMute() =>
_client.SetSelfMute(chkMute.Checked, chkDeafen.Checked);
private void RadioVad_CheckedChanged(object? sender, EventArgs e)
{
if (!radioVad.Checked) return;
lblPttKey.Visible = false;
btnChangePtt.Visible = false;
lblVadThreshold.Visible = true;
trkVadThreshold.Visible = true;
if (_micStreamId != 0)
{
_client.SetInputMode(VcInputMode.VoiceActivation);
_client.SetVadThreshold(VadThresholdFromSlider());
}
}
private void RadioPtt_CheckedChanged(object? sender, EventArgs e)
{
if (!radioPtt.Checked) return;
lblPttKey.Text = $"({_pttKey})";
lblPttKey.Visible = true;
btnChangePtt.Visible = true;
lblVadThreshold.Visible = false;
trkVadThreshold.Visible = false;
if (_micStreamId != 0)
{
_client.SetInputMode(VcInputMode.PushToTalk);
_client.SetPushToTalk(false); // start released
}
}
private void RadioAlwaysOn_CheckedChanged(object? sender, EventArgs e)
{
if (!radioAlwaysOn.Checked) return;
lblPttKey.Visible = false;
btnChangePtt.Visible = false;
lblVadThreshold.Visible = false;
trkVadThreshold.Visible = false;
if (_micStreamId != 0) _client.SetInputMode(VcInputMode.AlwaysOn);
}
private void TrkVadThreshold_Scroll(object? sender, EventArgs e)
{
if (_micStreamId != 0 && radioVad.Checked)
_client.SetVadThreshold(VadThresholdFromSlider());
}
// threshold = 0.1 × (1 (value1) / 99): slider=1→0.1 (least sensitive), slider=100→0.001
private float VadThresholdFromSlider() =>
0.1f * (1f - (trkVadThreshold.Value - 1f) / 99f);
private VcInputMode CurrentInputMode() =>
radioPtt.Checked ? VcInputMode.PushToTalk :
radioAlwaysOn.Checked ? VcInputMode.AlwaysOn :
VcInputMode.VoiceActivation;
private void BtnChangePtt_Click(object? sender, EventArgs e)
{
using var dlg = new PttKeyCaptureDialog(_pttKey);
if (dlg.ShowDialog(this) == DialogResult.OK)
{
_pttKey = dlg.CapturedKey;
lblPttKey.Text = $"({_pttKey})";
}
}
private void CboInputDevice_SelectedIndexChanged(object? sender, EventArgs e)
{
if (_micStreamId == 0) return;
string? deviceId = (cboInputDevice.SelectedItem as DeviceInfo)?.Id;
_client.SetInputDevice(_micStreamId, deviceId);
}
// ── PTT key handling (focus-scoped) ───────────────────────────────────────
private void MainForm_KeyDown(object? sender, KeyEventArgs e)
{
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
// Don't intercept PTT key while user is typing in a text control
if (ActiveControl is TextBox or RichTextBox) return;
_client.SetPushToTalk(true);
lblPttKey.Text = $"({_pttKey} ▶)";
e.Handled = true;
}
private void MainForm_KeyUp(object? sender, KeyEventArgs e)
{
if (!radioPtt.Checked || e.KeyCode != _pttKey || _micStreamId == 0) return;
_client.SetPushToTalk(false);
lblPttKey.Text = $"({_pttKey})";
e.Handled = true;
}
// ── Channel navigation ────────────────────────────────────────────────────
private void TvChannels_DoubleClick(object? sender, EventArgs e)
{
if (tvChannels.SelectedNode?.Tag is uint channelId)
JoinChannelRequest(channelId);
}
private void TvChannels_KeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter && tvChannels.SelectedNode?.Tag is uint channelId)
{
JoinChannelRequest(channelId);
e.Handled = e.SuppressKeyPress = true;
}
}
private void JoinChannelRequest(uint channelId)
{
if (channelId == _currentChannelId) return;
var channel = _channels.FirstOrDefault(c => c.Id == channelId);
string? password = null;
if (channel?.PasswordProtected == true)
{
using var dlg = new PasswordPromptDialog($"Password for channel \"{channel.Name}\":");
if (dlg.ShowDialog(this) != DialogResult.OK) return;
password = dlg.Password;
}
_client.JoinChannel(channelId, password);
}
// ── Per-user tuning ───────────────────────────────────────────────────────
private void OpenUserTuning()
{
if (lstUsers.SelectedItem is not UserListItem item) return;
if (!_users.TryGetValue(item.UserId, out var user)) return;
using var dlg = new PerUserTuningDialog(_client, item.UserId, user.Nickname);
dlg.ShowDialog(this);
}
// ── Text chat ─────────────────────────────────────────────────────────────
private void TxtCompose_KeyDown(object? sender, KeyEventArgs e)
{
if (e.KeyCode == Keys.Enter)
{
SendText();
e.Handled = e.SuppressKeyPress = true;
}
}
private void SendText()
{
string msg = txtCompose.Text.Trim();
if (string.IsNullOrEmpty(msg)) return;
var scope = VcTextScope.Channel;
uint targetId = _currentChannelId;
if (cboScope.SelectedItem is ScopeItem { Scope: VcTextScope.Private } si)
{
scope = VcTextScope.Private;
targetId = si.TargetId;
}
if (scope == VcTextScope.Channel && _currentChannelId == 0) return;
_client.SendText(scope, targetId, msg);
txtCompose.Clear();
// Server excludes sender from channel fan-out — echo our own message locally.
string time = DateTime.Now.ToString("HH:mm");
string prefix = scope == VcTextScope.Private
? $"(private to {GetNickname(targetId)}) "
: "";
rtbChat.AppendText($"[{time}] {prefix}{_nickname}: {msg}\n");
rtbChat.ScrollToCaret();
}
// ── Utility ───────────────────────────────────────────────────────────────
private void AddActivity(string text)
{
string entry = $"[{DateTime.Now:HH:mm}] {text}";
lstActivity.Items.Add(entry);
if (lstActivity.Items.Count > 200) lstActivity.Items.RemoveAt(0);
lstActivity.TopIndex = lstActivity.Items.Count - 1;
}
private string GetNickname(uint userId)
{
if (userId == _selfUserId) return _nickname;
return _users.TryGetValue(userId, out var u) ? u.Nickname : $"User#{userId}";
}
// ── Lifetime ──────────────────────────────────────────────────────────────
protected override void OnFormClosed(FormClosedEventArgs e)
{
_pumpTimer.Stop();
_client.LevelChanged -= OnLevelChanged;
_client.EventReceived -= OnEvent;
_client.Disconnect();
_client.Dispose();
base.OnFormClosed(e);
}
// ── Private types ─────────────────────────────────────────────────────────
private sealed class ScopeItem(string display, VcTextScope scope, uint targetId)
{
public VcTextScope Scope { get; } = scope;
public uint TargetId { get; } = targetId;
public override string ToString() => display;
}
private sealed class UserListItem(uint userId, string display)
{
public uint UserId { get; } = userId;
public override string ToString() => display;
}
}