Compare commits

...

3 Commits

Author SHA1 Message Date
d1fb0b9b55 Filter on activities 2025-06-21 22:21:20 +08:00
f1a47fd079 Chat message also preview links 2025-06-21 15:19:49 +08:00
546b65f4c6 🐛 Fixed post service 2025-06-21 14:47:21 +08:00
6 changed files with 264 additions and 16 deletions

View File

@ -6,7 +6,7 @@ namespace DysonNetwork.Sphere.Account;
public class RelationshipService(AppDatabase db, ICacheService cache)
{
private const string UserFriendsCacheKeyPrefix = "user:friends:";
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
{

View File

@ -22,8 +22,11 @@ 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<Activity>>> ListActivities([FromQuery] string? cursor,
[FromQuery] int take = 20)
public async Task<ActionResult<List<Activity>>> ListActivities(
[FromQuery] string? cursor,
[FromQuery] string? filter,
[FromQuery] int take = 20
)
{
Instant? cursorTimestamp = null;
if (!string.IsNullOrEmpty(cursor))
@ -42,6 +45,6 @@ public class ActivityController(
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
return currentUserValue is not Account.Account currentUser
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp))
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser));
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter));
}
}

View File

@ -42,26 +42,57 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS
return activities;
}
public async Task<List<Activity>> GetActivities(int take, Instant? cursor, Account.Account currentUser)
public async Task<List<Activity>> GetActivities(
int take,
Instant? cursor,
Account.Account currentUser,
string? filter = null
)
{
var activities = new List<Activity>();
var userFriends = await rels.ListAccountFriends(currentUser);
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
var publishersId = userPublishers.Select(e => e.Id).ToList();
// Crunching data
var posts = await db.Posts
// Get publishers based on filter
List<Publisher.Publisher>? filteredPublishers = null;
switch (filter)
{
case "subscriptions":
filteredPublishers = await pub.GetSubscribedPublishers(currentUser.Id);
break;
case "friends":
{
filteredPublishers = (await pub.GetUserPublishersBatch(userFriends))
.SelectMany(x => x.Value)
.DistinctBy(x => x.Id)
.ToList();
break;
}
default:
break;
}
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)
.Where(e => e.RepliedPostId == null || publishersId.Contains(e.RepliedPost!.PublisherId))
.Where(p => cursor == null || p.PublishedAt < cursor)
.OrderByDescending(p => p.PublishedAt)
.FilterWithVisibility(currentUser, userFriends, userPublishers, isListing: true)
.AsQueryable();
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)
.ToListAsync();
posts = await ps.LoadPostInfo(posts, currentUser, true);
var postsId = posts.Select(e => e.Id).ToList();

View File

