From b4535aeea0d3f1ef32a29012f1e420267ac47ace Mon Sep 17 00:00:00 2001 From: jevb Date: Sun, 15 Mar 2026 12:00:28 +0100 Subject: [PATCH] perf: fix O(n) allocations, freeze brushes, improve disposal and nullability - Replace 5x ToList().FindIndex() with direct for-loops in MainViewModel - UpdateUnreadCount now updates ChannelGroup in-place instead of full rebuild - Remove redundant RebuildChannelGroups() call in OnReady - Freeze all SolidColorBrush instances in converters for thread safety - EmojiPicker search shows empty state instead of fallback to all categories - MainViewModel implements IDisposable for _typingTimer cleanup - ApiMessage.Username changed to string? to match server reality --- .../Controls/EmojiPickerControl.xaml.cs | 4 +- .../Converters/ColorConverters.cs | 45 +++++++--- Client/OwnCord.Client/Models/ApiResponses.cs | 2 +- .../ViewModels/MainViewModel.cs | 87 ++++++++++++++----- 4 files changed, 104 insertions(+), 34 deletions(-) diff --git a/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml.cs b/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml.cs index 28db0a7d..6fccaa0d 100644 --- a/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml.cs +++ b/Client/OwnCord.Client/Controls/EmojiPickerControl.xaml.cs @@ -59,8 +59,6 @@ public partial class EmojiPickerControl : UserControl .Where(c => c.Name.Contains(query, StringComparison.OrdinalIgnoreCase)) .ToList(); - CategoryList.ItemsSource = filtered.Count > 0 - ? filtered - : EmojiData.Categories; + CategoryList.ItemsSource = filtered; } } diff --git a/Client/OwnCord.Client/Converters/ColorConverters.cs b/Client/OwnCord.Client/Converters/ColorConverters.cs index d9c877a8..fba55c8d 100644 --- a/Client/OwnCord.Client/Converters/ColorConverters.cs +++ b/Client/OwnCord.Client/Converters/ColorConverters.cs @@ -15,7 +15,9 @@ public sealed class HexColorToBrushConverter : IValueConverter try { var color = (Color)ColorConverter.ConvertFromString(hex); - return new SolidColorBrush(color); + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; } catch { @@ -24,7 +26,9 @@ public sealed class HexColorToBrushConverter : IValueConverter } // Default fallback color (muted text) - return new SolidColorBrush((Color)ColorConverter.ConvertFromString("#949ba4")); + var fallback = new SolidColorBrush((Color)ColorConverter.ConvertFromString("#949ba4")); + fallback.Freeze(); + return fallback; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) @@ -35,10 +39,17 @@ public sealed class HexColorToBrushConverter : IValueConverter [ValueConversion(typeof(string), typeof(SolidColorBrush))] public sealed class StatusToBrushConverter : IValueConverter { - private static readonly SolidColorBrush Online = new((Color)ColorConverter.ConvertFromString("#23a55a")); - private static readonly SolidColorBrush Idle = new((Color)ColorConverter.ConvertFromString("#f0b232")); - private static readonly SolidColorBrush Dnd = new((Color)ColorConverter.ConvertFromString("#f23f43")); - private static readonly SolidColorBrush Offline = new((Color)ColorConverter.ConvertFromString("#6d6f78")); + private static readonly SolidColorBrush Online = CreateFrozen("#23a55a"); + private static readonly SolidColorBrush Idle = CreateFrozen("#f0b232"); + private static readonly SolidColorBrush Dnd = CreateFrozen("#f23f43"); + private static readonly SolidColorBrush Offline = CreateFrozen("#6d6f78"); + + private static SolidColorBrush CreateFrozen(string hex) + { + var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { @@ -70,8 +81,15 @@ public sealed class FirstLetterConverter : IValueConverter [ValueConversion(typeof(bool), typeof(SolidColorBrush))] public sealed class BoolToRedBrushConverter : IValueConverter { - private static readonly SolidColorBrush Red = new((Color)ColorConverter.ConvertFromString("#f23f43")); - private static readonly SolidColorBrush Normal = new((Color)ColorConverter.ConvertFromString("#b5bac1")); + private static readonly SolidColorBrush Red = CreateFrozen("#f23f43"); + private static readonly SolidColorBrush Normal = CreateFrozen("#b5bac1"); + + private static SolidColorBrush CreateFrozen(string hex) + { + var brush = new SolidColorBrush((Color)ColorConverter.ConvertFromString(hex)); + brush.Freeze(); + return brush; + } public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is true ? Red : Normal; @@ -84,8 +102,15 @@ public sealed class BoolToRedBrushConverter : IValueConverter [ValueConversion(typeof(bool), typeof(SolidColorBrush))] public sealed class SpeakingToStrokeBrushConverter : IValueConverter { - private static readonly SolidColorBrush Speaking = new((Color)ColorConverter.ConvertFromString("#23a55a")); - private static readonly SolidColorBrush Silent = new(Colors.Transparent); + private static readonly SolidColorBrush Speaking = CreateFrozen((Color)ColorConverter.ConvertFromString("#23a55a")); + private static readonly SolidColorBrush Silent = CreateFrozen(Colors.Transparent); + + private static SolidColorBrush CreateFrozen(Color color) + { + var brush = new SolidColorBrush(color); + brush.Freeze(); + return brush; + } public object Convert(object value, Type targetType, object parameter, CultureInfo culture) => value is true ? Speaking : Silent; diff --git a/Client/OwnCord.Client/Models/ApiResponses.cs b/Client/OwnCord.Client/Models/ApiResponses.cs index 0be84111..77d7471d 100644 --- a/Client/OwnCord.Client/Models/ApiResponses.cs +++ b/Client/OwnCord.Client/Models/ApiResponses.cs @@ -50,7 +50,7 @@ public record ApiMessage( [property: JsonPropertyName("deleted")] bool Deleted, [property: JsonPropertyName("pinned")] bool Pinned, [property: JsonPropertyName("timestamp")] string Timestamp, - [property: JsonPropertyName("username")] string Username, + [property: JsonPropertyName("username")] string? Username, [property: JsonPropertyName("avatar")] string? Avatar, [property: JsonPropertyName("attachments")] IReadOnlyList? Attachments = null ); diff --git a/Client/OwnCord.Client/ViewModels/MainViewModel.cs b/Client/OwnCord.Client/ViewModels/MainViewModel.cs index 4441926f..ba2b001b 100644 --- a/Client/OwnCord.Client/ViewModels/MainViewModel.cs +++ b/Client/OwnCord.Client/ViewModels/MainViewModel.cs @@ -7,7 +7,7 @@ using OwnCord.Client.Services; namespace OwnCord.Client.ViewModels; -public sealed class MainViewModel : ViewModelBase +public sealed class MainViewModel : ViewModelBase, IDisposable { private IChatService? _chat; private Channel? _selectedChannel; @@ -444,11 +444,28 @@ public sealed class MainViewModel : ViewModelBase public void UpdateUnreadCount(long channelId, int count) { - var idx = Channels.ToList().FindIndex(c => c.Id == channelId); - if (idx < 0) return; - var updated = Channels[idx] with { UnreadCount = count }; - Channels[idx] = updated; - RebuildChannelGroups(); + for (var i = 0; i < Channels.Count; i++) + { + if (Channels[i].Id == channelId) + { + if (Channels[i].UnreadCount == count) return; + Channels[i] = Channels[i] with { UnreadCount = count }; + + // Update the specific ChannelItem in-place instead of rebuilding all groups + foreach (var group in ChannelGroups) + { + for (var j = 0; j < group.Items.Count; j++) + { + if (group.Items[j].Channel.Id == channelId) + { + group.Items[j] = new ChannelItem { Channel = Channels[i] }; + return; + } + } + } + return; + } + } } public void ShowTyping(string username) @@ -780,7 +797,6 @@ public sealed class MainViewModel : ViewModelBase }; Channels.Add(new Channel(ch.Id, ch.Name, type, ch.Category, ch.Position, 0, null, ch.Topic)); } - RebuildChannelGroups(); // Load members Members.Clear(); @@ -867,8 +883,6 @@ public sealed class MainViewModel : ViewModelBase private void OnPresence(PresencePayload payload) { - var idx = Members.ToList().FindIndex(m => m.Id == payload.UserId); - if (idx < 0) return; var status = payload.Status switch { "online" => UserStatus.Online, @@ -876,24 +890,41 @@ public sealed class MainViewModel : ViewModelBase "dnd" => UserStatus.Dnd, _ => UserStatus.Offline }; - Members[idx] = Members[idx] with { Status = status }; - RebuildMemberGroups(); + for (var i = 0; i < Members.Count; i++) + { + if (Members[i].Id == payload.UserId) + { + Members[i] = Members[i] with { Status = status }; + RebuildMemberGroups(); + break; + } + } } private void OnChatEdited(ChatEditedPayload payload) { - var idx = Messages.ToList().FindIndex(m => m.Id == payload.MessageId); - if (idx < 0) return; - Messages[idx] = Messages[idx] with { Content = payload.Content, EditedAt = payload.EditedAt }; - RebuildDisplayMessages(); + for (var i = 0; i < Messages.Count; i++) + { + if (Messages[i].Id == payload.MessageId) + { + Messages[i] = Messages[i] with { Content = payload.Content, EditedAt = payload.EditedAt }; + RebuildDisplayMessages(); + break; + } + } } private void OnChatDeleted(ChatDeletedPayload payload) { - var idx = Messages.ToList().FindIndex(m => m.Id == payload.MessageId); - if (idx < 0) return; - Messages[idx] = Messages[idx] with { Deleted = true, Content = "[deleted]" }; - RebuildDisplayMessages(); + for (var i = 0; i < Messages.Count; i++) + { + if (Messages[i].Id == payload.MessageId) + { + Messages[i] = Messages[i] with { Deleted = true, Content = "[deleted]" }; + RebuildDisplayMessages(); + break; + } + } } private void OnMemberJoined(WsMember payload) @@ -927,7 +958,15 @@ public sealed class MainViewModel : ViewModelBase private void OnChannelUpdated(ChannelEventPayload payload) { - var idx = Channels.ToList().FindIndex(c => c.Id == payload.Id); + var idx = -1; + for (var i = 0; i < Channels.Count; i++) + { + if (Channels[i].Id == payload.Id) + { + idx = i; + break; + } + } if (idx < 0) return; var type = payload.Type switch { @@ -1028,4 +1067,12 @@ public sealed class MainViewModel : ViewModelBase var ch = Channels.FirstOrDefault(c => c.Id == channelId); return ch?.UnreadCount ?? 0; } + + // ── IDisposable ────────────────────────────────────────────────────────── + + public void Dispose() + { + _typingTimer?.Dispose(); + _typingTimer = null; + } }