♻️ Refactored activities

This commit is contained in:
2025-06-08 23:52:02 +08:00
parent 39533cced3
commit b8341734df
11 changed files with 3512 additions and 292 deletions

View File

@ -1,26 +1,37 @@
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public enum ActivityVisibility
public interface IActivity
{
Public,
Friends,
Selected
public Activity ToActivity();
}
[NotMapped]
public class Activity : ModelBase
{
public Guid Id { get; set; } = Guid.NewGuid();
public Guid Id { get; set; }
[MaxLength(1024)] public string Type { get; set; } = null!;
[MaxLength(4096)] public string ResourceIdentifier { get; set; } = null!;
public ActivityVisibility Visibility { get; set; } = ActivityVisibility.Public;
[Column(TypeName = "jsonb")] public Dictionary<string, object> Meta { get; set; } = new();
[Column(TypeName = "jsonb")] public ICollection<Guid> UsersVisible { get; set; } = new List<Guid>();
public Guid AccountId { get; set; }
public Account.Account Account { get; set; } = null!;
public object? Data { get; set; }
[NotMapped] public object? Data { get; set; }
// Outdated fields, for backward compability
public int Visibility => 0;
public static Activity Empty()
{
var now = SystemClock.Instance.GetCurrentInstant();
return new Activity
{
CreatedAt = now,
UpdatedAt = now,
Id = Guid.NewGuid(),
Type = "empty",
ResourceIdentifier = "none"
};
}
}

View File

@ -1,38 +1,38 @@
using DysonNetwork.Sphere.Account;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
/// <summary>
/// Activity is a universal feed that contains multiple kinds of data. Personalized and generated dynamically.
/// </summary>
[ApiController]
[Route("/activities")]
public class ActivityController(
AppDatabase db,
ActivityReaderService reader,
RelationshipService rels) : ControllerBase
ActivityService 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<Activity>>> ListActivities([FromQuery] int offset, [FromQuery] int take = 20)
public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] int? cursor, [FromQuery] int take = 20)
{
var cursorTimestamp = cursor is <= 1000
? SystemClock.Instance.GetCurrentInstant()
: Instant.FromUnixTimeMilliseconds(cursor!.Value);
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
var currentUser = currentUserValue as Account.Account;
var userFriends = currentUser is null ? [] : await rels.ListAccountFriends(currentUser);
if (currentUserValue is not Account.Account currentUser)
return Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp));
var totalCount = await db.Activities
.FilterWithVisibility(currentUser, userFriends)
.CountAsync();
var activities = await db.Activities
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.FilterWithVisibility(currentUser, userFriends)
.OrderByDescending(e => e.CreatedAt)
.Skip(offset)
.Take(take)
.ToListAsync();
activities = await reader.LoadActivityData(activities, currentUser, userFriends);
Response.Headers["X-Total"] = totalCount.ToString();
return Ok(activities);
return Ok(await acts.GetActivities(take, cursorTimestamp, currentUser));
}
}

View File

