🚚 Rename activity in sphere to timeline
In order to leave the activity keyword for pass service user activity
This commit is contained in:
54
DysonNetwork.Sphere/Timeline/TimelineController.cs
Normal file
54
DysonNetwork.Sphere/Timeline/TimelineController.cs
Normal file
@@ -0,0 +1,54 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using DysonNetwork.Shared.Proto;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using NodaTime;
|
||||
using NodaTime.Text;
|
||||
|
||||
namespace DysonNetwork.Sphere.Timeline;
|
||||
|
||||
/// <summary>
|
||||
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
|
||||
/// </summary>
|
||||
[ApiController]
|
||||
[Route("/api/timeline")]
|
||||
public class ActivityController(TimelineService acts) : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Listing the activities for the user, users may be logged in or not to use this API.
|
||||
/// When the users are not logged in, this API will return the posts that are public.
|
||||
/// When the users are logged in,
|
||||
/// the API will personalize the user's experience
|
||||
/// by ranking up the people they like and the posts they like.
|
||||
/// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them.
|
||||
/// </summary>
|
||||
[HttpGet]
|
||||
public async Task<ActionResult<List<SnTimelineEvent>>> ListEvents(
|
||||
[FromQuery] string? cursor,
|
||||
[FromQuery] string? filter,
|
||||
[FromQuery] int take = 20,
|
||||
[FromQuery] string? debugInclude = null
|
||||
)
|
||||
{
|
||||
Instant? cursorTimestamp = null;
|
||||
if (!string.IsNullOrEmpty(cursor))
|
||||
{
|
||||
try
|
||||
{
|
||||
cursorTimestamp = InstantPattern.ExtendedIso.Parse(cursor).GetValueOrThrow();
|
||||
}
|
||||
catch
|
||||
{
|
||||
return BadRequest("Invalid cursor format");
|
||||
}
|
||||
}
|
||||
|
||||
var debugIncludeSet = debugInclude?.Split(',').ToHashSet() ?? new HashSet<string>();
|
||||
|
||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||
return currentUserValue is not Account currentUser
|
||||
? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp, debugIncludeSet))
|
||||
: Ok(
|
||||
await acts.ListEvents(take, cursorTimestamp, currentUser, filter, debugIncludeSet)
|
||||
);
|
||||
}
|
||||
}
|
||||
26
DysonNetwork.Sphere/Timeline/TimelineDiscovery.cs
Normal file
26
DysonNetwork.Sphere/Timeline/TimelineDiscovery.cs
Normal file
@@ -0,0 +1,26 @@
|
||||
using DysonNetwork.Shared.Models;
|
||||
using NodaTime;
|
||||
|
||||
namespace DysonNetwork.Sphere.Timeline;
|
||||
|
||||
public class TimelineDiscoveryEvent(List<DiscoveryItem> items) : ITimelineEvent
|
||||
{
|
||||
public List<DiscoveryItem> Items { get; set; } = items;
|
||||
|
||||
public SnTimelineEvent ToActivity()
|
||||
{
|
||||
var now = SystemClock.Instance.GetCurrentInstant();
|
||||
return new SnTimelineEvent
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Type = "discovery",
|
||||
ResourceIdentifier = "discovery",
|
||||
Data = this,
|
||||
CreatedAt = now,
|
||||
UpdatedAt = now,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public record DiscoveryItem(string Type, object Data);
|
||||
|
||||
391
DysonNetwork.Sphere/Timeline/TimelineService.cs
Normal file
391
DysonNetwork.Sphere/Timeline/TimelineService.cs
Normal file
@@ -0,0 +1,391 @@
|
||||
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.Timeline;
|
||||
|
||||
public class TimelineService(
|
||||
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<SnTimelineEvent>> ListEventsForAnyone(
|
||||
int take,
|
||||
Instant? cursor,
|
||||
HashSet<string>? debugInclude = null
|
||||
)
|
||||
{
|
||||
var activities = new List<SnTimelineEvent>();
|
||||
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<SnTimelineEvent>();
|
||||
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(SnTimelineEvent.Empty());
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
public async Task<List<SnTimelineEvent>> ListEvents(
|
||||
int take,
|
||||
Instant? cursor,
|
||||
Account currentUser,
|
||||
string? filter = null,
|
||||
HashSet<string>? debugInclude = null
|
||||
)
|
||||
{
|
||||
var activities = new List<SnTimelineEvent>();
|
||||
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<SnTimelineEvent>();
|
||||
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(SnTimelineEvent.Empty());
|
||||
|
||||
return activities;
|
||||
}
|
||||
|
||||
private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity(
|
||||
HashSet<string> debugInclude,
|
||||
Instant? cursor
|
||||
)
|
||||
{
|
||||
if (cursor != null)
|
||||
return null;
|
||||
var options = new List<Func<Task<SnTimelineEvent?>>>();
|
||||
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<SnTimelineEvent?> GetRealmDiscoveryActivity(int count = 5)
|
||||
{
|
||||
var realms = await ds.GetCommunityRealmAsync(null, count, 0, true);
|
||||
return realms.Count > 0
|
||||
? new TimelineDiscoveryEvent(
|
||||
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
|
||||
).ToActivity()
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<SnTimelineEvent?> GetPublisherDiscoveryActivity(int count = 5)
|
||||
{
|
||||
var popularPublishers = await GetPopularPublishers(count);
|
||||
return popularPublishers.Count > 0
|
||||
? new TimelineDiscoveryEvent(
|
||||
popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList()
|
||||
).ToActivity()
|
||||
: null;
|
||||
}
|
||||
|
||||
private async Task<SnTimelineEvent?> 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 TimelineDiscoveryEvent(
|
||||
posts.Select(x => new DiscoveryItem("post", x)).ToList()
|
||||
).ToActivity();
|
||||
}
|
||||
|
||||
private async Task<SnTimelineEvent?> 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 TimelineDiscoveryEvent(
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user