Compare commits
3 Commits
1baa3109bc
...
d1fb0b9b55
Author | SHA1 | Date | |
---|---|---|---|
d1fb0b9b55 | |||
f1a47fd079 | |||
546b65f4c6 |
@ -6,7 +6,7 @@ namespace DysonNetwork.Sphere.Account;
|
|||||||
|
|
||||||
public class RelationshipService(AppDatabase db, ICacheService cache)
|
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)
|
public async Task<bool> HasExistingRelationship(Guid accountId, Guid relatedId)
|
||||||
{
|
{
|
||||||
|
@ -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.
|
/// Besides, when users are logged in, it will also mix the other kinds of data and who're plying to them.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
[HttpGet]
|
[HttpGet]
|
||||||
public async Task<ActionResult<List<Activity>>> ListActivities([FromQuery] string? cursor,
|
public async Task<ActionResult<List<Activity>>> ListActivities(
|
||||||
[FromQuery] int take = 20)
|
[FromQuery] string? cursor,
|
||||||
|
[FromQuery] string? filter,
|
||||||
|
[FromQuery] int take = 20
|
||||||
|
)
|
||||||
{
|
{
|
||||||
Instant? cursorTimestamp = null;
|
Instant? cursorTimestamp = null;
|
||||||
if (!string.IsNullOrEmpty(cursor))
|
if (!string.IsNullOrEmpty(cursor))
|
||||||
@ -42,6 +45,6 @@ public class ActivityController(
|
|||||||
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
HttpContext.Items.TryGetValue("CurrentUser", out var currentUserValue);
|
||||||
return currentUserValue is not Account.Account currentUser
|
return currentUserValue is not Account.Account currentUser
|
||||||
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp))
|
? Ok(await acts.GetActivitiesForAnyone(take, cursorTimestamp))
|
||||||
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser));
|
: Ok(await acts.GetActivities(take, cursorTimestamp, currentUser, filter));
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -42,26 +42,57 @@ public class ActivityService(AppDatabase db, PublisherService pub, RelationshipS
|
|||||||
return activities;
|
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 activities = new List<Activity>();
|
||||||
var userFriends = await rels.ListAccountFriends(currentUser);
|
var userFriends = await rels.ListAccountFriends(currentUser);
|
||||||
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
|
var userPublishers = await pub.GetUserPublishers(currentUser.Id);
|
||||||
|
|
||||||
var publishersId = userPublishers.Select(e => e.Id).ToList();
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
// Crunching data
|
var filteredPublishersId = filteredPublishers?.Select(e => e.Id).ToList();
|
||||||
var posts = await db.Posts
|
|
||||||
|
// Build the query based on the filter
|
||||||
|
var postsQuery = db.Posts
|
||||||
.Include(e => e.RepliedPost)
|
.Include(e => e.RepliedPost)
|
||||||
.Include(e => e.ForwardedPost)
|
.Include(e => e.ForwardedPost)
|
||||||
.Include(e => e.Categories)
|
.Include(e => e.Categories)
|
||||||
.Include(e => e.Tags)
|
.Include(e => e.Tags)
|
||||||
.Where(e => e.RepliedPostId == null || publishersId.Contains(e.RepliedPost!.PublisherId))
|
|
||||||
.Where(p => cursor == null || p.PublishedAt < cursor)
|
.Where(p => cursor == null || p.PublishedAt < cursor)
|
||||||
.OrderByDescending(p => p.PublishedAt)
|
.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)
|
.Take(take)
|
||||||
.ToListAsync();
|
.ToListAsync();
|
||||||
|
|
||||||
posts = await ps.LoadPostInfo(posts, currentUser, true);
|
posts = await ps.LoadPostInfo(posts, currentUser, true);
|
||||||
|
|
||||||
var postsId = posts.Select(e => e.Id).ToList();
|
var postsId = posts.Select(e => e.Id).ToList();
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
using DysonNetwork.Sphere.Account;
|
using DysonNetwork.Sphere.Account;
|
||||||
using DysonNetwork.Sphere.Chat.Realtime;
|
using DysonNetwork.Sphere.Chat.Realtime;
|
||||||
using DysonNetwork.Sphere.Connection;
|
using DysonNetwork.Sphere.Connection;
|
||||||
@ -7,7 +8,7 @@ using NodaTime;
|
|||||||
|
|
||||||
namespace DysonNetwork.Sphere.Chat;
|
namespace DysonNetwork.Sphere.Chat;
|
||||||
|
|
||||||
public class ChatService(
|
public partial class ChatService(
|
||||||
AppDatabase db,
|
AppDatabase db,
|
||||||
FileReferenceService fileRefService,
|
FileReferenceService fileRefService,
|
||||||
IServiceScopeFactory scopeFactory,
|
IServiceScopeFactory scopeFactory,
|
||||||
@ -17,6 +18,132 @@ public class ChatService(
|
|||||||
{
|
{
|
||||||
private const string ChatFileUsageIdentifier = "chat";
|
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)
|
public async Task<Message> SendMessageAsync(Message message, ChatMember sender, ChatRoom room)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(message.Nonce)) message.Nonce = Guid.NewGuid().ToString();
|
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.Sender = sender;
|
||||||
message.ChatRoom = room;
|
message.ChatRoom = room;
|
||||||
return message;
|
return message;
|
||||||
@ -293,7 +423,7 @@ public class ChatService(
|
|||||||
{
|
{
|
||||||
Type = "call.ended",
|
Type = "call.ended",
|
||||||
ChatRoomId = call.RoomId,
|
ChatRoomId = call.RoomId,
|
||||||
SenderId = sender.Id,
|
SenderId = call.SenderId,
|
||||||
Meta = new Dictionary<string, object>
|
Meta = new Dictionary<string, object>
|
||||||
{
|
{
|
||||||
{ "call_id", call.Id },
|
{ "call_id", call.Id },
|
||||||
@ -389,6 +519,10 @@ public class ChatService(
|
|||||||
db.Update(message);
|
db.Update(message);
|
||||||
await db.SaveChangesAsync();
|
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(
|
_ = DeliverMessageAsync(
|
||||||
message,
|
message,
|
||||||
message.Sender,
|
message.Sender,
|
||||||
|
@ -342,7 +342,7 @@ public partial class PostService(
|
|||||||
// If embeds were added, update the post in the database
|
// If embeds were added, update the post in the database
|
||||||
if (updatedPost.Meta != null &&
|
if (updatedPost.Meta != null &&
|
||||||
updatedPost.Meta.TryGetValue("embeds", out var embeds) &&
|
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
|
// Get a fresh copy of the post from the database
|
||||||
var dbPost = await dbContext.Posts.FindAsync(post.Id);
|
var dbPost = await dbContext.Posts.FindAsync(post.Id);
|
||||||
@ -659,6 +659,5 @@ public static class PostQueryExtensions
|
|||||||
.Where(e => e.Visibility != PostVisibility.Friends ||
|
.Where(e => e.Visibility != PostVisibility.Friends ||
|
||||||
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
|
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
|
||||||
publishersId.Contains(e.PublisherId));
|
publishersId.Contains(e.PublisherId));
|
||||||
;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
@ -9,6 +9,7 @@ namespace DysonNetwork.Sphere.Publisher;
|
|||||||
public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache)
|
public class PublisherService(AppDatabase db, FileReferenceService fileRefService, ICacheService cache)
|
||||||
{
|
{
|
||||||
private const string UserPublishersCacheKey = "accounts:{0}:publishers";
|
private const string UserPublishersCacheKey = "accounts:{0}:publishers";
|
||||||
|
private const string UserPublishersBatchCacheKey = "accounts:batch:{0}:publishers";
|
||||||
|
|
||||||
public async Task<List<Publisher>> GetUserPublishers(Guid userId)
|
public async Task<List<Publisher>> GetUserPublishers(Guid userId)
|
||||||
{
|
{
|
||||||
@ -34,6 +35,86 @@ public class PublisherService(AppDatabase db, FileReferenceService fileRefServic
|
|||||||
return publishers;
|
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";
|
private const string PublisherMembersCacheKey = "publishers:{0}:members";
|
||||||
|
|
||||||
public async Task<List<PublisherMember>> GetPublisherMembers(Guid publisherId)
|
public async Task<List<PublisherMember>> GetPublisherMembers(Guid publisherId)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user