@ -1,3 +1,4 @@
using System.Text.RegularExpressions;
using DysonNetwork.Sphere.Account;
using DysonNetwork.Sphere.Chat.Realtime;
using DysonNetwork.Sphere.Connection;
@ -7,7 +8,7 @@ using NodaTime;
namespace DysonNetwork.Sphere.Chat;
public class ChatService(
public partial class ChatService(
AppDatabase db,
FileReferenceService fileRefService,
IServiceScopeFactory scopeFactory,
@ -17,6 +18,132 @@ public class ChatService(
{
private const string ChatFileUsageIdentifier = "chat";
[GeneratedRegex(@"https?://[-A-Za-z0-9+&@#/%?=~_|!:,.;]*[-A-Za-z0-9+&@#/%=~_|]")]
private static partial Regex GetLinkRegex();
/// <summary>
/// Process link previews for a message in the background
/// This method is designed to be called from a background task
/// </summary>
/// <param name="message">The message to process link previews for</param>
private async Task ProcessMessageLinkPreviewAsync(Message message)
{
try
{
// Create a new scope for database operations
using var scope = scopeFactory.CreateScope();
var dbContext = scope.ServiceProvider.GetRequiredService<AppDatabase>();
var webReader = scope.ServiceProvider.GetRequiredService<Connection.WebReader.WebReaderService>();
var newChat = scope.ServiceProvider.GetRequiredService<ChatService>();
// Preview the links in the message
var updatedMessage = await PreviewMessageLinkAsync(message, webReader);
// If embeds were added, update the message in the database
if (updatedMessage.Meta != null &&
updatedMessage.Meta.TryGetValue("embeds", out var embeds) &&
embeds is List<Dictionary<string, object>> { Count: > 0 } embedsList)
{
// Get a fresh copy of the message from the database
var dbMessage = await dbContext.ChatMessages
.Where(m => m.Id == message.Id)
.Include(m => m.Sender)
.Include(m => m.ChatRoom)
.FirstOrDefaultAsync();
if (dbMessage != null)
{
// Update the meta field with the new embeds
dbMessage.Meta ??= new Dictionary<string, object>();
dbMessage.Meta["embeds"] = embedsList;
// Save changes to the database
dbContext.Update(dbMessage);
await dbContext.SaveChangesAsync();
logger.LogDebug($"Updated message {message.Id} with {embedsList.Count} link previews");
// Notify clients of the updated message
await newChat.DeliverMessageAsync(
dbMessage,
dbMessage.Sender,
dbMessage.ChatRoom,
WebSocketPacketType.MessageUpdate
);
}
}
}
catch (Exception ex)
{
// Log errors but don't rethrow - this is a background task
logger.LogError($"Error processing link previews for message {message.Id}: {ex.Message} {ex.StackTrace}");
}
}
/// <summary>
/// Processes a message to find and preview links in its content
/// </summary>
/// <param name="message">The message to process</param>
/// <param name="webReader">The web reader service</param>
/// <returns>The message with link previews added to its meta data</returns>
public async Task<Message> PreviewMessageLinkAsync(Message message,
Connection.WebReader.WebReaderService? webReader = null)
{
if (string.IsNullOrEmpty(message.Content))
return message;
// Find all URLs in the content
var matches = GetLinkRegex().Matches(message.Content);
if (matches.Count == 0)
return message;
// Initialize meta dictionary if null
message.Meta ??= new Dictionary<string, object>();
// Initialize the embeds' array if it doesn't exist
if (!message.Meta.TryGetValue("embeds", out var existingEmbeds) ||
existingEmbeds is not List<Dictionary<string, object>>)
{
message.Meta["embeds"] = new List<Dictionary<string, object>>();
}
var embeds = (List<Dictionary<string, object>>)message.Meta["embeds"];
webReader ??= scopeFactory.CreateScope().ServiceProvider
.GetRequiredService<Connection.WebReader.WebReaderService>();
// Process up to 3 links to avoid excessive processing
var processedLinks = 0;
foreach (Match match in matches)
{
if (processedLinks >= 3)
break;
var url = match.Value;
try
{
// Check if this URL is already in the embed list
var urlAlreadyEmbedded = embeds.Any(e =>
e.TryGetValue("Url", out var originalUrl) && (string)originalUrl == url);
if (urlAlreadyEmbedded)
continue;
// Preview the link
var linkEmbed = await webReader.GetLinkPreviewAsync(url);
embeds.Add(linkEmbed.ToDictionary());
processedLinks++;
}
catch
{
// ignored
}
}
message.Meta["embeds"] = embeds;
return message;
}
public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
{
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
@ -57,6 +184,9 @@ public class ChatService(
}
});
// Process link preview in the background to avoid delaying message sending
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
message.Sender = sender;
message.ChatRoom = room;
return message;
@ -293,7 +423,7 @@ public class ChatService(
{
Type = "call.ended",
ChatRoomId = call.RoomId,
SenderId = sender.Id,
SenderId = call.SenderId,
Meta = new Dictionary<string, object>
{
{ "call_id", call.Id },
@ -389,6 +519,10 @@ public class ChatService(
db.Update(message);
await db.SaveChangesAsync();
// Process link preview in the background if content was updated
if (content is not null)
_ = Task.Run(async () => await ProcessMessageLinkPreviewAsync(message));
_ = DeliverMessageAsync(
message,
message.Sender,

View File

@ -342,7 +342,7 @@ public partial class PostService(
// If embeds were added, update the post in the database
if (updatedPost.Meta != null &&
updatedPost.Meta.TryGetValue("embeds", out var embeds) &&
embeds is List<EmbeddableBase> { Count: > 0 } embedsList)
embeds is List<Dictionary<string, object>> { Count: > 0 } embedsList)
{
// Get a fresh copy of the post from the database
var dbPost = await dbContext.Posts.FindAsync(post.Id);
@ -659,6 +659,5 @@ public static class PostQueryExtensions
.Where(e => e.Visibility != PostVisibility.Friends ||
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
publishersId.Contains(e.PublisherId));
;
}
}

View File

@ -9,6 +9,7 @@ namespace DysonNetwork.Sphere.Publisher;
public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache)
{
private const string UserPublishersCacheKey = "accounts:{0}:publishers";
private const string UserPublishersBatchCacheKey = "accounts:batch:{0}:publishers";
public async Task<List<Publisher>> GetUserPublishers(Guid userId)
{
@ -34,6 +35,86 @@ public class PublisherService(AppDatabase db, FileReferenceService fileRefServic
return publishers;
}
public async Task<Dictionary<Guid, List<Publisher>>> GetUserPublishersBatch(List<Guid> userIds)
{
var result = new Dictionary<Guid, List<Publisher>>();
var missingIds = new List<Guid>();
// Try to get publishers from cache for each user
foreach (var userId in userIds)
{
var cacheKey = string.Format(UserPublishersCacheKey, userId);
var publishers = await cache.GetAsync<List<Publisher>>(cacheKey);
if (publishers != null)
result[userId] = publishers;
else
missingIds.Add(userId);
}
if (missingIds.Count <= 0) return result;
{
// Fetch missing data from database
var publisherMembers = await db.PublisherMembers
.Where(p => missingIds.Contains(p.AccountId))
.Select(p => new { p.AccountId, p.PublisherId })
.ToListAsync();
var publisherIds = publisherMembers.Select(p => p.PublisherId).Distinct().ToList();
var publishers = await db.Publishers
.Where(p => publisherIds.Contains(p.Id))
.ToListAsync();
// Group publishers by user id
foreach (var userId in missingIds)
{
var userPublisherIds = publisherMembers
.Where(p => p.AccountId == userId)
.Select(p => p.PublisherId)
.ToList();
var userPublishers = publishers
.Where(p => userPublisherIds.Contains(p.Id))
.ToList();
result[userId] = userPublishers;
// Cache individual results
var cacheKey = string.Format(UserPublishersCacheKey, userId);
await cache.SetAsync(cacheKey, userPublishers, TimeSpan.FromMinutes(5));
}
}
return result;
}
private const string SubscribedPublishersCacheKey = "accounts:{0}:subscribed-publishers";
public async Task<List<Publisher>> GetSubscribedPublishers(Guid userId)
{
var cacheKey = string.Format(SubscribedPublishersCacheKey, userId);
// Try to get publishers from the cache first
var publishers = await cache.GetAsync<List<Publisher>>(cacheKey);
if (publishers is not null)
return publishers;
// If not in cache, fetch from a database
var publishersId = await db.PublisherSubscriptions
.Where(p => p.AccountId == userId)
.Select(p => p.PublisherId)
.ToListAsync();
publishers = await db.Publishers
.Where(p => publishersId.Contains(p.Id))
.ToListAsync();
// Store in a cache for 5 minutes
await cache.SetAsync(cacheKey, publishers, TimeSpan.FromMinutes(5));
return publishers;
}
private const string PublisherMembersCacheKey = "publishers:{0}:members";
public async Task<List<PublisherMember>> GetPublisherMembers(Guid publisherId)