@using System.Collections.Specialized @using LANCommander.SDK.Models @using LANCommander.SDK.Services @inject IChatClient ChatClient @namespace LANCommander.UI.Components @if (_thread != null) { @foreach (var message in context.Messages) { } } @code { [Parameter] public Guid? Id { get; set; } SDK.Models.ChatThread? _thread; private SDK.Models.ChatMessage? _lastCursor; private readonly HashSet _loadedMessageIds = new(); private InfiniteLoader? _infiniteLoader; private bool _isSubscribed = false; protected override async Task OnParametersSetAsync() { if ((_thread == null || _thread.Id != Id) && Id.HasValue) { // Unsubscribe from previous thread if exists if (_thread != null && _isSubscribed) { _thread.OnMessageReceivedAsync -= OnMessageReceivedAsync; _thread.MessageGroups.CollectionChanged -= MessageGroupsOnCollectionChanged; _isSubscribed = false; } _thread = await ChatClient.GetThreadAsync(Id.Value); _thread.MessageGroups.CollectionChanged += MessageGroupsOnCollectionChanged; // Subscribe to message received events _thread.OnMessageReceivedAsync = OnMessageReceivedAsync; _isSubscribed = true; // Reset loaded message tracking when switching threads _loadedMessageIds.Clear(); await ChatClient.GetMessagesAsync(Id.Value, null, 10); } } void MessageGroupsOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e) => InvokeAsync(StateHasChanged); async Task OnMessageReceivedAsync(SDK.Models.ChatMessage message) { // When a new message is received via SignalR, add it to the InfiniteLoader if (_thread != null && _infiniteLoader != null) { // Check if message is already loaded if (_loadedMessageIds.Contains(message.Id)) return; // Track the message _loadedMessageIds.Add(message.Id); // Check if we should merge with the last group in InfiniteLoader const int maxGapMinutes = 5; var maxGap = TimeSpan.FromMinutes(maxGapMinutes); var lastGroup = _infiniteLoader.GetLastItem() as SDK.Models.ChatMessageGroup; bool shouldMerge = lastGroup != null && lastGroup.UserId == message.UserId && lastGroup.Messages.Count > 0 && (message.SentOn - lastGroup.Messages.Last().SentOn) <= maxGap; if (shouldMerge) { // Merge into existing last group var updatedGroup = new SDK.Models.ChatMessageGroup { Id = lastGroup.Id, // Keep the same group ID UserId = lastGroup.UserId, UserName = lastGroup.UserName, Messages = new List(lastGroup.Messages) { message } }; await _infiniteLoader.UpdateLastItemAsync(updatedGroup); } else { // Create a new group var groups = GroupMessagesBySender([message]); if (groups.Count > 0) { // Add the new group(s) to the InfiniteLoader await _infiniteLoader.AddItemsAsync(groups); } } } } async Task> LoadMessages(SDK.Models.ChatMessageGroup? cursor, int pageSize) { // Use the oldest message from the cursor group, or the last cursor message SDK.Models.ChatMessage? messageCursor = null; if (cursor != null && cursor.Messages.Count > 0) { // Get the oldest message from the cursor group (first message in chronological order) messageCursor = cursor.Messages.OrderBy(m => m.SentOn).First(); } else if (_lastCursor != null) { messageCursor = _lastCursor; } var response = await ChatClient.GetMessagesAsync(Id.Value, messageCursor?.Id ?? null, pageSize); // Filter out blank/whitespace messages var validMessages = (response.Items ?? []) .Where(m => !string.IsNullOrWhiteSpace(m.Content)) .ToList(); // Build set of already-loaded message IDs from both our tracking and MessageGroups var existingMessageIds = new HashSet(_loadedMessageIds); if (_thread != null && _thread.MessageGroups.Count > 0) { foreach (var group in _thread.MessageGroups) { foreach (var message in group.Messages) { existingMessageIds.Add(message.Id); } } } // Filter out messages that we've already loaded (to avoid duplicates) validMessages = validMessages .Where(m => !existingMessageIds.Contains(m.Id)) .ToList(); // Track newly loaded message IDs foreach (var message in validMessages) { _loadedMessageIds.Add(message.Id); } // Mark messages as read when they are loaded if (Id.HasValue && validMessages.Count > 0) { await ChatClient.UpdatedReadStatus(Id.Value); } // Group messages by sender (messages are already in chronological order from the service) var groups = GroupMessagesBySender(validMessages); // Track the oldest message for next cursor (from the oldest group) // Groups are in newest-first order (will be reversed by InfiniteLoader) // So the oldest group is the last one if (groups.Count > 0) { var oldestGroup = groups[groups.Count - 1]; if (oldestGroup.Messages.Count > 0) { // Get the oldest message from the oldest group _lastCursor = oldestGroup.Messages.OrderBy(m => m.SentOn).First(); } } return new InfiniteResponse { Items = groups, HasMore = response.HasMore }; } private List GroupMessagesBySender(IEnumerable messages) { var groups = new List(); SDK.Models.ChatMessageGroup? currentGroup = null; const int maxGapMinutes = 5; var maxGap = TimeSpan.FromMinutes(maxGapMinutes); // Messages come from service in newest-first order (OrderByDescending) // We'll process them in reverse to get oldest-first for grouping var messagesList = messages.ToList(); // Reverse to get oldest-first order for proper grouping messagesList.Reverse(); foreach (var message in messagesList) { // Check if we should start a new group: // 1. No current group // 2. Sender changed // 3. Time gap is more than 5 minutes var shouldStartNewGroup = currentGroup == null || message.UserId != currentGroup.UserId || (currentGroup.Messages.Count > 0 && (message.SentOn - currentGroup.Messages.Last().SentOn) > maxGap); if (shouldStartNewGroup) { if (currentGroup != null) { groups.Add(currentGroup); } // Use the first message ID as a stable group ID to help with duplicate detection // This ensures groups with the same first message get the same ID currentGroup = new SDK.Models.ChatMessageGroup { Id = message.Id, // Use first message ID as group ID for stability UserId = message.UserId, UserName = message.UserName, Messages = new List { message } }; } else { // Add to current group (messages within group are in chronological order) currentGroup.Messages.Add(message); } } // Add the last group if (currentGroup != null) { groups.Add(currentGroup); } // Groups are now in oldest-first order (first group is oldest) // InfiniteLoader will reverse them, so we need to reverse again to maintain oldest-first // Actually, wait - InfiniteLoader reverses, so if we return oldest-first, after reversal it's newest-first (wrong) // So we should return newest-first, then InfiniteLoader reverses to oldest-first groups.Reverse(); return groups; } }