@ -1,173 +1,62 @@
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Post;
using Microsoft.EntityFrameworkCore;
using NodaTime;
namespace DysonNetwork.Sphere.Activity;
public class ActivityReaderService(AppDatabase db, PostService ps)
public class ActivityService(AppDatabase db, RelationshipService rels)
{
public async Task<List<Activity>> LoadActivityData(List<Activity> input, Account.Account? currentUser,
List<Guid> userFriends)
public async Task<List<Activity>> GetActivitiesForAnyone(int take, Instant cursor)
{
if (input.Count == 0) return input;
var activities = new List<Activity>();
// Crunching up data
var posts = await db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Where(e => e.RepliedPostId == null)
.Where(p => p.CreatedAt > cursor)
.FilterWithVisibility(null, [], isListing: true)
.Take(take)
.ToListAsync();
// Formatting data
foreach (var post in posts)
activities.Add(post.ToActivity());
var postsId = input
.Where(e => e.ResourceIdentifier.StartsWith("posts/"))
.Select(e => Guid.Parse(e.ResourceIdentifier.Split("/").Last()))
.Distinct()
.ToList();
if (postsId.Count > 0)
{
var posts = await db.Posts.Where(e => postsId.Contains(e.Id))
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.FilterWithVisibility(currentUser, userFriends)
.ToListAsync();
posts = PostService.TruncatePostContent(posts);
posts = await ps.LoadPublishers(posts);
var reactionMaps = await ps.GetPostReactionMapBatch(postsId);
foreach (var post in posts)
post.ReactionsCount =
reactionMaps.TryGetValue(post.Id, out var count) ? count : new Dictionary<string, int>();
var postsDict = posts.ToDictionary(p => p.Id);
foreach (var item in input)
{
var resourceIdentifier = item.ResourceIdentifier;
if (!resourceIdentifier.StartsWith("posts/")) continue;
var postId = Guid.Parse(resourceIdentifier.Split("/").Last());
if (postsDict.TryGetValue(postId, out var post) && item.Data is null)
{
item.Data = post;
}
}
}
var statusesId = input
.Where(e => e.ResourceIdentifier.StartsWith("account.statuses/"))
.Select(e => Guid.Parse(e.ResourceIdentifier.Split("/").Last()))
.Distinct()
.ToList();
if (statusesId.Count > 0)
{
var statuses = await db.AccountStatuses.Where(e => statusesId.Contains(e.Id))
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.ToListAsync();
var statusesDict = statuses.ToDictionary(p => p.Id);
foreach (var item in input)
{
var resourceIdentifier = item.ResourceIdentifier;
if (!resourceIdentifier.StartsWith("account.statuses/")) continue;
var statusId = Guid.Parse(resourceIdentifier.Split("/").Last());
if (statusesDict.TryGetValue(statusId, out var status) && item.Data is null)
{
item.Data = status;
}
}
}
var checkInId = input
.Where(e => e.ResourceIdentifier.StartsWith("account.check-in/"))
.Select(e => Guid.Parse(e.ResourceIdentifier.Split("/").Last()))
.Distinct()
.ToList();
if (checkInId.Count > 0)
{
var checkIns = await db.AccountCheckInResults.Where(e => checkInId.Contains(e.Id))
.Include(e => e.Account)
.Include(e => e.Account.Profile)
.ToListAsync();
var checkInsDict = checkIns.ToDictionary(p => p.Id);
foreach (var item in input)
{
var resourceIdentifier = item.ResourceIdentifier;
if (!resourceIdentifier.StartsWith("account.check-in/")) continue;
var checkInResultId = Guid.Parse(resourceIdentifier.Split("/").Last());
if (checkInsDict.TryGetValue(checkInResultId, out var checkIn) && item.Data is null)
{
item.Data = checkIn;
}
}
}
return input;
return activities;
}
}
public class ActivityService(AppDatabase db)
{
public async Task<Activity> CreateActivity(
Account.Account user,
string type,
string identifier,
ActivityVisibility visibility = ActivityVisibility.Public,
List<Guid>? visibleUsers = null
)
public async Task<List<Activity>> GetActivities(int take, Instant cursor, Account.Account currentUser)
{
var activity = new Activity
var activities = new List<Activity>();
var userFriends = await rels.ListAccountFriends(currentUser);
// Crunching data
var posts = await db.Posts
.Include(e => e.RepliedPost)
.Include(e => e.ForwardedPost)
.Include(e => e.Categories)
.Include(e => e.Tags)
.Where(e => e.RepliedPostId == null || e.RepliedPostId == currentUser.Id)
.Where(p => p.CreatedAt > cursor)
.FilterWithVisibility(currentUser, userFriends, isListing: true)
.Take(take)
.ToListAsync();
// Formatting data
foreach (var post in posts)
activities.Add(post.ToActivity());
if (activities.Count == 0)
{
Type = type,
ResourceIdentifier = identifier,
Visibility = visibility,
AccountId = user.Id,
UsersVisible = visibleUsers ?? []
};
db.Activities.Add(activity);
await db.SaveChangesAsync();
return activity;
}
public async Task CreateNewPostActivity(Account.Account user, Post.Post post)
{
if (post.Visibility is PostVisibility.Unlisted or PostVisibility.Private) return;
var identifier = $"posts/{post.Id}";
if (post.RepliedPostId is not null)
{
var ogPost = await db.Posts
.Where(e => e.Id == post.RepliedPostId)
.Include(e => e.Publisher)
.FirstOrDefaultAsync();
if (ogPost?.Publisher.AccountId == null) return;
await CreateActivity(
user,
"posts.new.replies",
identifier,
ActivityVisibility.Selected,
[ogPost.Publisher.AccountId!.Value]
);
return;
var now = SystemClock.Instance.GetCurrentInstant();
activities.Add(Activity.Empty());
}
await CreateActivity(
user,
"posts.new",
identifier,
post.Visibility == PostVisibility.Friends ? ActivityVisibility.Friends : ActivityVisibility.Public
);
}
}
public static class ActivityQueryExtensions
{
public static IQueryable<Activity> FilterWithVisibility(this IQueryable<Activity> source,
Account.Account? currentUser, List<Guid> userFriends)
{
if (currentUser is null)
return source.Where(e => e.Visibility == ActivityVisibility.Public);
return source
.Where(e => e.Visibility != ActivityVisibility.Friends ||
userFriends.Contains(e.AccountId) ||
e.AccountId == currentUser.Id)
.Where(e => e.Visibility != ActivityVisibility.Selected ||
EF.Functions.JsonExists(e.UsersVisible, currentUser.Id.ToString()));
return activities;
}
}