436 lines
16 KiB
C#
436 lines
16 KiB
C#
using DysonNetwork.Sphere.Account;
|
|
using DysonNetwork.Sphere.Activity;
|
|
using DysonNetwork.Sphere.Localization;
|
|
using DysonNetwork.Sphere.Publisher;
|
|
using DysonNetwork.Sphere.Storage;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Localization;
|
|
using NodaTime;
|
|
|
|
namespace DysonNetwork.Sphere.Post;
|
|
|
|
public class PostService(
|
|
AppDatabase db,
|
|
FileReferenceService fileRefService,
|
|
IStringLocalizer<NotificationResource> localizer,
|
|
IServiceScopeFactory factory
|
|
)
|
|
{
|
|
private const string PostFileUsageIdentifier = "post";
|
|
|
|
public static List<Post> TruncatePostContent(List<Post> input)
|
|
{
|
|
const int maxLength = 256;
|
|
foreach (var item in input)
|
|
{
|
|
if (!(item.Content?.Length > maxLength)) continue;
|
|
item.Content = item.Content[..maxLength];
|
|
item.IsTruncated = true;
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
public (string title, string content) ChopPostForNotification(Post post)
|
|
{
|
|
var content = !string.IsNullOrEmpty(post.Description)
|
|
? post.Description?.Length >= 40 ? post.Description[..37] + "..." : post.Description
|
|
: post.Content?.Length >= 100
|
|
? string.Concat(post.Content.AsSpan(0, 97), "...")
|
|
: post.Content;
|
|
var title = post.Title ?? (post.Content?.Length >= 10 ? post.Content[..10] + "..." : post.Content);
|
|
if (content is null)
|
|
content = localizer["PostOnlyMedia"];
|
|
if (title is null)
|
|
title = localizer["PostOnlyMedia"];
|
|
return (title, content);
|
|
}
|
|
|
|
public async Task<Post> PostAsync(
|
|
Account.Account user,
|
|
Post post,
|
|
List<string>? attachments = null,
|
|
List<string>? tags = null,
|
|
List<string>? categories = null
|
|
)
|
|
{
|
|
if (post.Empty)
|
|
throw new InvalidOperationException("Cannot create a post with barely no content.");
|
|
|
|
if (post.PublishedAt is not null)
|
|
{
|
|
if (post.PublishedAt.Value.ToDateTimeUtc() < DateTime.UtcNow)
|
|
throw new InvalidOperationException("Cannot create the post which published in the past.");
|
|
}
|
|
else
|
|
{
|
|
post.PublishedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
|
}
|
|
|
|
if (attachments is not null)
|
|
{
|
|
post.Attachments = (await db.Files.Where(e => attachments.Contains(e.Id)).ToListAsync())
|
|
.Select(x => x.ToReferenceObject()).ToList();
|
|
// Re-order the list to match the id list places
|
|
post.Attachments = attachments
|
|
.Select(id => post.Attachments.First(a => a.Id == id))
|
|
.ToList();
|
|
}
|
|
|
|
if (tags is not null)
|
|
{
|
|
var existingTags = await db.PostTags.Where(e => tags.Contains(e.Slug)).ToListAsync();
|
|
|
|
// Determine missing slugs
|
|
var existingSlugs = existingTags.Select(t => t.Slug).ToHashSet();
|
|
var missingSlugs = tags.Where(slug => !existingSlugs.Contains(slug)).ToList();
|
|
|
|
var newTags = missingSlugs.Select(slug => new PostTag { Slug = slug }).ToList();
|
|
if (newTags.Count > 0)
|
|
{
|
|
await db.PostTags.AddRangeAsync(newTags);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
post.Tags = existingTags.Concat(newTags).ToList();
|
|
}
|
|
|
|
if (categories is not null)
|
|
{
|
|
post.Categories = await db.PostCategories.Where(e => categories.Contains(e.Slug)).ToListAsync();
|
|
if (post.Categories.Count != categories.Distinct().Count())
|
|
throw new InvalidOperationException("Categories contains one or more categories that wasn't exists.");
|
|
}
|
|
|
|
db.Posts.Add(post);
|
|
await db.SaveChangesAsync();
|
|
|
|
// Create file references for each attachment
|
|
if (post.Attachments.Any())
|
|
{
|
|
var postResourceId = $"post:{post.Id}";
|
|
foreach (var file in post.Attachments)
|
|
{
|
|
await fileRefService.CreateReferenceAsync(
|
|
file.Id,
|
|
PostFileUsageIdentifier,
|
|
postResourceId
|
|
);
|
|
}
|
|
}
|
|
|
|
if (post.PublishedAt is not null && post.PublishedAt.Value.ToDateTimeUtc() <= DateTime.UtcNow)
|
|
_ = Task.Run(async () =>
|
|
{
|
|
using var scope = factory.CreateScope();
|
|
var pubSub = scope.ServiceProvider.GetRequiredService<PublisherSubscriptionService>();
|
|
await pubSub.NotifySubscriberPost(post);
|
|
});
|
|
|
|
if (post.PublishedAt is not null && post.PublishedAt.Value.ToDateTimeUtc() <= DateTime.UtcNow &&
|
|
post.RepliedPost is not null)
|
|
{
|
|
_ = Task.Run(async () =>
|
|
{
|
|
var sender = post.Publisher;
|
|
using var scope = factory.CreateScope();
|
|
var pub = scope.ServiceProvider.GetRequiredService<PublisherService>();
|
|
var nty = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<PostService>>();
|
|
try
|
|
{
|
|
var members = await pub.GetPublisherMembers(post.RepliedPost.PublisherId);
|
|
foreach (var member in members)
|
|
{
|
|
AccountService.SetCultureInfo(member.Account);
|
|
var (_, content) = ChopPostForNotification(post);
|
|
await nty.SendNotification(
|
|
member.Account,
|
|
"post.replies",
|
|
localizer["PostReplyTitle", sender.Nick],
|
|
null,
|
|
string.IsNullOrWhiteSpace(post.Title)
|
|
? localizer["PostReplyBody", sender.Nick, content]
|
|
: localizer["PostReplyContentBody", sender.Nick, post.Title, content]
|
|
);
|
|
}
|
|
}
|
|
catch (Exception err)
|
|
{
|
|
logger.LogError($"Error when sending post reactions notification: {err.Message} {err.StackTrace}");
|
|
}
|
|
});
|
|
}
|
|
|
|
return post;
|
|
}
|
|
|
|
public async Task<Post> UpdatePostAsync(
|
|
Post post,
|
|
List<string>? attachments = null,
|
|
List<string>? tags = null,
|
|
List<string>? categories = null,
|
|
Instant? publishedAt = null
|
|
)
|
|
{
|
|
if (post.Empty)
|
|
throw new InvalidOperationException("Cannot edit a post to barely no content.");
|
|
|
|
post.EditedAt = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
|
|
|
if (publishedAt is not null)
|
|
{
|
|
// User cannot set the published at to the past to prevent scam,
|
|
// But we can just let the controller set the published at, because when no changes to
|
|
// the published at will blocked the update operation
|
|
if (publishedAt.Value.ToDateTimeUtc() < DateTime.UtcNow)
|
|
throw new InvalidOperationException("Cannot set the published at to the past.");
|
|
}
|
|
|
|
if (attachments is not null)
|
|
{
|
|
var postResourceId = $"post:{post.Id}";
|
|
|
|
// Update resource references using the new file list
|
|
await fileRefService.UpdateResourceFilesAsync(
|
|
postResourceId,
|
|
attachments,
|
|
PostFileUsageIdentifier
|
|
);
|
|
|
|
// Update post attachments by getting files from database
|
|
var files = await db.Files
|
|
.Where(f => attachments.Contains(f.Id))
|
|
.ToListAsync();
|
|
|
|
post.Attachments = files.Select(x => x.ToReferenceObject()).ToList();
|
|
}
|
|
|
|
if (tags is not null)
|
|
{
|
|
var existingTags = await db.PostTags.Where(e => tags.Contains(e.Slug)).ToListAsync();
|
|
|
|
// Determine missing slugs
|
|
var existingSlugs = existingTags.Select(t => t.Slug).ToHashSet();
|
|
var missingSlugs = tags.Where(slug => !existingSlugs.Contains(slug)).ToList();
|
|
|
|
var newTags = missingSlugs.Select(slug => new PostTag { Slug = slug }).ToList();
|
|
if (newTags.Count > 0)
|
|
{
|
|
await db.PostTags.AddRangeAsync(newTags);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
post.Tags = existingTags.Concat(newTags).ToList();
|
|
}
|
|
|
|
if (categories is not null)
|
|
{
|
|
post.Categories = await db.PostCategories.Where(e => categories.Contains(e.Slug)).ToListAsync();
|
|
if (post.Categories.Count != categories.Distinct().Count())
|
|
throw new InvalidOperationException("Categories contains one or more categories that wasn't exists.");
|
|
}
|
|
|
|
db.Update(post);
|
|
await db.SaveChangesAsync();
|
|
|
|
return post;
|
|
}
|
|
|
|
public async Task DeletePostAsync(Post post)
|
|
{
|
|
var postResourceId = $"post:{post.Id}";
|
|
|
|
// Delete all file references for this post
|
|
await fileRefService.DeleteResourceReferencesAsync(postResourceId);
|
|
|
|
db.Posts.Remove(post);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Calculate the total number of votes for a post.
|
|
/// This function helps you save the new reactions.
|
|
/// </summary>
|
|
/// <param name="post">Post that modifying</param>
|
|
/// <param name="reaction">The new / target reaction adding / removing</param>
|
|
/// <param name="op">The original poster account of this post</param>
|
|
/// <param name="isRemoving">Indicate this operation is adding / removing</param>
|
|
/// <param name="isSelfReact">Indicate this reaction is by the original post himself</param>
|
|
/// <param name="sender">The account that creates this reaction</param>
|
|
public async Task<bool> ModifyPostVotes(
|
|
Post post,
|
|
PostReaction reaction,
|
|
Account.Account sender,
|
|
bool isRemoving,
|
|
bool isSelfReact
|
|
)
|
|
{
|
|
var isExistingReaction = await db.Set<PostReaction>()
|
|
.AnyAsync(r => r.PostId == post.Id && r.AccountId == reaction.AccountId);
|
|
|
|
if (isRemoving)
|
|
await db.PostReactions
|
|
.Where(r => r.PostId == post.Id && r.Symbol == reaction.Symbol && r.AccountId == reaction.AccountId)
|
|
.ExecuteDeleteAsync();
|
|
else
|
|
db.PostReactions.Add(reaction);
|
|
|
|
if (isExistingReaction)
|
|
{
|
|
if (!isRemoving)
|
|
await db.SaveChangesAsync();
|
|
return isRemoving;
|
|
}
|
|
|
|
if (isSelfReact)
|
|
{
|
|
await db.SaveChangesAsync();
|
|
return isRemoving;
|
|
}
|
|
|
|
switch (reaction.Attitude)
|
|
{
|
|
case PostReactionAttitude.Positive:
|
|
if (isRemoving) post.Upvotes--;
|
|
else post.Upvotes++;
|
|
break;
|
|
case PostReactionAttitude.Negative:
|
|
if (isRemoving) post.Downvotes--;
|
|
else post.Downvotes++;
|
|
break;
|
|
}
|
|
|
|
await db.SaveChangesAsync();
|
|
|
|
if (!isSelfReact)
|
|
_ = Task.Run(async () =>
|
|
{
|
|
using var scope = factory.CreateScope();
|
|
var pub = scope.ServiceProvider.GetRequiredService<PublisherService>();
|
|
var nty = scope.ServiceProvider.GetRequiredService<NotificationService>();
|
|
var logger = scope.ServiceProvider.GetRequiredService<ILogger<PostService>>();
|
|
try
|
|
{
|
|
var members = await pub.GetPublisherMembers(post.PublisherId);
|
|
foreach (var member in members)
|
|
{
|
|
AccountService.SetCultureInfo(member.Account);
|
|
await nty.SendNotification(
|
|
member.Account,
|
|
"posts.reactions.new",
|
|
localizer["PostReactTitle", sender.Nick],
|
|
null,
|
|
string.IsNullOrWhiteSpace(post.Title)
|
|
? localizer["PostReactBody", sender.Nick, reaction.Symbol]
|
|
: localizer["PostReactContentBody", sender.Nick, reaction.Symbol,
|
|
post.Title]
|
|
);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError($"Error when sending post reactions notification: {ex.Message} {ex.StackTrace}");
|
|
}
|
|
});
|
|
|
|
return isRemoving;
|
|
}
|
|
|
|
public async Task<Dictionary<string, int>> GetPostReactionMap(Guid postId)
|
|
{
|
|
return await db.Set<PostReaction>()
|
|
.Where(r => r.PostId == postId)
|
|
.GroupBy(r => r.Symbol)
|
|
.ToDictionaryAsync(
|
|
g => g.Key,
|
|
g => g.Count()
|
|
);
|
|
}
|
|
|
|
public async Task<Dictionary<Guid, Dictionary<string, int>>> GetPostReactionMapBatch(List<Guid> postIds)
|
|
{
|
|
return await db.Set<PostReaction>()
|
|
.Where(r => postIds.Contains(r.PostId))
|
|
.GroupBy(r => r.PostId)
|
|
.ToDictionaryAsync(
|
|
g => g.Key,
|
|
g => g.GroupBy(r => r.Symbol)
|
|
.ToDictionary(
|
|
sg => sg.Key,
|
|
sg => sg.Count()
|
|
)
|
|
);
|
|
}
|
|
|
|
public async Task<List<Post>> LoadPublishers(List<Post> posts)
|
|
{
|
|
var publisherIds = posts
|
|
.SelectMany<Post, Guid?>(e =>
|
|
[
|
|
e.PublisherId,
|
|
e.RepliedPost?.PublisherId,
|
|
e.ForwardedPost?.PublisherId
|
|
])
|
|
.Where(e => e != null)
|
|
.Distinct()
|
|
.ToList();
|
|
if (publisherIds.Count == 0) return posts;
|
|
|
|
var publishers = await db.Publishers
|
|
.Where(e => publisherIds.Contains(e.Id))
|
|
.ToDictionaryAsync(e => e.Id);
|
|
|
|
foreach (var post in posts)
|
|
{
|
|
if (publishers.TryGetValue(post.PublisherId, out var publisher))
|
|
post.Publisher = publisher;
|
|
|
|
if (post.RepliedPost?.PublisherId != null &&
|
|
publishers.TryGetValue(post.RepliedPost.PublisherId, out var repliedPublisher))
|
|
post.RepliedPost.Publisher = repliedPublisher;
|
|
|
|
if (post.ForwardedPost?.PublisherId != null &&
|
|
publishers.TryGetValue(post.ForwardedPost.PublisherId, out var forwardedPublisher))
|
|
post.ForwardedPost.Publisher = forwardedPublisher;
|
|
}
|
|
|
|
return posts;
|
|
}
|
|
}
|
|
|
|
public static class PostQueryExtensions
|
|
{
|
|
public static IQueryable<Post> FilterWithVisibility(
|
|
this IQueryable<Post> source,
|
|
Account.Account? currentUser,
|
|
List<Guid> userFriends,
|
|
List<Publisher.Publisher> publishers,
|
|
bool isListing = false
|
|
)
|
|
{
|
|
var now = Instant.FromDateTimeUtc(DateTime.UtcNow);
|
|
var publishersId = publishers.Select(e => e.Id).ToList();
|
|
|
|
source = isListing switch
|
|
{
|
|
true when currentUser is not null => source.Where(e =>
|
|
e.Visibility != PostVisibility.Unlisted || publishersId.Contains(e.PublisherId)),
|
|
true => source.Where(e => e.Visibility != PostVisibility.Unlisted),
|
|
_ => source
|
|
};
|
|
|
|
if (currentUser is null)
|
|
return source
|
|
.Where(e => e.PublishedAt != null && now >= e.PublishedAt)
|
|
.Where(e => e.Visibility == PostVisibility.Public);
|
|
|
|
return source
|
|
.Where(e => (e.PublishedAt != null && now >= e.PublishedAt) || publishersId.Contains(e.PublisherId))
|
|
.Where(e => e.Visibility != PostVisibility.Private || publishersId.Contains(e.PublisherId))
|
|
.Where(e => e.Visibility != PostVisibility.Friends ||
|
|
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
|
|
publishersId.Contains(e.PublisherId));
|
|
;
|
|
}
|
|
} |