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:
parent
abb808a8ad
commit
4086cbaeeb
4 changed files with 234 additions and 28 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue