🚚 Rename activity in sphere to timeline

In order to leave the activity keyword for pass service user activity
This commit is contained in:
2025-10-30 21:46:24 +08:00
parent ab23f87a66
commit 0b65bf8dd7
7 changed files with 323 additions and 208 deletions

View File

@@ -1,23 +1,23 @@
using System.Globalization;
using DysonNetwork.Sphere.Activity;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Sticker;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
using System.Text.Json;
using System.Text.Json.Serialization;
using DysonNetwork.Shared.Cache;
using DysonNetwork.Shared.GeoIp;
using DysonNetwork.Shared.Registry;
using DysonNetwork.Sphere.Autocompletion;
using DysonNetwork.Sphere.WebReader;
using DysonNetwork.Sphere.Chat;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Discovery;
using DysonNetwork.Sphere.Localization;
using DysonNetwork.Sphere.Poll;
using DysonNetwork.Sphere.Post;
using DysonNetwork.Sphere.Publisher;
using DysonNetwork.Sphere.Sticker;
using DysonNetwork.Sphere.Timeline;
using DysonNetwork.Sphere.Translation;
using DysonNetwork.Sphere.WebReader;
using NodaTime;
using NodaTime.Serialization.SystemTextJson;
namespace DysonNetwork.Sphere.Startup;
@@ -34,34 +34,41 @@ public static class ServiceCollectionExtensions
services.AddHttpClient();
services.AddControllers().AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling = JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower;
services
.AddControllers()
.AddJsonOptions(options =>
{
options.JsonSerializerOptions.NumberHandling =
JsonNumberHandling.AllowNamedFloatingPointLiterals;
options.JsonSerializerOptions.PropertyNamingPolicy =
JsonNamingPolicy.SnakeCaseLower;
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
}).AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
}).ConfigureApplicationPartManager(opts =>
{
var mockingPart = opts.ApplicationParts.FirstOrDefault(a => a.Name == "DysonNetwork.Pass");
if (mockingPart != null)
opts.ApplicationParts.Remove(mockingPart);
});
options.JsonSerializerOptions.ConfigureForNodaTime(DateTimeZoneProviders.Tzdb);
})
.AddDataAnnotationsLocalization(options =>
{
options.DataAnnotationLocalizerProvider = (type, factory) =>
factory.Create(typeof(SharedResource));
})
.ConfigureApplicationPartManager(opts =>
{
var mockingPart = opts.ApplicationParts.FirstOrDefault(a =>
a.Name == "DysonNetwork.Pass"
);
if (mockingPart != null)
opts.ApplicationParts.Remove(mockingPart);
});
services.AddRazorPages();
services.AddGrpc(options => { options.EnableDetailedErrors = true; });
services.AddGrpc(options =>
{
options.EnableDetailedErrors = true;
});
services.AddGrpcReflection();
services.Configure<RequestLocalizationOptions>(options =>
{
var supportedCultures = new[]
{
new CultureInfo("en-US"),
new CultureInfo("zh-Hans"),
};
var supportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("zh-Hans") };
options.SupportedCultures = supportedCultures;
options.SupportedUICultures = supportedCultures;
@@ -71,7 +78,7 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppAuthentication(this IServiceCollection services)
{
services.AddAuthorization();
@@ -86,14 +93,16 @@ public static class ServiceCollectionExtensions
return services;
}
public static IServiceCollection AddAppBusinessServices(this IServiceCollection services,
IConfiguration configuration)
public static IServiceCollection AddAppBusinessServices(
this IServiceCollection services,
IConfiguration configuration
)
{
services.Configure<GeoIpOptions>(configuration.GetSection("GeoIP"));
services.AddScoped<GeoIpService>();
services.AddScoped<PublisherService>();
services.AddScoped<PublisherSubscriptionService>();
services.AddScoped<ActivityService>();
services.AddScoped<TimelineService>();
services.AddScoped<PostService>();
services.AddScoped<ChatRoomService>();
services.AddScoped<ChatService>();

View File

@@ -4,16 +4,14 @@ using Microsoft.AspNetCore.Mvc;
using NodaTime;
using NodaTime.Text;
namespace DysonNetwork.Sphere.Activity;
namespace DysonNetwork.Sphere.Timeline;
/// <summary>
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
/// </summary>
[ApiController]
[Route("/api/activities")]
public class ActivityController(
ActivityService acts
) : ControllerBase
[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.
@@ -24,7 +22,7 @@ public class ActivityController(
/// 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<SnActivity>>> ListActivities(
public async Task<ActionResult<List<SnTimelineEvent>>> ListEvents(
[FromQuery] string? cursor,
[FromQuery] string? filter,
[FromQuery] int take = 20,
@@ -48,7 +46,9 @@ public class ActivityController(
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
return currentUserValue is not Account currentUser
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp, debugIncludeSet))
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter, debugIncludeSet));
? Ok(await acts.ListEventsForAnyone(take, cursorTimestamp, debugIncludeSet))
: Ok(
await acts.ListEvents(take, cursorTimestamp, currentUser, filter, debugIncludeSet)
);
}
}
}

View File

@@ -1,16 +1,16 @@
using DysonNetwork.Shared.Models;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
namespace DysonNetwork.Sphere.Timeline;
public class DiscoveryActivity(List<DiscoveryItem> items) : IActivity
public class TimelineDiscoveryEvent(List<DiscoveryItem> items) : ITimelineEvent
{
public List<DiscoveryItem> Items { get; set; } = items;
public SnActivity ToActivity()
public SnTimelineEvent ToActivity()
{
var now = SystemClock.Instance.GetCurrentInstant();
return new SnActivity
return new SnTimelineEvent
{
Id = Guid.NewGuid(),
Type = "discovery",
@@ -22,4 +22,5 @@ public class DiscoveryActivity(List<DiscoveryItem> items) : IActivity
}
}
public record DiscoveryItem(string Type, object Data);
public record DiscoveryItem(string Type, object Data);

View File

@@ -7,9 +7,9 @@ using DysonNetwork.Sphere.WebReader;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
namespace DysonNetwork.Sphere.Timeline;
public class ActivityService(
public class TimelineService(
AppDatabase db,
Publisher.PublisherService pub,
Post.PostService ps,
@@ -20,23 +20,25 @@ public class ActivityService(
{
private static double CalculateHotRank(SnPost post, Instant now)
{
var performanceScore = post.Upvotes - post.Downvotes + post.RepliesCount + (int)post.AwardedScore / 10;
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;
var normalizedTime = timeScore / 60.0;
return performanceWeight / Math.Pow(normalizedTime + 1.0, 1.2);
}
public async Task<List<SnActivity>> GetActivitiesForAnyone(
public async Task<List<SnTimelineEvent>> ListEventsForAnyone(
int take,
Instant? cursor,
HashSet<string>? debugInclude = null)
HashSet<string>? debugInclude = null
)
{
var activities = new List<SnActivity>();
var activities = new List<SnTimelineEvent>();
debugInclude ??= new HashSet<string>();
// Get and process posts
@@ -51,7 +53,7 @@ public class ActivityService(
await LoadPostsRealmsAsync(posts, rs);
posts = RankPosts(posts, take);
var interleaved = new List<SnActivity>();
var interleaved = new List<SnTimelineEvent>();
var random = new Random();
foreach (var post in posts)
{
@@ -69,26 +71,26 @@ public class ActivityService(
activities.AddRange(interleaved);
if (activities.Count == 0)
activities.Add(SnActivity.Empty());
activities.Add(SnTimelineEvent.Empty());
return activities;
}
public async Task<List<SnActivity>> GetActivities(
public async Task<List<SnTimelineEvent>> ListEvents(
int take,
Instant? cursor,
Account currentUser,
string? filter = null,
HashSet<string>? debugInclude = null)
HashSet<string>? debugInclude = null
)
{
var activities = new List<SnActivity>();
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 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));
@@ -107,20 +109,18 @@ public class ActivityService(
currentUser,
userFriends,
filter is null ? userPublishers : [],
isListing: true)
isListing: true
)
.Take(take * 5);
// Get, process and rank posts
var posts = await GetAndProcessPosts(
postsQuery,
currentUser,
trackViews: true);
var posts = await GetAndProcessPosts(postsQuery, currentUser, trackViews: true);
await LoadPostsRealmsAsync(posts, rs);
posts = RankPosts(posts, take);
var interleaved = new List<SnActivity>();
var interleaved = new List<SnTimelineEvent>();
var random = new Random();
foreach (var post in posts)
{
@@ -137,15 +137,19 @@ public class ActivityService(
activities.AddRange(interleaved);
if (activities.Count == 0)
activities.Add(SnActivity.Empty());
activities.Add(SnTimelineEvent.Empty());
return activities;
}
private async Task<SnActivity?> MaybeGetDiscoveryActivity(HashSet<string> debugInclude, Instant? cursor)
private async Task<SnTimelineEvent?> MaybeGetDiscoveryActivity(
HashSet<string> debugInclude,
Instant? cursor
)
{
if (cursor != null) return null;
var options = new List<Func<Task<SnActivity?>>>();
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)
@@ -154,7 +158,8 @@ public class ActivityService(
options.Add(() => GetArticleDiscoveryActivity());
if (debugInclude.Contains("shuffledPosts") || Random.Shared.NextDouble() < 0.2)
options.Add(() => GetShuffledPostsActivity());
if (options.Count == 0) return null;
if (options.Count == 0)
return null;
var random = new Random();
var pick = options[random.Next(options.Count)];
return await pick();
@@ -177,9 +182,7 @@ public class ActivityService(
var now = SystemClock.Instance.GetCurrentInstant();
var recent = now.Minus(Duration.FromDays(7));
var posts = await db.Posts
.Where(p => p.PublishedAt > recent)
.ToListAsync();
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();
@@ -188,7 +191,7 @@ public class ActivityService(
.Select(p => new
{
Publisher = p,
Rank = CalculatePopularity(posts.Where(post => post.PublisherId == p.Id).ToList())
Rank = CalculatePopularity(posts.Where(post => post.PublisherId == p.Id).ToList()),
})
.OrderByDescending(x => x.Rank)
.Select(x => x.Publisher)
@@ -196,30 +199,33 @@ public class ActivityService(
.ToList();
}
private async Task<SnActivity?> GetRealmDiscoveryActivity(int count = 5)
private async Task<SnTimelineEvent?> 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()
? new TimelineDiscoveryEvent(
realms.Select(x => new DiscoveryItem("realm", x)).ToList()
).ToActivity()
: null;
}
private async Task<SnActivity?> GetPublisherDiscoveryActivity(int count = 5)
private async Task<SnTimelineEvent?> GetPublisherDiscoveryActivity(int count = 5)
{
var popularPublishers = await GetPopularPublishers(count);
return popularPublishers.Count > 0
? new DiscoveryActivity(popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList())
.ToActivity()
? new TimelineDiscoveryEvent(
popularPublishers.Select(x => new DiscoveryItem("publisher", x)).ToList()
).ToActivity()
: null;
}
private async Task<SnActivity?> GetShuffledPostsActivity(int count = 5)
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)
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))
@@ -231,17 +237,22 @@ public class ActivityService(
return posts.Count == 0
? null
: new DiscoveryActivity(posts.Select(x => new DiscoveryItem("post", x)).ToList()).ToActivity();
: new TimelineDiscoveryEvent(
posts.Select(x => new DiscoveryItem("post", x)).ToList()
).ToActivity();
}
private async Task<SnActivity?> GetArticleDiscoveryActivity(int count = 5, int feedSampleSize = 10)
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)
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)
@@ -253,26 +264,31 @@ public class ActivityService(
foreach (var feedId in recentFeedIds.OrderBy(_ => random.Next()))
{
var article = await db.WebArticles
.Include(a => a.Feed)
var article = await db
.WebArticles.Include(a => a.Feed)
.Where(a => a.FeedId == feedId)
.OrderBy(_ => EF.Functions.Random())
.FirstOrDefaultAsync();
if (article == null) continue;
if (article == null)
continue;
recentArticles.Add(article);
if (recentArticles.Count >= count) break;
if (recentArticles.Count >= count)
break;
}
return recentArticles.Count > 0
? new DiscoveryActivity(recentArticles.Select(x => new DiscoveryItem("article", x)).ToList()).ToActivity()
? 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)
bool trackViews = true
)
{
var posts = await baseQuery.ToListAsync();
posts = await ps.LoadPostInfo(posts, currentUser, true);
@@ -282,7 +298,10 @@ public class ActivityService(
foreach (var post in posts)
{
post.ReactionsCount = reactionMaps.GetValueOrDefault(post.Id, new Dictionary<string, int>());
post.ReactionsCount = reactionMaps.GetValueOrDefault(
post.Id,
new Dictionary<string, int>()
);
if (trackViews && currentUser != null)
{
@@ -299,8 +318,8 @@ public class ActivityService(
List<Guid>? userRealms = null
)
{
var query = db.Posts
.Include(e => e.RepliedPost)
var query = db
.Posts.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
@@ -319,8 +338,7 @@ public class ActivityService(
query = query.Where(p => p.RealmId == null); // Modify in caller
}
else
query = query.Where(p =>
p.RealmId == null || userRealms.Contains(p.RealmId.Value));
query = query.Where(p => p.RealmId == null || userRealms.Contains(p.RealmId.Value));
return query;
}
@@ -328,7 +346,8 @@ public class ActivityService(
private async Task<List<Shared.Models.SnPublisher>?> GetFilteredPublishers(
string? filter,
Account currentUser,
List<Guid> userFriends)
List<Guid> userFriends
)
{
return filter?.ToLower() switch
{
@@ -337,14 +356,19 @@ public class ActivityService(
.SelectMany(x => x.Value)
.DistinctBy(x => x.Id)
.ToList(),
_ => null
_ => 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 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);