diff --git a/DysonNetwork.Sphere/Activity/ActivityService.cs b/DysonNetwork.Sphere/Activity/ActivityService.cs index 66e56fc..b8a1ff5 100644 --- a/DysonNetwork.Sphere/Activity/ActivityService.cs +++ b/DysonNetwork.Sphere/Activity/ActivityService.cs @@ -1,7 +1,7 @@ using DysonNetwork.Shared.Proto; -using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.Discovery; using DysonNetwork.Sphere.Post; +using DysonNetwork.Sphere.WebReader; using Microsoft.EntityFrameworkCore; using NodaTime; @@ -27,58 +27,28 @@ public class ActivityService( public async Task> GetActivitiesForAnyone( int take, Instant? cursor, - HashSet? debugInclude = null - ) + HashSet? debugInclude = null) { var activities = new List(); debugInclude ??= new HashSet(); + // Add realm discovery if needed if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2)) { - var realms = await ds.GetCommunityRealmAsync(null, 5, 0, true); - if (realms.Count > 0) - { - activities.Add(new DiscoveryActivity( - realms.Select(x => new DiscoveryItem("realm", x)).ToList() - ).ToActivity()); - } + var realmActivity = await GetRealmDiscoveryActivity(); + if (realmActivity != null) + activities.Add(realmActivity); } + // Add article discovery if needed if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2) { - var recentFeedIds = await db.WebArticles - .GroupBy(a => a.FeedId) - .OrderByDescending(g => g.Max(a => a.PublishedAt)) - .Take(10) // Get recent 10 distinct feeds - .Select(g => g.Key) - .ToListAsync(); - - // For each feed, get one random article - var recentArticles = new List(); - var random = new Random(); - - foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next())) - { - var article = await db.WebArticles - .Include(a => a.Feed) - .Where(a => a.FeedId == feedId) - .OrderBy(_ => EF.Functions.Random()) - .FirstOrDefaultAsync(); - - if (article == null) continue; - recentArticles.Add(article); - if (recentArticles.Count >= 5) break; // Limit to 5 articles - } - - if (recentArticles.Count > 0) - { - activities.Add(new DiscoveryActivity( - recentArticles.Select(x => new DiscoveryItem("article", x)).ToList() - ).ToActivity()); - } + var articleActivity = await GetArticleDiscoveryActivity(); + if (articleActivity != null) + activities.Add(articleActivity); } - // Fetch a larger batch of recent posts to rank + // Get and process posts var postsQuery = db.Posts .Include(e => e.RepliedPost) .Include(e => e.ForwardedPost) @@ -89,31 +59,13 @@ public class ActivityService( .Where(p => cursor == null || p.PublishedAt < cursor) .OrderByDescending(p => p.PublishedAt) .FilterWithVisibility(null, [], [], isListing: true) - .Take(take * 5); // Fetch more posts to have a good pool for ranking + .Take(take * 5); - var posts = await postsQuery.ToListAsync(); - posts = await ps.LoadPostInfo(posts, null, true); - - var postsId = posts.Select(e => e.Id).ToList(); - var reactionMaps = await ps.GetPostReactionMapBatch(postsId); - foreach (var post in posts) - post.ReactionsCount = - reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary(); - - // Rank and sort - // TODO: This feature is disabled for now - /* - var now = SystemClock.Instance.GetCurrentInstant(); - var rankedPosts = posts - .Select(p => new { Post = p, Rank = CalculateHotRank(p, now) }) - .OrderByDescending(x => x.Rank) - .Select(x => x.Post) - .Take(take) - .ToList(); */ - - // Formatting data - foreach (var post in posts) - activities.Add(post.ToActivity()); + var posts = await GetAndProcessPosts(postsQuery); + posts = RankPosts(posts, take); + + // Add posts to activities + activities.AddRange(posts.Select(post => post.ToActivity())); if (activities.Count == 0) activities.Add(Activity.Empty()); @@ -126,135 +78,74 @@ public class ActivityService( Instant? cursor, Account currentUser, string? filter = null, - HashSet? debugInclude = null - ) + HashSet? debugInclude = null) { var activities = new List(); + debugInclude ??= []; + + // Get user's friends and publishers var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest { AccountId = currentUser.Id }); var userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList(); var userPublishers = await pub.GetUserPublishers(Guid.Parse(currentUser.Id)); - debugInclude ??= []; + // Add discovery activities if no specific filter is applied if (string.IsNullOrEmpty(filter)) { + // Add realm discovery if needed if (cursor == null && (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2)) { - var realms = await ds.GetCommunityRealmAsync(null, 5, 0, true); - if (realms.Count > 0) - { - activities.Add(new DiscoveryActivity( - realms.Select(x => new DiscoveryItem("realm", x)).ToList() - ).ToActivity()); - } + var realmActivity = await GetRealmDiscoveryActivity(); + if (realmActivity != null) + activities.Add(realmActivity); } + // Add publisher discovery if needed if (cursor == null && (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2)) { - var popularPublishers = await GetPopularPublishers(5); - if (popularPublishers.Count > 0) - { - activities.Add(new DiscoveryActivity( - popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList() - ).ToActivity()); - } + var publisherActivity = await GetPublisherDiscoveryActivity(); + if (publisherActivity != null) + activities.Add(publisherActivity); } + // Add article discovery if needed if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2) { - var recentFeedIds = await db.WebArticles - .GroupBy(a => a.FeedId) - .OrderByDescending(g => g.Max(a => a.PublishedAt)) - .Take(10) // Get recent 10 distinct feeds - .Select(g => g.Key) - .ToListAsync(); - - // For each feed, get one random article - var recentArticles = new List(); - var random = new Random(); - - foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next())) - { - var article = await db.WebArticles - .Include(a => a.Feed) - .Where(a => a.FeedId == feedId) - .OrderBy(_ => EF.Functions.Random()) - .FirstOrDefaultAsync(); - - if (article == null) continue; - recentArticles.Add(article); - if (recentArticles.Count >= 5) break; // Limit to 5 articles - } - - if (recentArticles.Count > 0) - { - activities.Add(new DiscoveryActivity( - recentArticles.Select(x => new DiscoveryItem("article", x)).ToList() - ).ToActivity()); - } + var articleActivity = await GetArticleDiscoveryActivity(); + if (articleActivity != null) + activities.Add(articleActivity); } } // Get publishers based on filter - var filteredPublishers = filter switch - { - "subscriptions" => await pub.GetSubscribedPublishers(Guid.Parse(currentUser.Id)), - "friends" => (await pub.GetUserPublishersBatch(userFriends)).SelectMany(x => x.Value) - .DistinctBy(x => x.Id) - .ToList(), - _ => null - }; - + var filteredPublishers = await GetFilteredPublishers(filter, currentUser, userFriends); var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList(); - // Build the query based on the filter - var postsQuery = db.Posts - .Include(e => e.RepliedPost) - .Include(e => e.ForwardedPost) - .Include(e => e.Categories) - .Include(e => e.Tags) - .Include(e => e.Realm) - .Where(e => e.RepliedPostId == null) - .Where(p => cursor == null || p.PublishedAt < cursor) - .OrderByDescending(p => p.PublishedAt) - .AsQueryable(); + // Build and execute the posts query + var postsQuery = BuildPostsQuery(cursor, filteredPublishersId); + + // Apply visibility filtering and execute + postsQuery = postsQuery + .FilterWithVisibility( + currentUser, + userFriends, + filter is null ? userPublishers : [], + isListing: true) + .Take(take * 5); - 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 * 5) // Fetch more posts to have a good pool for ranking - .ToListAsync(); - - posts = await ps.LoadPostInfo(posts, currentUser, true); - - var postsId = posts.Select(e => e.Id).ToList(); - var reactionMaps = await ps.GetPostReactionMapBatch(postsId); - foreach (var post in posts) - { - post.ReactionsCount = - reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary(); - - // Track view for each post in the feed - await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString()); - } - - // Rank and sort - // TODO: This feature is disabled for now - /* - var now = SystemClock.Instance.GetCurrentInstant(); - var rankedPosts = posts - .Select(p => new { Post = p, Rank = CalculateHotRank(p, now) }) - .OrderByDescending(x => x.Rank) - .Select(x => x.Post) - .Take(take) - .ToList(); */ - - // Formatting data + // Get, process and rank posts + var posts = await GetAndProcessPosts( + postsQuery, + currentUser, + userFriends, + userPublishers, + trackViews: true); + + posts = RankPosts(posts, take); + + // Add posts to activities activities.AddRange(posts.Select(post => post.ToActivity())); if (activities.Count == 0) @@ -263,11 +154,20 @@ public class ActivityService( return activities; } - private static double CalculatePopularity(List posts) + private static List RankPosts(List posts, int take) { - var score = posts.Sum(p => p.Upvotes - p.Downvotes); - var postCount = posts.Count; - return score + postCount; + // TODO: This feature is disabled for now + // Uncomment and implement when ready + /* + var now = SystemClock.Instance.GetCurrentInstant(); + return posts + .Select(p => new { Post = p, Rank = CalculateHotRank(p, now) }) + .OrderByDescending(x => x.Rank) + .Select(x => x.Post) + .Take(take) + .ToList(); + */ + return posts.Take(take).ToList(); } private async Task> GetPopularPublishers(int take) @@ -282,7 +182,7 @@ public class ActivityService( var publisherIds = posts.Select(p => p.PublisherId).Distinct().ToList(); var publishers = await db.Publishers.Where(p => publisherIds.Contains(p.Id)).ToListAsync(); - var rankedPublishers = publishers + return publishers .Select(p => new { Publisher = p, @@ -292,7 +192,121 @@ public class ActivityService( .Select(x => x.Publisher) .Take(take) .ToList(); + } - return rankedPublishers; + private async Task GetRealmDiscoveryActivity(int count = 5) + { + var realms = await ds.GetCommunityRealmAsync(null, count, 0, true); + return realms.Count > 0 + ? new DiscoveryActivity(realms.Select(x => new DiscoveryItem("realm", x)).ToList()).ToActivity() + : null; + } + + private async Task GetPublisherDiscoveryActivity(int count = 5) + { + var popularPublishers = await GetPopularPublishers(count); + return popularPublishers.Count > 0 + ? new DiscoveryActivity(popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList()).ToActivity() + : null; + } + + private async Task GetArticleDiscoveryActivity(int count = 5, int feedSampleSize = 10) + { + var recentFeedIds = await db.WebArticles + .GroupBy(a => a.FeedId) + .OrderByDescending(g => g.Max(a => a.PublishedAt)) + .Take(feedSampleSize) + .Select(g => g.Key) + .ToListAsync(); + + var recentArticles = new List(); + var random = new Random(); + + foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next())) + { + var article = await db.WebArticles + .Include(a => a.Feed) + .Where(a => a.FeedId == feedId) + .OrderBy(_ => EF.Functions.Random()) + .FirstOrDefaultAsync(); + + if (article == null) continue; + recentArticles.Add(article); + if (recentArticles.Count >= count) break; + } + + return recentArticles.Count > 0 + ? new DiscoveryActivity(recentArticles.Select(x => new DiscoveryItem("article", x)).ToList()).ToActivity() + : null; + } + + private async Task> GetAndProcessPosts( + IQueryable baseQuery, + Account? currentUser = null, + List? userFriends = null, + List? userPublishers = null, + bool trackViews = true) + { + var posts = await baseQuery.ToListAsync(); + posts = await ps.LoadPostInfo(posts, currentUser, true); + + var postsId = posts.Select(e => e.Id).ToList(); + var reactionMaps = await ps.GetPostReactionMapBatch(postsId); + + foreach (var post in posts) + { + post.ReactionsCount = reactionMaps.GetValueOrDefault(post.Id, new Dictionary()); + + if (trackViews && currentUser != null) + { + await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString()); + } + } + + return posts; + } + + private IQueryable BuildPostsQuery(Instant? cursor, List? filteredPublishersId = null) + { + var query = db.Posts + .Include(e => e.RepliedPost) + .Include(e => e.ForwardedPost) + .Include(e => e.Categories) + .Include(e => e.Tags) + .Include(e => e.Realm) + .Where(e => e.RepliedPostId == null) + .Where(p => cursor == null || p.PublishedAt < cursor) + .OrderByDescending(p => p.PublishedAt) + .AsQueryable(); + + if (filteredPublishersId != null && filteredPublishersId.Any()) + { + query = query.Where(p => filteredPublishersId.Contains(p.PublisherId)); + } + + return query; + } + + private async Task?> GetFilteredPublishers( + string? filter, + Account currentUser, + List userFriends) + { + return filter?.ToLower() switch + { + "subscriptions" => await pub.GetSubscribedPublishers(Guid.Parse(currentUser.Id)), + "friends" => (await pub.GetUserPublishersBatch(userFriends)) + .SelectMany(x => x.Value) + .DistinctBy(x => x.Id) + .ToList(), + _ => null + }; + } + + private static double CalculatePopularity(List posts) + { + var score = posts.Sum(p => p.Upvotes - p.Downvotes); + var postCount = posts.Count; + return score + postCount; } }