💥 New message system and syncing API
This commit is contained in:
@@ -65,7 +65,19 @@ public partial class ChatService(
|
|||||||
|
|
||||||
logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews");
|
logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews");
|
||||||
|
|
||||||
// Notify clients of the updated message
|
// Create sync message for link preview update
|
||||||
|
var syncMessage = dbMessage.Clone();
|
||||||
|
syncMessage.Type = "messages.update.links";
|
||||||
|
syncMessage.UpdatedAt = dbMessage.UpdatedAt;
|
||||||
|
|
||||||
|
// Send sync message to clients
|
||||||
|
using var syncScope = scopeFactory.CreateScope();
|
||||||
|
var syncCrs = syncScope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||||
|
var syncMembers = await syncCrs.ListRoomMembers(dbMessage.ChatRoomId);
|
||||||
|
|
||||||
|
await SendWebSocketPacketToRoomMembersAsync(syncMessage, "messages.update.links", syncMembers, syncScope);
|
||||||
|
|
||||||
|
// Also notify clients of the updated message via regular WebSocket
|
||||||
await newChat.DeliverMessageAsync(
|
await newChat.DeliverMessageAsync(
|
||||||
dbMessage,
|
dbMessage,
|
||||||
dbMessage.Sender,
|
dbMessage.Sender,
|
||||||
@@ -146,6 +158,26 @@ public partial class ChatService(
|
|||||||
return message;
|
return message;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task SendWebSocketPacketToRoomMembersAsync(Message message, string type, List<ChatMember> members,
|
||||||
|
IServiceScope scope)
|
||||||
|
{
|
||||||
|
var scopedNty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
|
||||||
|
|
||||||
|
var request = new PushWebSocketPacketToUsersRequest
|
||||||
|
{
|
||||||
|
Packet = new WebSocketPacket
|
||||||
|
{
|
||||||
|
Type = type,
|
||||||
|
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null)
|
||||||
|
.Select(a => a!.Id.ToString()));
|
||||||
|
await scopedNty.PushWebSocketPacketToUsersAsync(request);
|
||||||
|
|
||||||
|
logger.LogInformation($"Delivered message to {request.UserIds.Count} accounts.");
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
|
public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
|
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
|
||||||
@@ -156,17 +188,8 @@ public partial class ChatService(
|
|||||||
db.ChatMessages.Add(message);
|
db.ChatMessages.Add(message);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
var files = message.Attachments.Distinct().ToList();
|
// Create file references if message has attachments
|
||||||
if (files.Count != 0)
|
await CreateFileReferencesForMessageAsync(message);
|
||||||
{
|
|
||||||
var request = new CreateReferenceBatchRequest
|
|
||||||
{
|
|
||||||
Usage = ChatFileUsageIdentifier,
|
|
||||||
ResourceId = message.ResourceIdentifier,
|
|
||||||
};
|
|
||||||
request.FilesId.AddRange(message.Attachments.Select(a => a.Id));
|
|
||||||
await fileRefs.CreateReferenceBatchAsync(request);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Then start the delivery process
|
// Then start the delivery process
|
||||||
_ = Task.Run(async () =>
|
_ = Task.Run(async () =>
|
||||||
@@ -177,8 +200,6 @@ public partial class ChatService(
|
|||||||
}
|
}
|
||||||
catch (Exception ex)
|
catch (Exception ex)
|
||||||
{
|
{
|
||||||
// Log the exception properly
|
|
||||||
// Consider using ILogger or your logging framework
|
|
||||||
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
|
logger.LogError($"Error when delivering message: {ex.Message} {ex.StackTrace}");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -203,31 +224,32 @@ public partial class ChatService(
|
|||||||
message.ChatRoom = room;
|
message.ChatRoom = room;
|
||||||
|
|
||||||
using var scope = scopeFactory.CreateScope();
|
using var scope = scopeFactory.CreateScope();
|
||||||
var scopedNty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
|
|
||||||
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||||
|
|
||||||
var members = await scopedCrs.ListRoomMembers(room.Id);
|
var members = await scopedCrs.ListRoomMembers(room.Id);
|
||||||
|
|
||||||
var request = new PushWebSocketPacketToUsersRequest
|
await SendWebSocketPacketToRoomMembersAsync(message, type, members, scope);
|
||||||
{
|
|
||||||
Packet = new WebSocketPacket
|
|
||||||
{
|
|
||||||
Type = type,
|
|
||||||
Data = GrpcTypeHelper.ConvertObjectToByteString(message),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
request.UserIds.AddRange(members.Select(a => a.Account).Where(a => a is not null)
|
|
||||||
.Select(a => a!.Id.ToString()));
|
|
||||||
await scopedNty.PushWebSocketPacketToUsersAsync(request);
|
|
||||||
|
|
||||||
if (!notify)
|
if (notify)
|
||||||
{
|
{
|
||||||
logger.LogInformation($"Delivered message to {request.UserIds.Count} accounts.");
|
await SendPushNotificationsAsync(message, sender, room, type, members, scope);
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task SendPushNotificationsAsync(
|
||||||
|
Message message,
|
||||||
|
ChatMember sender,
|
||||||
|
ChatRoom room,
|
||||||
|
string type,
|
||||||
|
List<ChatMember> members,
|
||||||
|
IServiceScope scope
|
||||||
|
)
|
||||||
|
{
|
||||||
|
var scopedCrs = scope.ServiceProvider.GetRequiredService<ChatRoomService>();
|
||||||
|
var scopedNty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
|
||||||
|
|
||||||
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
|
var roomSubject = room is { Type: ChatRoomType.DirectMessage, Name: null } ? "DM" :
|
||||||
room.Realm is not null ? $"{room.Name}, {room.Realm.Name}" : room.Name;
|
room.Realm is not null ? $"{room.Name ?? "Unknown"}, {room.Realm.Name}" : room.Name ?? "Unknown";
|
||||||
|
|
||||||
if (sender.Account is null)
|
if (sender.Account is null)
|
||||||
sender = await scopedCrs.LoadMemberAccount(sender);
|
sender = await scopedCrs.LoadMemberAccount(sender);
|
||||||
@@ -237,18 +259,37 @@ public partial class ChatService(
|
|||||||
sender.Id
|
sender.Id
|
||||||
);
|
);
|
||||||
|
|
||||||
var metaDict =
|
var notification = BuildNotification(message, sender, room, roomSubject, type);
|
||||||
new Dictionary<string, object>
|
|
||||||
{
|
var accountsToNotify = FilterAccountsForNotification(members, message, sender);
|
||||||
["sender_name"] = sender.Nick ?? sender.Account!.Nick,
|
|
||||||
["user_id"] = sender.AccountId,
|
logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts...");
|
||||||
["sender_id"] = sender.Id,
|
|
||||||
["message_id"] = message.Id,
|
if (accountsToNotify.Count > 0)
|
||||||
["room_id"] = room.Id,
|
{
|
||||||
["images"] = message.Attachments
|
var ntyRequest = new SendPushNotificationToUsersRequest { Notification = notification };
|
||||||
.Where(a => a.MimeType != null && a.MimeType.StartsWith("image"))
|
ntyRequest.UserIds.AddRange(accountsToNotify.Select(a => a.Id.ToString()));
|
||||||
.Select(a => a.Id).ToList(),
|
await scopedNty.SendPushNotificationToUsersAsync(ntyRequest);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts.");
|
||||||
|
}
|
||||||
|
|
||||||
|
private PushNotification BuildNotification(Message message, ChatMember sender, ChatRoom room, string roomSubject,
|
||||||
|
string type)
|
||||||
|
{
|
||||||
|
var metaDict = new Dictionary<string, object>
|
||||||
|
{
|
||||||
|
["sender_name"] = sender.Nick ?? sender.Account!.Nick,
|
||||||
|
["user_id"] = sender.AccountId,
|
||||||
|
["sender_id"] = sender.Id,
|
||||||
|
["message_id"] = message.Id,
|
||||||
|
["room_id"] = room.Id,
|
||||||
|
["images"] = message.Attachments
|
||||||
|
.Where(a => a.MimeType != null && a.MimeType.StartsWith("image"))
|
||||||
|
.Select(a => a.Id).ToList(),
|
||||||
|
};
|
||||||
|
|
||||||
if (sender.Account!.Profile is not { Picture: null })
|
if (sender.Account!.Profile is not { Picture: null })
|
||||||
metaDict["pfp"] = sender.Account!.Profile.Picture.Id;
|
metaDict["pfp"] = sender.Account!.Profile.Picture.Id;
|
||||||
if (!string.IsNullOrEmpty(room.Name))
|
if (!string.IsNullOrEmpty(room.Name))
|
||||||
@@ -261,44 +302,51 @@ public partial class ChatService(
|
|||||||
Meta = GrpcTypeHelper.ConvertObjectToByteString(metaDict),
|
Meta = GrpcTypeHelper.ConvertObjectToByteString(metaDict),
|
||||||
ActionUri = $"/chat/{room.Id}",
|
ActionUri = $"/chat/{room.Id}",
|
||||||
IsSavable = false,
|
IsSavable = false,
|
||||||
|
Body = BuildNotificationBody(message, type)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
return notification;
|
||||||
|
}
|
||||||
|
|
||||||
|
private string BuildNotificationBody(Message message, string type)
|
||||||
|
{
|
||||||
if (message.DeletedAt is not null)
|
if (message.DeletedAt is not null)
|
||||||
notification.Body = "Deleted a message";
|
return "Deleted a message";
|
||||||
|
|
||||||
switch (message.Type)
|
switch (message.Type)
|
||||||
{
|
{
|
||||||
case "call.ended":
|
case "call.ended":
|
||||||
notification.Body = "Call ended";
|
return "Call ended";
|
||||||
break;
|
|
||||||
case "call.start":
|
case "call.start":
|
||||||
notification.Body = "Call begun";
|
return "Call begun";
|
||||||
break;
|
|
||||||
default:
|
default:
|
||||||
var attachmentWord = message.Attachments.Count == 1 ? "attachment" : "attachments";
|
var attachmentWord = message.Attachments.Count == 1 ? "attachment" : "attachments";
|
||||||
notification.Body = !string.IsNullOrEmpty(message.Content)
|
var body = !string.IsNullOrEmpty(message.Content)
|
||||||
? message.Content[..Math.Min(message.Content.Length, 100)]
|
? message.Content[..Math.Min(message.Content.Length, 100)]
|
||||||
: $"<{message.Attachments.Count} {attachmentWord}>";
|
: $"<{message.Attachments.Count} {attachmentWord}>";
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (type)
|
switch (type)
|
||||||
{
|
{
|
||||||
case WebSocketPacketType.MessageUpdate:
|
case WebSocketPacketType.MessageUpdate:
|
||||||
notification.Body += " (edited)";
|
body += " (edited)";
|
||||||
break;
|
break;
|
||||||
case WebSocketPacketType.MessageDelete:
|
case WebSocketPacketType.MessageDelete:
|
||||||
notification.Body = "Deleted a message";
|
body = "Deleted a message";
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private List<Account> FilterAccountsForNotification(List<ChatMember> members, Message message, ChatMember sender)
|
||||||
|
{
|
||||||
var now = SystemClock.Instance.GetCurrentInstant();
|
var now = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
List<Account> accountsToNotify = [];
|
var accountsToNotify = new List<Account>();
|
||||||
foreach (
|
foreach (var member in members.Where(member => member.Notify != ChatMemberNotify.None))
|
||||||
var member in members
|
|
||||||
.Where(member => member.Notify != ChatMemberNotify.None)
|
|
||||||
)
|
|
||||||
{
|
{
|
||||||
// if (scopedWs.IsUserSubscribedToChatRoom(member.AccountId, room.Id.ToString())) continue;
|
// Skip if mentioned but not in mentions-only mode or if break is active
|
||||||
if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.AccountId))
|
if (message.MembersMentioned is null || !message.MembersMentioned.Contains(member.AccountId))
|
||||||
{
|
{
|
||||||
if (member.BreakUntil is not null && member.BreakUntil > now) continue;
|
if (member.BreakUntil is not null && member.BreakUntil > now) continue;
|
||||||
@@ -309,17 +357,52 @@ public partial class ChatService(
|
|||||||
accountsToNotify.Add(member.Account.ToProtoValue());
|
accountsToNotify.Add(member.Account.ToProtoValue());
|
||||||
}
|
}
|
||||||
|
|
||||||
accountsToNotify = accountsToNotify
|
return accountsToNotify.Where(a => a.Id != sender.AccountId.ToString()).ToList();
|
||||||
.Where(a => a.Id != sender.AccountId.ToString()).ToList();
|
}
|
||||||
|
|
||||||
logger.LogInformation($"Trying to deliver message to {accountsToNotify.Count} accounts...");
|
private async Task CreateFileReferencesForMessageAsync(Message message)
|
||||||
// Only send notifications if there are accounts to notify
|
{
|
||||||
var ntyRequest = new SendPushNotificationToUsersRequest { Notification = notification };
|
var files = message.Attachments.Distinct().ToList();
|
||||||
ntyRequest.UserIds.AddRange(accountsToNotify.Select(a => a.Id.ToString()));
|
if (files.Count == 0) return;
|
||||||
if (accountsToNotify.Count > 0)
|
|
||||||
await scopedNty.SendPushNotificationToUsersAsync(ntyRequest);
|
|
||||||
|
|
||||||
logger.LogInformation($"Delivered message to {accountsToNotify.Count} accounts.");
|
var request = new CreateReferenceBatchRequest
|
||||||
|
{
|
||||||
|
Usage = ChatFileUsageIdentifier,
|
||||||
|
ResourceId = message.ResourceIdentifier,
|
||||||
|
};
|
||||||
|
request.FilesId.AddRange(message.Attachments.Select(a => a.Id));
|
||||||
|
await fileRefs.CreateReferenceBatchAsync(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task UpdateFileReferencesForMessageAsync(Message message, List<string> attachmentsId)
|
||||||
|
{
|
||||||
|
// Delete existing references for this message
|
||||||
|
await fileRefs.DeleteResourceReferencesAsync(
|
||||||
|
new DeleteResourceReferencesRequest { ResourceId = message.ResourceIdentifier }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create new references for each attachment
|
||||||
|
var createRequest = new CreateReferenceBatchRequest
|
||||||
|
{
|
||||||
|
Usage = ChatFileUsageIdentifier,
|
||||||
|
ResourceId = message.ResourceIdentifier,
|
||||||
|
};
|
||||||
|
createRequest.FilesId.AddRange(attachmentsId);
|
||||||
|
await fileRefs.CreateReferenceBatchAsync(createRequest);
|
||||||
|
|
||||||
|
// Update message attachments by getting files from database
|
||||||
|
var queryRequest = new GetFileBatchRequest();
|
||||||
|
queryRequest.Ids.AddRange(attachmentsId);
|
||||||
|
var queryResult = await filesClient.GetFileBatchAsync(queryRequest);
|
||||||
|
message.Attachments = queryResult.Files.Select(CloudFileReferenceObject.FromProtoValue).ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task DeleteFileReferencesForMessageAsync(Message message)
|
||||||
|
{
|
||||||
|
var messageResourceId = $"message:{message.Id}";
|
||||||
|
await fileRefs.DeleteResourceReferencesAsync(
|
||||||
|
new DeleteResourceReferencesRequest { ResourceId = messageResourceId }
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -518,68 +601,81 @@ public partial class ChatService(
|
|||||||
public async Task<SyncResponse> GetSyncDataAsync(Guid roomId, long lastSyncTimestamp)
|
public async Task<SyncResponse> GetSyncDataAsync(Guid roomId, long lastSyncTimestamp)
|
||||||
{
|
{
|
||||||
var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
|
var timestamp = Instant.FromUnixTimeMilliseconds(lastSyncTimestamp);
|
||||||
var changes = await db.ChatMessages
|
|
||||||
|
// Get all messages that have been modified since the last sync
|
||||||
|
var modifiedMessages = await db.ChatMessages
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.Include(e => e.Sender)
|
.Include(m => m.Sender)
|
||||||
.Where(m => m.ChatRoomId == roomId)
|
.Where(m => m.ChatRoomId == roomId)
|
||||||
.Where(m => m.UpdatedAt > timestamp || m.DeletedAt > timestamp)
|
.Where(m => m.UpdatedAt > timestamp || m.DeletedAt > timestamp)
|
||||||
.Select(m => new MessageChange
|
|
||||||
{
|
|
||||||
MessageId = m.Id,
|
|
||||||
Action = m.DeletedAt != null ? "delete" : (m.UpdatedAt == m.CreatedAt ? "create" : "update"),
|
|
||||||
Message = m.DeletedAt != null ? null : m,
|
|
||||||
Timestamp = m.DeletedAt ?? m.UpdatedAt
|
|
||||||
})
|
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
// Get messages that need member data
|
var syncMessages = modifiedMessages
|
||||||
var messagesNeedingSenders = changes
|
.Select(message => CreateSyncMessage(message, timestamp))
|
||||||
.Where(c => c.Message != null)
|
.OfType<Message>()
|
||||||
.Select(c => c.Message!)
|
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
// If no messages need senders, return with the latest timestamp from changes
|
|
||||||
if (messagesNeedingSenders.Count <= 0)
|
|
||||||
{
|
|
||||||
var latestTimestamp = changes.Count > 0
|
|
||||||
? changes.Max(c => c.Timestamp)
|
|
||||||
: SystemClock.Instance.GetCurrentInstant();
|
|
||||||
|
|
||||||
return new SyncResponse
|
|
||||||
{
|
|
||||||
Changes = changes,
|
|
||||||
CurrentTimestamp = latestTimestamp
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load member accounts for messages that need them
|
// Load member accounts for messages that need them
|
||||||
var changesMembers = messagesNeedingSenders
|
if (syncMessages.Count > 0)
|
||||||
.Select(m => m.Sender)
|
|
||||||
.DistinctBy(x => x.Id)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
changesMembers = await crs.LoadMemberAccounts(changesMembers);
|
|
||||||
|
|
||||||
// Update sender information for messages that have it
|
|
||||||
foreach (var message in messagesNeedingSenders)
|
|
||||||
{
|
{
|
||||||
var sender = changesMembers.FirstOrDefault(x => x.Id == message.SenderId);
|
var senders = syncMessages
|
||||||
if (sender is not null)
|
.Select(m => m.Sender)
|
||||||
message.Sender = sender;
|
.DistinctBy(s => s.Id)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
senders = await crs.LoadMemberAccounts(senders);
|
||||||
|
|
||||||
|
// Update sender information
|
||||||
|
foreach (var message in syncMessages)
|
||||||
|
{
|
||||||
|
var sender = senders.FirstOrDefault(s => s.Id == message.SenderId);
|
||||||
|
if (sender != null)
|
||||||
|
{
|
||||||
|
message.Sender = sender;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use the latest timestamp from changes, or current time if no changes
|
var latestTimestamp = syncMessages.Count > 0
|
||||||
var latestChangeTimestamp = changes.Count > 0
|
? syncMessages.Max(m => m.UpdatedAt)
|
||||||
? changes.Max(c => c.Timestamp)
|
|
||||||
: SystemClock.Instance.GetCurrentInstant();
|
: SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
return new SyncResponse
|
return new SyncResponse
|
||||||
{
|
{
|
||||||
Changes = changes,
|
Messages = syncMessages.OrderBy(m => m.UpdatedAt).ToList(),
|
||||||
CurrentTimestamp = latestChangeTimestamp
|
CurrentTimestamp = latestTimestamp
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Message? CreateSyncMessage(Message message, Instant sinceTimestamp)
|
||||||
|
{
|
||||||
|
// Handle deleted messages
|
||||||
|
if (message.DeletedAt.HasValue && message.DeletedAt > sinceTimestamp)
|
||||||
|
{
|
||||||
|
return new Message
|
||||||
|
{
|
||||||
|
Id = message.Id,
|
||||||
|
Type = "messages.delete",
|
||||||
|
SenderId = message.SenderId,
|
||||||
|
Sender = message.Sender,
|
||||||
|
ChatRoomId = message.ChatRoomId,
|
||||||
|
ChatRoom = message.ChatRoom,
|
||||||
|
CreatedAt = message.CreatedAt,
|
||||||
|
UpdatedAt = message.DeletedAt.Value
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle updated/edited messages
|
||||||
|
if (message.EditedAt.HasValue)
|
||||||
|
{
|
||||||
|
var syncMessage = message.Clone();
|
||||||
|
syncMessage.Type = "messages.update";
|
||||||
|
return syncMessage;
|
||||||
|
}
|
||||||
|
|
||||||
|
return message;
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<Message> UpdateMessageAsync(
|
public async Task<Message> UpdateMessageAsync(
|
||||||
Message message,
|
Message message,
|
||||||
Dictionary<string, object>? meta = null,
|
Dictionary<string, object>? meta = null,
|
||||||
@@ -589,6 +685,15 @@ public partial class ChatService(
|
|||||||
List<string>? attachmentsId = null
|
List<string>? attachmentsId = null
|
||||||
)
|
)
|
||||||
{
|
{
|
||||||
|
// Only allow editing regular text messages
|
||||||
|
if (message.Type != "text")
|
||||||
|
{
|
||||||
|
throw new InvalidOperationException("Only regular messages can be edited.");
|
||||||
|
}
|
||||||
|
|
||||||
|
var isContentChanged = content is not null && content != message.Content;
|
||||||
|
var isAttachmentsChanged = attachmentsId is not null;
|
||||||
|
|
||||||
if (content is not null)
|
if (content is not null)
|
||||||
message.Content = content;
|
message.Content = content;
|
||||||
|
|
||||||
@@ -603,32 +708,22 @@ public partial class ChatService(
|
|||||||
|
|
||||||
if (attachmentsId is not null)
|
if (attachmentsId is not null)
|
||||||
{
|
{
|
||||||
// Delete existing references for this message
|
await UpdateFileReferencesForMessageAsync(message, attachmentsId);
|
||||||
await fileRefs.DeleteResourceReferencesAsync(
|
|
||||||
new DeleteResourceReferencesRequest { ResourceId = message.ResourceIdentifier }
|
|
||||||
);
|
|
||||||
|
|
||||||
// Create new references for each attachment
|
|
||||||
var createRequest = new CreateReferenceBatchRequest
|
|
||||||
{
|
|
||||||
Usage = ChatFileUsageIdentifier,
|
|
||||||
ResourceId = message.ResourceIdentifier,
|
|
||||||
};
|
|
||||||
createRequest.FilesId.AddRange(attachmentsId);
|
|
||||||
await fileRefs.CreateReferenceBatchAsync(createRequest);
|
|
||||||
|
|
||||||
// Update message attachments by getting files from da
|
|
||||||
var queryRequest = new GetFileBatchRequest();
|
|
||||||
queryRequest.Ids.AddRange(attachmentsId);
|
|
||||||
var queryResult = await filesClient.GetFileBatchAsync(queryRequest);
|
|
||||||
message.Attachments = queryResult.Files.Select(CloudFileReferenceObject.FromProtoValue).ToList();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Mark as edited if content or attachments changed
|
||||||
|
if (isContentChanged || isAttachmentsChanged)
|
||||||
|
{
|
||||||
|
message.EditedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
}
|
||||||
|
|
||||||
|
message.UpdatedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
|
||||||
db.Update(message);
|
db.Update(message);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
// Process link preview in the background if content was updated
|
// Process link preview in the background if content was updated
|
||||||
if (content is not null)
|
if (isContentChanged)
|
||||||
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
|
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
|
||||||
|
|
||||||
if (message.Sender.Account is null)
|
if (message.Sender.Account is null)
|
||||||
@@ -645,18 +740,25 @@ public partial class ChatService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Deletes a message and notifies other chat members
|
/// Soft deletes a message and notifies other chat members
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="message">The message to delete</param>
|
/// <param name="message">The message to delete</param>
|
||||||
public async Task DeleteMessageAsync(Message message)
|
public async Task DeleteMessageAsync(Message message)
|
||||||
{
|
{
|
||||||
// Remove all file references for this message
|
// Only allow deleting regular text messages
|
||||||
var messageResourceId = $"message:{message.Id}";
|
if (message.Type != "text")
|
||||||
await fileRefs.DeleteResourceReferencesAsync(
|
{
|
||||||
new DeleteResourceReferencesRequest { ResourceId = messageResourceId }
|
throw new InvalidOperationException("Only regular messages can be deleted.");
|
||||||
);
|
}
|
||||||
|
|
||||||
db.ChatMessages.Remove(message);
|
// Remove all file references for this message
|
||||||
|
await DeleteFileReferencesForMessageAsync(message);
|
||||||
|
|
||||||
|
// Soft delete by setting DeletedAt timestamp
|
||||||
|
message.DeletedAt = SystemClock.Instance.GetCurrentInstant();
|
||||||
|
message.UpdatedAt = message.DeletedAt.Value;
|
||||||
|
|
||||||
|
db.Update(message);
|
||||||
await db.SaveChangesAsync();
|
await db.SaveChangesAsync();
|
||||||
|
|
||||||
_ = DeliverMessageAsync(
|
_ = DeliverMessageAsync(
|
||||||
@@ -668,23 +770,8 @@ public partial class ChatService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public class MessageChangeAction
|
|
||||||
{
|
|
||||||
public const string Create = "create";
|
|
||||||
public const string Update = "update";
|
|
||||||
public const string Delete = "delete";
|
|
||||||
}
|
|
||||||
|
|
||||||
public class MessageChange
|
|
||||||
{
|
|
||||||
public Guid MessageId { get; set; }
|
|
||||||
public string Action { get; set; } = null!;
|
|
||||||
public Message? Message { get; set; }
|
|
||||||
public Instant Timestamp { get; set; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public class SyncResponse
|
public class SyncResponse
|
||||||
{
|
{
|
||||||
public List<MessageChange> Changes { get; set; } = [];
|
public List<Message> Messages { get; set; } = [];
|
||||||
public Instant CurrentTimestamp { get; set; }
|
public Instant CurrentTimestamp { get; set; }
|
||||||
}
|
}
|
@@ -31,6 +31,33 @@ public class Message : ModelBase, IIdentifiedResource
|
|||||||
[JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!;
|
[JsonIgnore] public ChatRoom ChatRoom { get; set; } = null!;
|
||||||
|
|
||||||
public string ResourceIdentifier => $"message:{Id}";
|
public string ResourceIdentifier => $"message:{Id}";
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Creates a shallow clone of this message for sync operations
|
||||||
|
/// </summary>
|
||||||
|
/// <returns>A new Message instance with copied properties</returns>
|
||||||
|
public Message Clone()
|
||||||
|
{
|
||||||
|
return new Message
|
||||||
|
{
|
||||||
|
Id = Id,
|
||||||
|
Type = Type,
|
||||||
|
Content = Content,
|
||||||
|
Meta = Meta,
|
||||||
|
MembersMentioned = MembersMentioned,
|
||||||
|
Nonce = Nonce,
|
||||||
|
EditedAt = EditedAt,
|
||||||
|
Attachments = Attachments,
|
||||||
|
RepliedMessageId = RepliedMessageId,
|
||||||
|
ForwardedMessageId = ForwardedMessageId,
|
||||||
|
SenderId = SenderId,
|
||||||
|
Sender = Sender,
|
||||||
|
ChatRoomId = ChatRoomId,
|
||||||
|
ChatRoom = ChatRoom,
|
||||||
|
CreatedAt = CreatedAt,
|
||||||
|
UpdatedAt = UpdatedAt
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public enum MessageReactionAttitude
|
public enum MessageReactionAttitude
|
||||||
|
@@ -16,9 +16,9 @@
|
|||||||
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
"DatabasePath": "./Keys/GeoLite2-City.mmdb"
|
||||||
},
|
},
|
||||||
"RealtimeChat": {
|
"RealtimeChat": {
|
||||||
"Endpoint": "https://solar-network-im44o8gq.livekit.cloud",
|
"Endpoint": "https://example.livekit.cloud",
|
||||||
"ApiKey": "",
|
"ApiKey": "APIeY2atwJUogZ1",
|
||||||
"ApiSecret": ""
|
"ApiSecret": "ABEeYpcNpNfKWBh2W0gZtM5xkqRhInhWjHOhv7XVakB"
|
||||||
},
|
},
|
||||||
"Translation": {
|
"Translation": {
|
||||||
"Provider": "Tencent",
|
"Provider": "Tencent",
|
||||||
|
Reference in New Issue
Block a user