Compare commits
	
		
			3 Commits
		
	
	
		
			1baa3109bc
			...
			d1fb0b9b55
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| d1fb0b9b55 | |||
| f1a47fd079 | |||
| 546b65f4c6 | 
| @@ -6,7 +6,7 @@ namespace DysonNetwork.Sphere.Account; | |||||||
|  |  | ||||||
| public class RelationshipService(AppDatabase db, ICacheService cache) | public class RelationshipService(AppDatabase db, ICacheService cache) | ||||||
| { | { | ||||||
|     private const string UserFriendsCacheKeyPrefix = "user:friends:"; |     private const string UserFriendsCacheKeyPrefix = "accounts:friends:"; | ||||||
|      |      | ||||||
|     public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId) |     public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId) | ||||||
|     { |     { | ||||||
|   | |||||||
| @@ -22,8 +22,11 @@ public class ActivityController( | |||||||
|     /// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them. |     /// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them. | ||||||
|     /// </summary> |     /// </summary> | ||||||
|     [HttpGet] |     [HttpGet] | ||||||
|     public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] string? cursor, |     public async Task<ActionResult<List<Activity>>> ListActivities( | ||||||
|         [FromQuery] int take = 20) |         [FromQuery] string? cursor, | ||||||
|  |         [FromQuery] string? filter, | ||||||
|  |         [FromQuery] int take = 20 | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         Instant? cursorTimestamp = null; |         Instant? cursorTimestamp = null; | ||||||
|         if (!string.IsNullOrEmpty(cursor)) |         if (!string.IsNullOrEmpty(cursor)) | ||||||
| @@ -42,6 +45,6 @@ public class ActivityController( | |||||||
|         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); |         HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue); | ||||||
|         return currentUserValue is not Account.Account currentUser |         return currentUserValue is not Account.Account currentUser | ||||||
|             ? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp)) |             ? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp)) | ||||||
|             : Ok(await acts.GetActivities(take, cursorTimestamp, currentUser)); |             : Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter)); | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -42,26 +42,57 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS | |||||||
|         return activities; |         return activities; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|     public async Task<List<Activity>> GetActivities(int take, Instant? cursor, Account.Account currentUser) |     public async Task<List<Activity>> GetActivities( | ||||||
|  |         int take, | ||||||
|  |         Instant? cursor, | ||||||
|  |         Account.Account currentUser, | ||||||
|  |         string? filter = null | ||||||
|  |     ) | ||||||
|     { |     { | ||||||
|         var activities = new List<Activity>(); |         var activities = new List<Activity>(); | ||||||
|         var userFriends = await rels.ListAccountFriends(currentUser); |         var userFriends = await rels.ListAccountFriends(currentUser); | ||||||
|         var userPublishers = await pub.GetUserPublishers(currentUser.Id); |         var userPublishers = await pub.GetUserPublishers(currentUser.Id); | ||||||
|  |  | ||||||
|         var publishersId = userPublishers.Select(e => e.Id).ToList(); |         // Get publishers based on filter | ||||||
|  |         List<Publisher.Publisher>? filteredPublishers = null; | ||||||
|  |         switch (filter) | ||||||
|  |         { | ||||||
|  |             case "subscriptions": | ||||||
|  |                 filteredPublishers = await pub.GetSubscribedPublishers(currentUser.Id); | ||||||
|  |                 break; | ||||||
|  |             case "friends": | ||||||
|  |             { | ||||||
|  |                 filteredPublishers = (await pub.GetUserPublishersBatch(userFriends)) | ||||||
|  |                     .SelectMany(x => x.Value) | ||||||
|  |                     .DistinctBy(x => x.Id) | ||||||
|  |                     .ToList(); | ||||||
|  |                 break; | ||||||
|  |             } | ||||||
|  |             default: | ||||||
|  |                 break; | ||||||
|  |         } | ||||||
|  |  | ||||||
|         // Crunching data |         var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList(); | ||||||
|         var posts = await db.Posts |  | ||||||
|  |         // Build the query based on the filter | ||||||
|  |         var postsQuery = db.Posts | ||||||
|             .Include(e => e.RepliedPost) |             .Include(e => e.RepliedPost) | ||||||
|             .Include(e => e.ForwardedPost) |             .Include(e => e.ForwardedPost) | ||||||
|             .Include(e => e.Categories) |             .Include(e => e.Categories) | ||||||
|             .Include(e => e.Tags) |             .Include(e => e.Tags) | ||||||
|             .Where(e => e.RepliedPostId == null || publishersId.Contains(e.RepliedPost!.PublisherId)) |  | ||||||
|             .Where(p => cursor == null || p.PublishedAt < cursor) |             .Where(p => cursor == null || p.PublishedAt < cursor) | ||||||
|             .OrderByDescending(p => p.PublishedAt) |             .OrderByDescending(p => p.PublishedAt) | ||||||
|             .FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true) |             .AsQueryable(); | ||||||
|  |  | ||||||
|  |         if (filteredPublishersId is not null) | ||||||
|  |             postsQuery = postsQuery.Where(p => filteredPublishersId.Contains(p.PublisherId)); | ||||||
|  |  | ||||||
|  |         // Complete the query with visibility filtering and execute | ||||||
|  |         var posts = await postsQuery | ||||||
|  |             .FilterWithVisibility(currentUser, userFriends, filter is null ? userPublishers : [], isListing: true) | ||||||
|             .Take(take) |             .Take(take) | ||||||
|             .ToListAsync(); |             .ToListAsync(); | ||||||
|  |  | ||||||
|         posts = await ps.LoadPostInfo(posts, currentUser, true); |         posts = await ps.LoadPostInfo(posts, currentUser, true); | ||||||
|  |  | ||||||
|         var postsId = posts.Select(e => e.Id).ToList(); |         var postsId = posts.Select(e => e.Id).ToList(); | ||||||
|   | |||||||
| @@ -1,3 +1,4 @@ | |||||||
|  | using System.Text.RegularExpressions; | ||||||
| using DysonNetwork.Sphere.Account; | using DysonNetwork.Sphere.Account; | ||||||
| using DysonNetwork.Sphere.Chat.Realtime; | using DysonNetwork.Sphere.Chat.Realtime; | ||||||
| using DysonNetwork.Sphere.Connection; | using DysonNetwork.Sphere.Connection; | ||||||
| @@ -7,7 +8,7 @@ using NodaTime; | |||||||
|  |  | ||||||
| namespace DysonNetwork.Sphere.Chat; | namespace DysonNetwork.Sphere.Chat; | ||||||
|  |  | ||||||
| public class ChatService( | public partial class ChatService( | ||||||
|     AppDatabase db, |     AppDatabase db, | ||||||
|     FileReferenceService fileRefService, |     FileReferenceService fileRefService, | ||||||
|     IServiceScopeFactory scopeFactory, |     IServiceScopeFactory scopeFactory, | ||||||
| @@ -17,6 +18,132 @@ public class ChatService( | |||||||
| { | { | ||||||
|     private const string ChatFileUsageIdentifier = "chat"; |     private const string ChatFileUsageIdentifier = "chat"; | ||||||
|  |  | ||||||
|  |     [GeneratedRegex(@"https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]*[-A-Za-z0-9+&@#/%=~_|]")] | ||||||
|  |     private static partial Regex GetLinkRegex(); | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Process link previews for a message in the background | ||||||
|  |     /// This method is designed to be called from a background task | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="message">The message to process link previews for</param> | ||||||
|  |     private async Task ProcessMessageLinkPreviewAsync(Message message) | ||||||
|  |     { | ||||||
|  |         try | ||||||
|  |         { | ||||||
|  |             // Create a new scope for database operations | ||||||
|  |             using var scope = scopeFactory.CreateScope(); | ||||||
|  |             var dbContext = scope.ServiceProvider.GetRequiredService<AppDatabase>(); | ||||||
|  |             var webReader = scope.ServiceProvider.GetRequiredService<Connection.WebReader.WebReaderService>(); | ||||||
|  |             var newChat = scope.ServiceProvider.GetRequiredService<ChatService>(); | ||||||
|  |  | ||||||
|  |             // Preview the links in the message | ||||||
|  |             var updatedMessage = await PreviewMessageLinkAsync(message, webReader); | ||||||
|  |  | ||||||
|  |             // If embeds were added, update the message in the database | ||||||
|  |             if (updatedMessage.Meta != null && | ||||||
|  |                 updatedMessage.Meta.TryGetValue("embeds", out var embeds) && | ||||||
|  |                 embeds is List<Dictionary<string, object>> { Count: > 0 } embedsList) | ||||||
|  |             { | ||||||
|  |                 // Get a fresh copy of the message from the database | ||||||
|  |                 var dbMessage = await dbContext.ChatMessages | ||||||
|  |                     .Where(m => m.Id == message.Id) | ||||||
|  |                     .Include(m => m.Sender) | ||||||
|  |                     .Include(m => m.ChatRoom) | ||||||
|  |                     .FirstOrDefaultAsync(); | ||||||
|  |                 if (dbMessage != null) | ||||||
|  |                 { | ||||||
|  |                     // Update the meta field with the new embeds | ||||||
|  |                     dbMessage.Meta ??= new Dictionary<string, object>(); | ||||||
|  |                     dbMessage.Meta["embeds"] = embedsList; | ||||||
|  |  | ||||||
|  |                     // Save changes to the database | ||||||
|  |                     dbContext.Update(dbMessage); | ||||||
|  |                     await dbContext.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |                     logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews"); | ||||||
|  |  | ||||||
|  |                     // Notify clients of the updated message | ||||||
|  |                     await newChat.DeliverMessageAsync( | ||||||
|  |                         dbMessage, | ||||||
|  |                         dbMessage.Sender, | ||||||
|  |                         dbMessage.ChatRoom, | ||||||
|  |                         WebSocketPacketType.MessageUpdate | ||||||
|  |                     ); | ||||||
|  |                 } | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |         catch (Exception ex) | ||||||
|  |         { | ||||||
|  |             // Log errors but don't rethrow - this is a background task | ||||||
|  |             logger.LogError($"Error processing link previews for message {message.Id}: {ex.Message} {ex.StackTrace}"); | ||||||
|  |         } | ||||||
|  |     } | ||||||
|  |  | ||||||
|  |     /// <summary> | ||||||
|  |     /// Processes a message to find and preview links in its content | ||||||
|  |     /// </summary> | ||||||
|  |     /// <param name="message">The message to process</param> | ||||||
|  |     /// <param name="webReader">The web reader service</param> | ||||||
|  |     /// <returns>The message with link previews added to its meta data</returns> | ||||||
|  |     public async Task<Message> PreviewMessageLinkAsync(Message message, | ||||||
|  |         Connection.WebReader.WebReaderService? webReader = null) | ||||||
|  |     { | ||||||
|  |         if (string.IsNullOrEmpty(message.Content)) | ||||||
|  |             return message; | ||||||
|  |  | ||||||
|  |         // Find all URLs in the content | ||||||
|  |         var matches = GetLinkRegex().Matches(message.Content); | ||||||
|  |  | ||||||
|  |         if (matches.Count == 0) | ||||||
|  |             return message; | ||||||
|  |  | ||||||
|  |         // Initialize meta dictionary if null | ||||||
|  |         message.Meta ??= new Dictionary<string, object>(); | ||||||
|  |  | ||||||
|  |         // Initialize the embeds' array if it doesn't exist | ||||||
|  |         if (!message.Meta.TryGetValue("embeds", out var existingEmbeds) || | ||||||
|  |             existingEmbeds is not List<Dictionary<string, object>>) | ||||||
|  |         { | ||||||
|  |             message.Meta["embeds"] = new List<Dictionary<string, object>>(); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"]; | ||||||
|  |         webReader ??= scopeFactory.CreateScope().ServiceProvider | ||||||
|  |             .GetRequiredService<Connection.WebReader.WebReaderService>(); | ||||||
|  |  | ||||||
|  |         // Process up to 3 links to avoid excessive processing | ||||||
|  |         var processedLinks = 0; | ||||||
|  |         foreach (Match match in matches) | ||||||
|  |         { | ||||||
|  |             if (processedLinks >= 3) | ||||||
|  |                 break; | ||||||
|  |  | ||||||
|  |             var url = match.Value; | ||||||
|  |  | ||||||
|  |             try | ||||||
|  |             { | ||||||
|  |                 // Check if this URL is already in the embed list | ||||||
|  |                 var urlAlreadyEmbedded = embeds.Any(e => | ||||||
|  |                     e.TryGetValue("Url", out var originalUrl) && (string)originalUrl == url); | ||||||
|  |                 if (urlAlreadyEmbedded) | ||||||
|  |                     continue; | ||||||
|  |  | ||||||
|  |                 // Preview the link | ||||||
|  |                 var linkEmbed = await webReader.GetLinkPreviewAsync(url); | ||||||
|  |                 embeds.Add(linkEmbed.ToDictionary()); | ||||||
|  |                 processedLinks++; | ||||||
|  |             } | ||||||
|  |             catch | ||||||
|  |             { | ||||||
|  |                 // ignored | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         message.Meta["embeds"] = embeds; | ||||||
|  |  | ||||||
|  |         return message; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     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(); | ||||||
| @@ -57,6 +184,9 @@ public class ChatService( | |||||||
|             } |             } | ||||||
|         }); |         }); | ||||||
|  |  | ||||||
|  |         // Process link preview in the background to avoid delaying message sending | ||||||
|  |         _ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message)); | ||||||
|  |  | ||||||
|         message.Sender = sender; |         message.Sender = sender; | ||||||
|         message.ChatRoom = room; |         message.ChatRoom = room; | ||||||
|         return message; |         return message; | ||||||
| @@ -293,7 +423,7 @@ public class ChatService( | |||||||
|         { |         { | ||||||
|             Type = "call.ended", |             Type = "call.ended", | ||||||
|             ChatRoomId = call.RoomId, |             ChatRoomId = call.RoomId, | ||||||
|             SenderId = sender.Id, |             SenderId = call.SenderId, | ||||||
|             Meta = new Dictionary<string, object> |             Meta = new Dictionary<string, object> | ||||||
|             { |             { | ||||||
|                 { "call_id", call.Id }, |                 { "call_id", call.Id }, | ||||||
| @@ -389,6 +519,10 @@ public class ChatService( | |||||||
|         db.Update(message); |         db.Update(message); | ||||||
|         await db.SaveChangesAsync(); |         await db.SaveChangesAsync(); | ||||||
|  |  | ||||||
|  |         // Process link preview in the background if content was updated | ||||||
|  |         if (content is not null) | ||||||
|  |             _ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message)); | ||||||
|  |  | ||||||
|         _ = DeliverMessageAsync( |         _ = DeliverMessageAsync( | ||||||
|             message, |             message, | ||||||
|             message.Sender, |             message.Sender, | ||||||
|   | |||||||
| @@ -342,7 +342,7 @@ public partial class PostService( | |||||||
|             // If embeds were added, update the post in the database |             // If embeds were added, update the post in the database | ||||||
|             if (updatedPost.Meta != null && |             if (updatedPost.Meta != null && | ||||||
|                 updatedPost.Meta.TryGetValue("embeds", out var embeds) && |                 updatedPost.Meta.TryGetValue("embeds", out var embeds) && | ||||||
|                 embeds is List<EmbeddableBase> { Count: > 0 } embedsList) |                 embeds is List<Dictionary<string, object>> { Count: > 0 } embedsList) | ||||||
|             { |             { | ||||||
|                 // Get a fresh copy of the post from the database |                 // Get a fresh copy of the post from the database | ||||||
|                 var dbPost = await dbContext.Posts.FindAsync(post.Id); |                 var dbPost = await dbContext.Posts.FindAsync(post.Id); | ||||||
| @@ -659,6 +659,5 @@ public static class PostQueryExtensions | |||||||
|             .Where(e => e.Visibility != PostVisibility.Friends || |             .Where(e => e.Visibility != PostVisibility.Friends || | ||||||
|                         (e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) || |                         (e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) || | ||||||
|                         publishersId.Contains(e.PublisherId)); |                         publishersId.Contains(e.PublisherId)); | ||||||
|         ; |  | ||||||
|     } |     } | ||||||
| } | } | ||||||
| @@ -9,6 +9,7 @@ namespace DysonNetwork.Sphere.Publisher; | |||||||
| public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache) | public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache) | ||||||
| { | { | ||||||
|     private const string UserPublishersCacheKey = "accounts:{0}:publishers"; |     private const string UserPublishersCacheKey = "accounts:{0}:publishers"; | ||||||
|  |     private const string UserPublishersBatchCacheKey = "accounts:batch:{0}:publishers"; | ||||||
|  |  | ||||||
|     public async Task<List<Publisher>> GetUserPublishers(Guid userId) |     public async Task<List<Publisher>> GetUserPublishers(Guid userId) | ||||||
|     { |     { | ||||||
| @@ -34,6 +35,86 @@ public class PublisherService(AppDatabase db, FileReferenceService fileRefServic | |||||||
|         return publishers; |         return publishers; | ||||||
|     } |     } | ||||||
|  |  | ||||||
|  |     public async Task<Dictionary<Guid, List<Publisher>>> GetUserPublishersBatch(List<Guid> userIds) | ||||||
|  |     { | ||||||
|  |         var result = new Dictionary<Guid, List<Publisher>>(); | ||||||
|  |         var missingIds = new List<Guid>(); | ||||||
|  |  | ||||||
|  |         // Try to get publishers from cache for each user | ||||||
|  |         foreach (var userId in userIds) | ||||||
|  |         { | ||||||
|  |             var cacheKey = string.Format(UserPublishersCacheKey, userId); | ||||||
|  |             var publishers = await cache.GetAsync<List<Publisher>>(cacheKey); | ||||||
|  |             if (publishers != null) | ||||||
|  |                 result[userId] = publishers; | ||||||
|  |             else | ||||||
|  |                 missingIds.Add(userId); | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         if (missingIds.Count <= 0) return result; | ||||||
|  |         { | ||||||
|  |             // Fetch missing data from database | ||||||
|  |             var publisherMembers = await db.PublisherMembers | ||||||
|  |                 .Where(p => missingIds.Contains(p.AccountId)) | ||||||
|  |                 .Select(p => new { p.AccountId, p.PublisherId }) | ||||||
|  |                 .ToListAsync(); | ||||||
|  |  | ||||||
|  |             var publisherIds = publisherMembers.Select(p => p.PublisherId).Distinct().ToList(); | ||||||
|  |             var publishers = await db.Publishers | ||||||
|  |                 .Where(p => publisherIds.Contains(p.Id)) | ||||||
|  |                 .ToListAsync(); | ||||||
|  |  | ||||||
|  |             // Group publishers by user id | ||||||
|  |             foreach (var userId in missingIds) | ||||||
|  |             { | ||||||
|  |                 var userPublisherIds = publisherMembers | ||||||
|  |                     .Where(p => p.AccountId == userId) | ||||||
|  |                     .Select(p => p.PublisherId) | ||||||
|  |                     .ToList(); | ||||||
|  |  | ||||||
|  |                 var userPublishers = publishers | ||||||
|  |                     .Where(p => userPublisherIds.Contains(p.Id)) | ||||||
|  |                     .ToList(); | ||||||
|  |  | ||||||
|  |                 result[userId] = userPublishers; | ||||||
|  |  | ||||||
|  |                 // Cache individual results | ||||||
|  |                 var cacheKey = string.Format(UserPublishersCacheKey, userId); | ||||||
|  |                 await cache.SetAsync(cacheKey, userPublishers, TimeSpan.FromMinutes(5)); | ||||||
|  |             } | ||||||
|  |         } | ||||||
|  |  | ||||||
|  |         return result; | ||||||
|  |     } | ||||||
|  |      | ||||||
|  |      | ||||||
|  |  | ||||||
|  |     private const string SubscribedPublishersCacheKey = "accounts:{0}:subscribed-publishers"; | ||||||
|  |      | ||||||
|  |     public async Task<List<Publisher>> GetSubscribedPublishers(Guid userId) | ||||||
|  |     { | ||||||
|  |         var cacheKey = string.Format(SubscribedPublishersCacheKey, userId); | ||||||
|  |  | ||||||
|  |         // Try to get publishers from the cache first | ||||||
|  |         var publishers = await cache.GetAsync<List<Publisher>>(cacheKey); | ||||||
|  |         if (publishers is not null) | ||||||
|  |             return publishers; | ||||||
|  |  | ||||||
|  |         // If not in cache, fetch from a database | ||||||
|  |         var publishersId = await db.PublisherSubscriptions | ||||||
|  |             .Where(p => p.AccountId == userId) | ||||||
|  |             .Select(p => p.PublisherId) | ||||||
|  |             .ToListAsync(); | ||||||
|  |         publishers = await db.Publishers | ||||||
|  |             .Where(p => publishersId.Contains(p.Id)) | ||||||
|  |             .ToListAsync(); | ||||||
|  |  | ||||||
|  |         // Store in a cache for 5 minutes | ||||||
|  |         await cache.SetAsync(cacheKey, publishers, TimeSpan.FromMinutes(5)); | ||||||
|  |  | ||||||
|  |         return publishers; | ||||||
|  |     } | ||||||
|  |  | ||||||
|     private const string PublisherMembersCacheKey = "publishers:{0}:members"; |     private const string PublisherMembersCacheKey = "publishers:{0}:members"; | ||||||
|  |  | ||||||
|     public async Task<List<PublisherMember>> GetPublisherMembers(Guid publisherId) |     public async Task<List<PublisherMember>> GetPublisherMembers(Guid publisherId) | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user