Update read status when sending message, scroll to bottom on new message, merge message into group if gap < 5 mins

This commit is contained in:
Pat Hartl 2026-01-11 21:21:12 -06:00
parent abb808a8ad
commit 4086cbaeeb
4 changed files with 234 additions and 28 deletions

View file

@ -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<ChatMessage> 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<Guid>(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);
}
}

View file

@ -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<ChatMessage>(message));
}

View file

@ -9,6 +9,7 @@
@if (_thread != null)
{
<InfiniteLoader
@ref="_infiniteLoader"
T="SDK.Models.ChatMessageGroup"
KeySelector="g => g.Id.ToString()"
Loader="LoadMessages"
@ -34,15 +35,33 @@
SDK.Models.ChatThread? _thread;
private SDK.Models.ChatMessage? _lastCursor;
private readonly HashSet<Guid> _loadedMessageIds = new();
private InfiniteLoader<SDK.Models.ChatMessageGroup>? _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<SDK.Models.ChatMessage>(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<InfiniteResponse<SDK.Models.ChatMessageGroup>> 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<Guid>(_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>();
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<SDK.Models.ChatMessage> { message }

View file

@ -216,6 +216,67 @@ public partial class InfiniteLoader<T> : BaseComponent
await LoadMoreAsync(anchor);
}
/// <summary>
/// Adds new items to the end of the list (for real-time updates like new messages)
/// </summary>
public async Task AddItemsAsync(IEnumerable<T> 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<string>(_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();
}
}
/// <summary>
/// Gets the last item in the list (newest item)
/// </summary>
public T? GetLastItem()
{
return _items.Count > 0 ? _items[_items.Count - 1] : default;
}
/// <summary>
/// Updates the last item in the list (for merging messages into the last group)
/// </summary>
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)