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
This commit is contained in:
jevb
2026-03-15 12:00:28 +01:00
parent 7a374e0c79
commit b4535aeea0
4 changed files with 104 additions and 34 deletions
@@ -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;
}
}
@@ -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;
+1 -1
View File
@@ -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<ApiAttachment>? Attachments = null
);
@@ -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;
}
}