diff --git a/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs index 128b958..f80d965 100644 --- a/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs +++ b/clients/windows/VoiceCat.App/Forms/ChannelEditDialog.cs @@ -150,8 +150,8 @@ public sealed class ChannelEditDialog : Form { Location = new Point(inputX, y - 2), Size = new Size(120, 23), - Minimum = 0, - Maximum = uint.MaxValue, + Minimum = int.MinValue, + Maximum = int.MaxValue, Value = existing?.SortOrder ?? 0, TabIndex = 5, }; @@ -385,7 +385,7 @@ public sealed class ChannelEditDialog : Form PasswordProtected: _chkPassword.Checked, Password: _chkPassword.Checked ? _txtPassword.Text : null, MaxUsers: (uint)_numMaxUsers.Value, - SortOrder: (uint)_numSortOrder.Value, + SortOrder: (int)_numSortOrder.Value, Audio: audio); } diff --git a/clients/windows/VoiceCat.App/Forms/MainForm.cs b/clients/windows/VoiceCat.App/Forms/MainForm.cs index 65e72fe..fe4d3fb 100644 --- a/clients/windows/VoiceCat.App/Forms/MainForm.cs +++ b/clients/windows/VoiceCat.App/Forms/MainForm.cs @@ -575,25 +575,21 @@ public partial class MainForm : Form 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) + void AddChildren(TreeNodeCollection nodes, IReadOnlyList children) { - if (!byParent.TryGetValue(parentId, out var kids)) return; - foreach (var ch in kids.OrderBy(c => c.Name)) + foreach (ChannelHierarchyItem item in children) { + ChannelInfo ch = item.Channel; int count = _users.Values.Count(u => u.ChannelId == ch.Id); var label = $"{ch.Name} ({count})"; 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(node.Nodes, item.Children); } } - AddChildren(tvChannels.Nodes, 0); + AddChildren(tvChannels.Nodes, ChannelHierarchy.Build(_channels)); tvChannels.ExpandAll(); SeekAndSelect(tvChannels.Nodes, toSelect); tvChannels.EndUpdate(); diff --git a/clients/windows/VoiceCat.Windows/Administration.cs b/clients/windows/VoiceCat.Windows/Administration.cs index 95e0bd3..7078ec3 100644 --- a/clients/windows/VoiceCat.Windows/Administration.cs +++ b/clients/windows/VoiceCat.Windows/Administration.cs @@ -24,7 +24,7 @@ public sealed partial class VoiceCatClient } private static Voicecat.V1.Channel Channel(ChannelEditInfo info) => new() { - Id = info.Id, ParentId = info.ParentId, Name = info.Name, Topic = info.Topic, MaxUsers = info.MaxUsers, Order = unchecked((int)info.SortOrder), + Id = info.Id, ParentId = info.ParentId, Name = info.Name, Topic = info.Topic, MaxUsers = info.MaxUsers, Order = info.SortOrder, Audio = new() { Codec = info.Audio.Codec, Mode = info.Audio.Stereo ? ChannelMode.ModeStereo : ChannelMode.ModeMono, SampleRate = info.Audio.SampleRate, BitrateBps = info.Audio.BitrateBps, FrameMs = info.Audio.FrameMs, Application = (OpusApplication)info.Audio.Application, Complexity = info.Audio.Complexity, Fec = info.Audio.Fec, ExpectedPacketLoss = info.Audio.ExpectedPacketLoss, Dtx = info.Audio.Dtx, Dred = info.Audio.Dred } diff --git a/clients/windows/VoiceCat.Windows/ChannelHierarchy.cs b/clients/windows/VoiceCat.Windows/ChannelHierarchy.cs new file mode 100644 index 0000000..cc66e7e --- /dev/null +++ b/clients/windows/VoiceCat.Windows/ChannelHierarchy.cs @@ -0,0 +1,32 @@ +namespace VoiceCat.Windows; + +/// A channel and its ordered children as displayed by the Windows client. +public sealed record ChannelHierarchyItem( + ChannelInfo Channel, + IReadOnlyList Children); + +public static class ChannelHierarchy +{ + /// + /// Builds the server-defined channel hierarchy. SortOrder orders siblings; LINQ's stable + /// ordering preserves the server's sequence when siblings have the same value. + /// + public static IReadOnlyList Build(IReadOnlyList channels) + { + var byParent = channels + .GroupBy(channel => channel.ParentId) + .ToDictionary(group => group.Key, group => group.OrderBy(channel => channel.SortOrder).ToArray()); + + IReadOnlyList AddChildren(uint parentId) + { + if (!byParent.TryGetValue(parentId, out ChannelInfo[]? children)) + return []; + + return children + .Select(channel => new ChannelHierarchyItem(channel, AddChildren(channel.Id))) + .ToArray(); + } + + return AddChildren(0); + } +} diff --git a/clients/windows/VoiceCat.Windows/Models.cs b/clients/windows/VoiceCat.Windows/Models.cs index 9a5368d..87c1142 100644 --- a/clients/windows/VoiceCat.Windows/Models.cs +++ b/clients/windows/VoiceCat.Windows/Models.cs @@ -8,7 +8,7 @@ public sealed record ChannelInfo( string Topic, bool PasswordProtected, uint MaxUsers, - uint SortOrder, + int SortOrder, AudioConfigInfo Audio); public sealed record ChannelEditInfo( @@ -19,7 +19,7 @@ public sealed record ChannelEditInfo( bool PasswordProtected, string? Password, uint MaxUsers, - uint SortOrder, + int SortOrder, AudioConfigInfo Audio); public sealed record UserInfo( diff --git a/clients/windows/VoiceCat.Windows/VoiceCatClient.cs b/clients/windows/VoiceCat.Windows/VoiceCatClient.cs index 0d681dd..e9c9b2b 100644 --- a/clients/windows/VoiceCat.Windows/VoiceCatClient.cs +++ b/clients/windows/VoiceCat.Windows/VoiceCatClient.cs @@ -172,7 +172,7 @@ public sealed partial class VoiceCatClient : IDisposable } public VcResult LeaveVoice() { StopDevices(); local.Clear(); return Request(new() { UnsubscribeVoice = new() }); } - public List ListChannels() => core.Channels.Select(c => new ChannelInfo(c.Id, c.ParentId, c.Name, c.Topic, c.PasswordProtected, c.MaxUsers, unchecked((uint)c.Order), Audio(c.Audio))).ToList(); + public List ListChannels() => core.Channels.Select(c => new ChannelInfo(c.Id, c.ParentId, c.Name, c.Topic, c.PasswordProtected, c.MaxUsers, c.Order, Audio(c.Audio))).ToList(); public List ListUsers() => core.Users.Select(u => new UserInfo(u.Id, u.Nickname, u.IsGuest, u.ChannelId, u.SelfMicMuted, u.SelfDeafened, u.ServerMuted, u.ServerDeafened, u.VoiceSubscribed)).ToList(); public List ListUserStreams(uint id) => id == core.Authentication?.Self.Id ? local.Values.Select(s => new StreamSummary(s.Alias, (VcStreamKind)s.Info.Kind, s.Info.Label)).ToList() diff --git a/tests/VoiceCat.Tests/WindowsManagedClientTests.cs b/tests/VoiceCat.Tests/WindowsManagedClientTests.cs index 68aac28..d4247fa 100644 --- a/tests/VoiceCat.Tests/WindowsManagedClientTests.cs +++ b/tests/VoiceCat.Tests/WindowsManagedClientTests.cs @@ -8,6 +8,28 @@ namespace VoiceCat.Tests; public class WindowsManagedClientTests { + private static readonly AudioConfigInfo DefaultAudio = new(0, false, 48_000, 64_000, 20, 0, true, 10, false, 10, false); + + [Fact] + public void ChannelHierarchyUsesServerSortOrderAndKeepsIdsWithDisplayedChannels() + { + ChannelInfo[] channels = + [ + new(10, 0, "Zulu", "", false, 0, -10, DefaultAudio), + new(20, 0, "Alpha", "", false, 0, 20, DefaultAudio), + new(11, 10, "Zulu child", "", false, 0, 10, DefaultAudio), + new(12, 10, "Alpha child", "", false, 0, 0, DefaultAudio), + new(30, 0, "Beta", "", false, 0, 20, DefaultAudio), + ]; + + IReadOnlyList roots = ChannelHierarchy.Build(channels); + + Assert.Equal([(10U, "Zulu"), (20U, "Alpha"), (30U, "Beta")], + roots.Select(item => (item.Channel.Id, item.Channel.Name))); + Assert.Equal([(12U, "Alpha child"), (11U, "Zulu child")], + roots[0].Children.Select(item => (item.Channel.Id, item.Channel.Name))); + } + private static async Task Until(Client client, Func predicate) { using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));