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)
|
||||
{
|
||||
private const string UserFriendsCacheKeyPrefix = "user:friends:";
|
||||
private const string UserFriendsCacheKeyPrefix = "accounts:friends:";
|
||||
|
||||
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.
|
||||
/// </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));
|
||||
}
|
||||
}
|
@ -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();
|
||||
|
@ -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,
|
||||
|
@ -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));
|
||||
;
|
||||
}
|
||||
}
|
@ -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)
|
||||
|
Loading…
x
Reference in New Issue
Block a user