diff --git a/LANCommander.SDK/Models/ChatThread.cs b/LANCommander.SDK/Models/ChatThread.cs index 40b4f2d6..b708ed2f 100644 --- a/LANCommander.SDK/Models/ChatThread.cs +++ b/LANCommander.SDK/Models/ChatThread.cs @@ -32,7 +32,8 @@ public class ChatThread public async Task MessageReceivedAsync(ChatMessage message) { - AddToMessageGroups([message]); + // Use 5 minute time gap for grouping messages + AddToMessageGroups([message], TimeSpan.FromMinutes(5)); if (OnMessageReceivedAsync != null) await OnMessageReceivedAsync.Invoke(message); @@ -53,37 +54,71 @@ public class ChatThread private void AddToMessageGroups(IEnumerable source, TimeSpan? maxGap = null) { + var sourceMessages = source.OrderBy(x => x.SentOn).ToList(); + + if (sourceMessages.Count == 0) + return; + ChatMessageGroup? current = MessageGroups.LastOrDefault(); ChatMessage? last = null; - if (current is not null) - MessageGroups.Remove(current); + // Check if we should merge with the last group + bool shouldMergeWithLast = current is not null && + sourceMessages.Count > 0 && + sourceMessages[0].UserId == current.UserId && + (maxGap == null || (current.Messages.Count > 0 && + (sourceMessages[0].SentOn - current.Messages.Last().SentOn) <= maxGap.Value)); - foreach (var message in source.OrderBy(x => x.SentOn)) + if (shouldMergeWithLast) { - var mustBreak = current is null || message.UserId != current.UserId || (maxGap is not null && - last is not null && (message.SentOn - last.SentOn) > maxGap.Value); - - if (mustBreak) + // Merge new messages into existing last group + // Filter out messages that already exist in the group + var existingMessageIds = new HashSet(current.Messages.Select(m => m.Id)); + var newMessages = sourceMessages.Where(m => !existingMessageIds.Contains(m.Id)).ToList(); + + foreach (var message in newMessages) { - if (current is not null) - MessageGroups.Add(current); - - current = new ChatMessageGroup - { - Id = Guid.NewGuid(), - UserId = message.UserId, - UserName = message.UserName, - Messages = [message], - }; + current.Messages.Add(message); } - else - current!.Messages.Add(message); - - last = message; + + last = current.Messages.LastOrDefault(); + } + else + { + // Remove last group if it exists (we'll re-add it if needed) + if (current is not null) + MessageGroups.Remove(current); + + foreach (var message in sourceMessages) + { + var mustBreak = current is null || message.UserId != current.UserId || (maxGap is not null && + last is not null && (message.SentOn - last.SentOn) > maxGap.Value); + + if (mustBreak) + { + if (current is not null) + MessageGroups.Add(current); + + current = new ChatMessageGroup + { + Id = Guid.NewGuid(), + UserId = message.UserId, + UserName = message.UserName, + Messages = [message], + }; + } + else + { + // Check if message already exists in current group to avoid duplicates + if (!current.Messages.Any(m => m.Id == message.Id)) + current.Messages.Add(message); + } + + last = message; + } + + if (current is not null) + MessageGroups.Add(current); } - - if (current is not null) - MessageGroups.Add(current); } } \ No newline at end of file diff --git a/LANCommander.Server/Hubs/ChatHub.cs b/LANCommander.Server/Hubs/ChatHub.cs index 5cd470da..37f90e7f 100644 --- a/LANCommander.Server/Hubs/ChatHub.cs +++ b/LANCommander.Server/Hubs/ChatHub.cs @@ -120,6 +120,12 @@ public class ChatHub( var message = await chatService.SendMessageAsync(threadId, content); var participants = await GetThreadParticipants(threadId); + // Update read status for the sender (they've read their own message) + if (Guid.TryParse(Context.UserIdentifier, out var userId)) + { + await chatService.UpdateReadStatus(threadId, userId); + } + await Clients.Users(participants).ReceiveMessageAsync(threadId, mapper.Map(message)); } diff --git a/LANCommander.UI/Components/Chat/ChatThread.razor b/LANCommander.UI/Components/Chat/ChatThread.razor index c0437b26..81f50f62 100644 --- a/LANCommander.UI/Components/Chat/ChatThread.razor +++ b/LANCommander.UI/Components/Chat/ChatThread.razor @@ -9,6 +9,7 @@ @if (_thread != null) { _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); } } @@ -50,6 +69,55 @@ 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 @@ -71,6 +139,30 @@ .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) { @@ -104,6 +196,8 @@ { 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 @@ -114,17 +208,27 @@ foreach (var message in messagesList) { - // Start a new group if this is the first message, or if the sender changed - if (currentGroup == null || message.UserId != currentGroup.UserId) + // 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 = Guid.NewGuid(), + Id = message.Id, // Use first message ID as group ID for stability UserId = message.UserId, UserName = message.UserName, Messages = new List { message } diff --git a/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs index bd51df72..e23d423d 100644 --- a/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs +++ b/LANCommander.UI/Components/InfiniteLoader/InfiniteLoader.razor.cs @@ -216,6 +216,67 @@ public partial class InfiniteLoader : BaseComponent await LoadMoreAsync(anchor); } + /// + /// Adds new items to the end of the list (for real-time updates like new messages) + /// + public async Task AddItemsAsync(IEnumerable newItems) + { + if (newItems == null || !newItems.Any()) + return; + + var itemsList = newItems.ToList(); + + if (_keySelector is not null) + { + // Filter out duplicates using the key selector + var existingKeys = new HashSet(_items.Select(_keySelector)); + var uniqueItems = itemsList.Where(item => !existingKeys.Contains(_keySelector(item))).ToList(); + + if (uniqueItems.Count > 0) + { + _items.AddRange(uniqueItems); + await InvokeAsync(StateHasChanged); + + // Scroll to bottom to show new messages + await Task.Delay(50); + await ScrollToBottomAsync(); + } + } + else + { + _items.AddRange(itemsList); + await InvokeAsync(StateHasChanged); + + // Scroll to bottom to show new messages + await Task.Delay(50); + await ScrollToBottomAsync(); + } + } + + /// + /// Gets the last item in the list (newest item) + /// + public T? GetLastItem() + { + return _items.Count > 0 ? _items[_items.Count - 1] : default; + } + + /// + /// Updates the last item in the list (for merging messages into the last group) + /// + public async Task UpdateLastItemAsync(T updatedItem) + { + if (_items.Count > 0) + { + _items[_items.Count - 1] = updatedItem; + await InvokeAsync(StateHasChanged); + + // Scroll to bottom to show the updated message + await Task.Delay(50); + await ScrollToBottomAsync(); + } + } + public async ValueTask DisposeAsync() { if (_scrollInterop is not null)