368 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
			
		
		
	
	
			368 lines
		
	
	
		
			13 KiB
		
	
	
	
		
			C#
		
	
	
	
	
	
using DysonNetwork.Shared.Models;
 | 
						|
using DysonNetwork.Shared.Proto;
 | 
						|
using DysonNetwork.Shared.Registry;
 | 
						|
using DysonNetwork.Sphere.Discovery;
 | 
						|
using DysonNetwork.Sphere.Post;
 | 
						|
using DysonNetwork.Sphere.WebReader;
 | 
						|
using Microsoft.EntityFrameworkCore;
 | 
						|
using NodaTime;
 | 
						|
 | 
						|
namespace DysonNetwork.Sphere.Activity;
 | 
						|
 | 
						|
public class ActivityService(
 | 
						|
    AppDatabase db,
 | 
						|
    Publisher.PublisherService pub,
 | 
						|
    Post.PostService ps,
 | 
						|
    RemoteRealmService rs,
 | 
						|
    DiscoveryService ds,
 | 
						|
    AccountService.AccountServiceClient accounts
 | 
						|
)
 | 
						|
{
 | 
						|
    private static double CalculateHotRank(SnPost post, Instant now)
 | 
						|
    {
 | 
						|
        var performanceScore = post.Upvotes - post.Downvotes + post.RepliesCount + (int)post.AwardedScore / 10;
 | 
						|
        var postTime = post.PublishedAt ?? post.CreatedAt;
 | 
						|
        var timeScore = (now - postTime).TotalMinutes;
 | 
						|
        // Add 1 to score to prevent negative results for posts with more downvotes than upvotes
 | 
						|
        // Time dominates ranking, performance adjusts within similar timeframes.
 | 
						|
        var performanceWeight = performanceScore + 5;
 | 
						|
        // Normalize time influence since average post interval ~60 minutes
 | 
						|
        var normalizedTime = timeScore / 60.0; 
 | 
						|
        return performanceWeight / Math.Pow(normalizedTime + 1.0, 1.2);
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<List<SnActivity>> GetActivitiesForAnyone(
 | 
						|
        int take,
 | 
						|
        Instant? cursor,
 | 
						|
        HashSet<string>? debugInclude = null)
 | 
						|
    {
 | 
						|
        var activities = new List<SnActivity>();
 | 
						|
        debugInclude ??= new HashSet<string>();
 | 
						|
 | 
						|
        // Get and process posts
 | 
						|
        var publicRealms = await rs.GetPublicRealms();
 | 
						|
        var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
 | 
						|
 | 
						|
        var postsQuery = BuildPostsQuery(cursor, null, publicRealmIds)
 | 
						|
            .FilterWithVisibility(null, [], [], isListing: true)
 | 
						|
            .Take(take * 5);
 | 
						|
 | 
						|
        var posts = await GetAndProcessPosts(postsQuery);
 | 
						|
        await LoadPostsRealmsAsync(posts, rs);
 | 
						|
        posts = RankPosts(posts, take);
 | 
						|
 | 
						|
        var interleaved = new List<SnActivity>();
 | 
						|
        var random = new Random();
 | 
						|
        foreach (var post in posts)
 | 
						|
        {
 | 
						|
            // Randomly insert a discovery activity before some posts
 | 
						|
            if (random.NextDouble() < 0.15)
 | 
						|
            {
 | 
						|
                var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor);
 | 
						|
                if (discovery != null)
 | 
						|
                    interleaved.Add(discovery);
 | 
						|
            }
 | 
						|
 | 
						|
            interleaved.Add(post.ToActivity());
 | 
						|
        }
 | 
						|
 | 
						|
        activities.AddRange(interleaved);
 | 
						|
 | 
						|
        if (activities.Count == 0)
 | 
						|
            activities.Add(SnActivity.Empty());
 | 
						|
 | 
						|
        return activities;
 | 
						|
    }
 | 
						|
 | 
						|
    public async Task<List<SnActivity>> GetActivities(
 | 
						|
        int take,
 | 
						|
        Instant? cursor,
 | 
						|
        Account currentUser,
 | 
						|
        string? filter = null,
 | 
						|
        HashSet<string>? debugInclude = null)
 | 
						|
    {
 | 
						|
        var activities = new List<SnActivity>();
 | 
						|
        debugInclude ??= new HashSet<string>();
 | 
						|
 | 
						|
        // 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));
 | 
						|
 | 
						|
        // Get publishers based on filter
 | 
						|
        var filteredPublishers = await GetFilteredPublishers(filter, currentUser, userFriends);
 | 
						|
        var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList();
 | 
						|
 | 
						|
        var userRealms = await rs.GetUserRealms(Guid.Parse(currentUser.Id));
 | 
						|
 | 
						|
        // Build and execute the post query
 | 
						|
        var postsQuery = BuildPostsQuery(cursor, filteredPublishersId, userRealms);
 | 
						|
 | 
						|
        // Apply visibility filtering and execute
 | 
						|
        postsQuery = postsQuery
 | 
						|
            .FilterWithVisibility(
 | 
						|
                currentUser,
 | 
						|
                userFriends,
 | 
						|
                filter is null ? userPublishers : [],
 | 
						|
                isListing: true)
 | 
						|
            .Take(take * 5);
 | 
						|
 | 
						|
        // Get, process and rank posts
 | 
						|
        var posts = await GetAndProcessPosts(
 | 
						|
            postsQuery,
 | 
						|
            currentUser,
 | 
						|
            trackViews: true);
 | 
						|
 | 
						|
        await LoadPostsRealmsAsync(posts, rs);
 | 
						|
 | 
						|
        posts = RankPosts(posts, take);
 | 
						|
 | 
						|
        var interleaved = new List<SnActivity>();
 | 
						|
        var random = new Random();
 | 
						|
        foreach (var post in posts)
 | 
						|
        {
 | 
						|
            if (random.NextDouble() < 0.15)
 | 
						|
            {
 | 
						|
                var discovery = await MaybeGetDiscoveryActivity(debugInclude, cursor: cursor);
 | 
						|
                if (discovery != null)
 | 
						|
                    interleaved.Add(discovery);
 | 
						|
            }
 | 
						|
 | 
						|
            interleaved.Add(post.ToActivity());
 | 
						|
        }
 | 
						|
 | 
						|
        activities.AddRange(interleaved);
 | 
						|
 | 
						|
        if (activities.Count == 0)
 | 
						|
            activities.Add(SnActivity.Empty());
 | 
						|
 | 
						|
        return activities;
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<SnActivity?> MaybeGetDiscoveryActivity(HashSet<string> debugInclude, Instant? cursor)
 | 
						|
    {
 | 
						|
        if (cursor != null) return null;
 | 
						|
        var options = new List<Func<Task<SnActivity?>>>();
 | 
						|
        if (debugInclude.Contains("realms") || Random.Shared.NextDouble() < 0.2)
 | 
						|
            options.Add(() => GetRealmDiscoveryActivity());
 | 
						|
        if (debugInclude.Contains("publishers") || Random.Shared.NextDouble() < 0.2)
 | 
						|
            options.Add(() => GetPublisherDiscoveryActivity());
 | 
						|
        if (debugInclude.Contains("articles") || Random.Shared.NextDouble() < 0.2)
 | 
						|
            options.Add(() => GetArticleDiscoveryActivity());
 | 
						|
        if (debugInclude.Contains("shuffledPosts") || Random.Shared.NextDouble() < 0.2)
 | 
						|
            options.Add(() => GetShuffledPostsActivity());
 | 
						|
        if (options.Count == 0) return null;
 | 
						|
        var random = new Random();
 | 
						|
        var pick = options[random.Next(options.Count)];
 | 
						|
        return await pick();
 | 
						|
    }
 | 
						|
 | 
						|
    private static List<SnPost> RankPosts(List<SnPost> posts, int take)
 | 
						|
    {
 | 
						|
        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<List<Shared.Models.SnPublisher>> GetPopularPublishers(int take)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var recent = now.Minus(Duration.FromDays(7));
 | 
						|
 | 
						|
        var posts = await db.Posts
 | 
						|
            .Where(p => p.PublishedAt > recent)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var publisherIds = posts.Select(p => p.PublisherId).Distinct().ToList();
 | 
						|
        var publishers = await db.Publishers.Where(p => publisherIds.Contains(p.Id)).ToListAsync();
 | 
						|
 | 
						|
        return publishers
 | 
						|
            .Select(p => new
 | 
						|
            {
 | 
						|
                Publisher = p,
 | 
						|
                Rank = CalculatePopularity(posts.Where(post => post.PublisherId == p.Id).ToList())
 | 
						|
            })
 | 
						|
            .OrderByDescending(x => x.Rank)
 | 
						|
            .Select(x => x.Publisher)
 | 
						|
            .Take(take)
 | 
						|
            .ToList();
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<SnActivity?> 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<SnActivity?> 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<SnActivity?> GetShuffledPostsActivity(int count = 5)
 | 
						|
    {
 | 
						|
        var publicRealms = await rs.GetPublicRealms();
 | 
						|
        var publicRealmIds = publicRealms.Select(r => r.Id).ToList();
 | 
						|
 | 
						|
        var postsQuery = db.Posts
 | 
						|
            .Include(p => p.Categories)
 | 
						|
            .Include(p => p.Tags)
 | 
						|
            .Where(p => p.RepliedPostId == null)
 | 
						|
            .Where(p => p.RealmId == null || publicRealmIds.Contains(p.RealmId.Value))
 | 
						|
            .OrderBy(_ => EF.Functions.Random())
 | 
						|
            .Take(count);
 | 
						|
 | 
						|
        var posts = await GetAndProcessPosts(postsQuery, trackViews: false);
 | 
						|
        await LoadPostsRealmsAsync(posts, rs);
 | 
						|
 | 
						|
        return posts.Count == 0
 | 
						|
            ? null
 | 
						|
            : new DiscoveryActivity(posts.Select(x => new DiscoveryItem("post", x)).ToList()).ToActivity();
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<SnActivity?> GetArticleDiscoveryActivity(int count = 5, int feedSampleSize = 10)
 | 
						|
    {
 | 
						|
        var now = SystemClock.Instance.GetCurrentInstant();
 | 
						|
        var today = now.InZone(DateTimeZone.Utc).Date;
 | 
						|
        var todayBegin = today.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
						|
        var todayEnd = today.PlusDays(1).AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
 | 
						|
        var recentFeedIds = await db.WebArticles
 | 
						|
            .Where(a => a.CreatedAt >= todayBegin && a.CreatedAt < todayEnd)
 | 
						|
            .GroupBy(a => a.FeedId)
 | 
						|
            .OrderByDescending(g => g.Max(a => a.PublishedAt))
 | 
						|
            .Take(feedSampleSize)
 | 
						|
            .Select(g => g.Key)
 | 
						|
            .ToListAsync();
 | 
						|
 | 
						|
        var recentArticles = new List<WebArticle>();
 | 
						|
        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<List<SnPost>> GetAndProcessPosts(
 | 
						|
        IQueryable<SnPost> baseQuery,
 | 
						|
        Account? currentUser = 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<string, int>());
 | 
						|
 | 
						|
            if (trackViews && currentUser != null)
 | 
						|
            {
 | 
						|
                await ps.IncreaseViewCount(post.Id, currentUser.Id.ToString());
 | 
						|
            }
 | 
						|
        }
 | 
						|
 | 
						|
        return posts;
 | 
						|
    }
 | 
						|
 | 
						|
    private IQueryable<SnPost> BuildPostsQuery(
 | 
						|
        Instant? cursor,
 | 
						|
        List<Guid>? filteredPublishersId = null,
 | 
						|
        List<Guid>? userRealms = null
 | 
						|
    )
 | 
						|
    {
 | 
						|
        var query = db.Posts
 | 
						|
            .Include(e => e.RepliedPost)
 | 
						|
            .Include(e => e.ForwardedPost)
 | 
						|
            .Include(e => e.Categories)
 | 
						|
            .Include(e => e.Tags)
 | 
						|
            .Include(e => e.FeaturedRecords)
 | 
						|
            .Where(e => e.RepliedPostId == null)
 | 
						|
            .Where(p => cursor == null || p.PublishedAt < cursor)
 | 
						|
            .OrderByDescending(p => p.PublishedAt)
 | 
						|
            .AsQueryable();
 | 
						|
 | 
						|
        if (filteredPublishersId != null && filteredPublishersId.Count != 0)
 | 
						|
            query = query.Where(p => filteredPublishersId.Contains(p.PublisherId));
 | 
						|
        if (userRealms == null)
 | 
						|
        {
 | 
						|
            // For anonymous users, only show public realm posts or posts without realm
 | 
						|
            // Get public realm ids in the caller and pass them
 | 
						|
            query = query.Where(p => p.RealmId == null); // Modify in caller
 | 
						|
        }
 | 
						|
        else
 | 
						|
            query = query.Where(p =>
 | 
						|
                p.RealmId == null || userRealms.Contains(p.RealmId.Value));
 | 
						|
 | 
						|
        return query;
 | 
						|
    }
 | 
						|
 | 
						|
    private async Task<List<Shared.Models.SnPublisher>?> GetFilteredPublishers(
 | 
						|
        string? filter,
 | 
						|
        Account currentUser,
 | 
						|
        List<Guid> 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 async Task LoadPostsRealmsAsync(List<SnPost> posts, RemoteRealmService rs)
 | 
						|
    {
 | 
						|
        var postRealmIds = posts.Where(p => p.RealmId != null).Select(p => p.RealmId!.Value).Distinct().ToList();
 | 
						|
        if (!postRealmIds.Any()) return;
 | 
						|
 | 
						|
        var realms = await rs.GetRealmBatch(postRealmIds.Select(id => id.ToString()).ToList());
 | 
						|
        var realmDict = realms.ToDictionary(r => r.Id, r => r);
 | 
						|
 | 
						|
        foreach (var post in posts.Where(p => p.RealmId != null))
 | 
						|
        {
 | 
						|
            if (realmDict.TryGetValue(post.RealmId!.Value, out var realm))
 | 
						|
            {
 | 
						|
                post.Realm = realm;
 | 
						|
            }
 | 
						|
        }
 | 
						|
    }
 | 
						|
 | 
						|
    private static double CalculatePopularity(List<SnPost> posts)
 | 
						|
    {
 | 
						|
        var score = posts.Sum(p => p.Upvotes - p.Downvotes);
 | 
						|
        var postCount = posts.Count;
 | 
						|
        return score + postCount;
 | 
						|
    }
 | 
						|
}
 |