1438 lines
52 KiB
C#
1438 lines
52 KiB
C#
using System.Text;
|
|
using System.Text.Json;
|
|
using System.Text.RegularExpressions;
|
|
using AngleSharp.Common;
|
|
using DysonNetwork.Shared;
|
|
using DysonNetwork.Shared.Cache;
|
|
using DysonNetwork.Shared.Proto;
|
|
using DysonNetwork.Shared.Registry;
|
|
using DysonNetwork.Sphere.WebReader;
|
|
using DysonNetwork.Sphere.Localization;
|
|
using DysonNetwork.Sphere.Poll;
|
|
using DysonNetwork.Sphere.Publisher;
|
|
using Markdig;
|
|
using Markdig.Extensions.Mathematics;
|
|
using Markdig.Syntax;
|
|
using Markdig.Syntax.Inlines;
|
|
using Microsoft.EntityFrameworkCore;
|
|
using Microsoft.Extensions.Localization;
|
|
using NodaTime;
|
|
using DysonNetwork.Shared.Models;
|
|
|
|
namespace DysonNetwork.Sphere.Post;
|
|
|
|
public partial class PostService(
|
|
AppDatabase db,
|
|
IStringLocalizer<NotificationResource> localizer,
|
|
IServiceScopeFactory factory,
|
|
FlushBufferService flushBuffer,
|
|
ICacheService cache,
|
|
ILogger<PostService> logger,
|
|
FileService.FileServiceClient files,
|
|
FileReferenceService.FileReferenceServiceClient fileRefs,
|
|
Publisher.PublisherService ps,
|
|
WebReaderService reader,
|
|
AccountService.AccountServiceClient accounts
|
|
)
|
|
{
|
|
private const string PostFileUsageIdentifier = "post";
|
|
|
|
private static List<SnPost> TruncatePostContent(List<SnPost> input)
|
|
{
|
|
const int maxLength = 256;
|
|
const int embedMaxLength = 80;
|
|
var pipeline = new MarkdownPipelineBuilder()
|
|
.UseAdvancedExtensions()
|
|
.Build();
|
|
|
|
foreach (var item in input)
|
|
{
|
|
if (item.Content?.Length > maxLength)
|
|
{
|
|
item.Content = TruncateMarkdownSafely(item.Content, maxLength, pipeline);
|
|
item.IsTruncated = true;
|
|
}
|
|
|
|
// Truncate replied post content with shorter embed length
|
|
if (item.RepliedPost?.Content?.Length > embedMaxLength)
|
|
{
|
|
item.RepliedPost.Content = TruncateMarkdownSafely(item.RepliedPost.Content, embedMaxLength, pipeline);
|
|
item.RepliedPost.IsTruncated = true;
|
|
}
|
|
|
|
// Truncate forwarded post content with shorter embed length
|
|
if (item.ForwardedPost?.Content?.Length > embedMaxLength)
|
|
{
|
|
item.ForwardedPost.Content = TruncateMarkdownSafely(item.ForwardedPost.Content, embedMaxLength, pipeline);
|
|
item.ForwardedPost.IsTruncated = true;
|
|
}
|
|
}
|
|
|
|
return input;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Truncates markdown content safely by cutting at safe inline boundaries
|
|
/// </summary>
|
|
private static string TruncateMarkdownSafely(string markdown, int maxLength, MarkdownPipeline pipeline)
|
|
{
|
|
if (string.IsNullOrEmpty(markdown) || markdown.Length <= maxLength)
|
|
return markdown;
|
|
|
|
var document = Markdown.Parse(markdown, pipeline);
|
|
var resultBuilder = new StringBuilder();
|
|
var currentLength = 0;
|
|
var truncated = false;
|
|
var previousWasBlock = false;
|
|
|
|
foreach (var block in document)
|
|
{
|
|
if (truncated)
|
|
break;
|
|
|
|
if (block is ParagraphBlock paragraph)
|
|
{
|
|
var (truncatedPara, paraLength, wasTruncated) =
|
|
TruncateParagraph(paragraph, markdown, maxLength - currentLength);
|
|
|
|
if (truncatedPara.Length > 0)
|
|
{
|
|
if (previousWasBlock)
|
|
{
|
|
resultBuilder.AppendLine();
|
|
}
|
|
resultBuilder.Append(truncatedPara);
|
|
currentLength += paraLength;
|
|
previousWasBlock = true;
|
|
}
|
|
|
|
if (wasTruncated)
|
|
{
|
|
truncated = true;
|
|
break;
|
|
}
|
|
}
|
|
else
|
|
{
|
|
var blockText = GetBlockText(block, markdown);
|
|
var blockLength = blockText.Length;
|
|
|
|
if (currentLength + blockLength > maxLength)
|
|
{
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
if (previousWasBlock)
|
|
{
|
|
resultBuilder.AppendLine();
|
|
}
|
|
|
|
resultBuilder.Append(blockText);
|
|
currentLength += blockLength;
|
|
previousWasBlock = true;
|
|
}
|
|
}
|
|
|
|
var result = CollapseNewlines(resultBuilder.ToString().TrimEnd());
|
|
|
|
if (truncated)
|
|
{
|
|
result += "...";
|
|
}
|
|
|
|
return result;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Truncates a paragraph at safe inline boundaries
|
|
/// </summary>
|
|
private static (string markdown, int length, bool truncated) TruncateParagraph(
|
|
ParagraphBlock paragraph, string originalMarkdown, int remainingLength)
|
|
{
|
|
if (paragraph.Inline == null || remainingLength <= 0)
|
|
return ("", 0, false);
|
|
|
|
var resultBuilder = new StringBuilder();
|
|
var currentLength = 0;
|
|
var truncated = false;
|
|
|
|
foreach (var inline in paragraph.Inline)
|
|
{
|
|
var inlineText = GetInlineText(inline, originalMarkdown);
|
|
|
|
if (currentLength + inlineText.Length > remainingLength)
|
|
{
|
|
var availableChars = remainingLength - currentLength;
|
|
if (availableChars > 3)
|
|
{
|
|
var truncatedText = inlineText[..availableChars];
|
|
var lastSpace = truncatedText.LastIndexOf(' ');
|
|
if (lastSpace > 0)
|
|
{
|
|
truncatedText = truncatedText[..lastSpace];
|
|
}
|
|
resultBuilder.Append(truncatedText);
|
|
currentLength += truncatedText.Length;
|
|
}
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
resultBuilder.Append(inlineText);
|
|
currentLength += inlineText.Length;
|
|
}
|
|
|
|
return (resultBuilder.ToString(), currentLength, truncated);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the markdown text representation of a block
|
|
/// </summary>
|
|
private static string GetBlockText(Block block, string originalMarkdown = "")
|
|
{
|
|
return block switch
|
|
{
|
|
ParagraphBlock paragraph => GetParagraphText(paragraph, originalMarkdown),
|
|
HeadingBlock heading => GetHeadingText(heading, originalMarkdown),
|
|
MathBlock math => GetMathBlockText(math),
|
|
FencedCodeBlock code => GetFencedCodeText(code),
|
|
CodeBlock code => GetIndentedCodeText(code),
|
|
ListBlock list => GetListText(list),
|
|
QuoteBlock quote => GetQuoteText(quote),
|
|
ThematicBreakBlock => "---",
|
|
_ => ""
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for a paragraph block
|
|
/// </summary>
|
|
private static string GetParagraphText(ParagraphBlock paragraph, string originalMarkdown = "")
|
|
{
|
|
if (paragraph.Inline == null) return "";
|
|
return GetInlinesText(paragraph.Inline, originalMarkdown);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for a heading block
|
|
/// </summary>
|
|
private static string GetHeadingText(HeadingBlock heading, string originalMarkdown = "")
|
|
{
|
|
var prefix = new string('#', heading.Level);
|
|
var text = heading.Inline != null ? GetInlinesText(heading.Inline, originalMarkdown) : "";
|
|
return $"{prefix} {text}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for a fenced code block
|
|
/// </summary>
|
|
private static string GetFencedCodeText(FencedCodeBlock code)
|
|
{
|
|
var lines = code.Lines;
|
|
var sb = new StringBuilder();
|
|
var fenceChar = code.FencedChar;
|
|
var fenceLength = code.OpeningFencedCharCount;
|
|
|
|
if (fenceLength > 0)
|
|
{
|
|
sb.Append(new string(fenceChar, fenceLength));
|
|
if (!string.IsNullOrEmpty(code.Info))
|
|
{
|
|
sb.Append(code.Info);
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
sb.AppendLine(line.ToString());
|
|
}
|
|
|
|
if (fenceLength > 0)
|
|
{
|
|
sb.Append(new string(fenceChar, fenceLength));
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for a math block (LaTeX)
|
|
/// </summary>
|
|
private static string GetMathBlockText(MathBlock math)
|
|
{
|
|
var lines = math.CodeBlockLines;
|
|
var sb = new StringBuilder();
|
|
var fenceChar = math.FencedChar;
|
|
var fenceLength = math.OpeningFencedCharCount;
|
|
|
|
if (fenceLength > 0)
|
|
{
|
|
sb.Append(new string(fenceChar, fenceLength));
|
|
if (!math.UnescapedInfo.IsEmpty)
|
|
{
|
|
sb.Append(math.UnescapedInfo.ToString());
|
|
}
|
|
sb.AppendLine();
|
|
}
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
sb.AppendLine(line.ToString());
|
|
}
|
|
|
|
if (fenceLength > 0)
|
|
{
|
|
sb.Append(new string(fenceChar, fenceLength));
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for an indented code block
|
|
/// </summary>
|
|
private static string GetIndentedCodeText(CodeBlock code)
|
|
{
|
|
var lines = code.Lines;
|
|
var sb = new StringBuilder();
|
|
|
|
foreach (var line in lines)
|
|
{
|
|
sb.Append(" ");
|
|
sb.AppendLine(line.ToString());
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for a list block
|
|
/// </summary>
|
|
private static string GetListText(ListBlock list)
|
|
{
|
|
var sb = new StringBuilder();
|
|
var index = 0;
|
|
|
|
foreach (var item in list)
|
|
{
|
|
if (item is ListItemBlock listItem)
|
|
{
|
|
if (list.IsOrdered)
|
|
{
|
|
index++;
|
|
sb.Append($"{index}. ");
|
|
}
|
|
else
|
|
{
|
|
sb.Append(list.BulletType != '\0' ? list.BulletType.ToString() : "-");
|
|
sb.Append(" ");
|
|
}
|
|
|
|
foreach (var child in listItem)
|
|
{
|
|
sb.AppendLine(GetBlockText(child));
|
|
}
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for a quote block
|
|
/// </summary>
|
|
private static string GetQuoteText(QuoteBlock quote)
|
|
{
|
|
var sb = new StringBuilder();
|
|
|
|
foreach (var block in quote)
|
|
{
|
|
var blockText = GetBlockText(block);
|
|
var lines = blockText.Split('\n');
|
|
foreach (var line in lines)
|
|
{
|
|
sb.Append("> ");
|
|
sb.AppendLine(line.TrimEnd());
|
|
}
|
|
}
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Truncates a paragraph at safe inline boundaries
|
|
/// </summary>
|
|
private static (string markdown, int length, bool truncated) TruncateParagraph(
|
|
ParagraphBlock paragraph, int remainingLength, MarkdownPipeline pipeline)
|
|
{
|
|
if (paragraph.Inline == null || remainingLength <= 0)
|
|
return ("", 0, false);
|
|
|
|
var resultBuilder = new StringBuilder();
|
|
var currentLength = 0;
|
|
var truncated = false;
|
|
|
|
foreach (var inline in paragraph.Inline)
|
|
{
|
|
var inlineText = GetInlineText(inline);
|
|
|
|
if (currentLength + inlineText.Length > remainingLength)
|
|
{
|
|
var availableChars = remainingLength - currentLength;
|
|
if (availableChars > 3)
|
|
{
|
|
var truncatedText = inlineText[..availableChars];
|
|
var lastSpace = truncatedText.LastIndexOf(' ');
|
|
if (lastSpace > 0)
|
|
{
|
|
truncatedText = truncatedText[..lastSpace];
|
|
}
|
|
resultBuilder.Append(truncatedText);
|
|
currentLength += truncatedText.Length;
|
|
}
|
|
truncated = true;
|
|
break;
|
|
}
|
|
|
|
resultBuilder.Append(inlineText);
|
|
currentLength += inlineText.Length;
|
|
}
|
|
|
|
return (resultBuilder.ToString(), currentLength, truncated);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets text representation of an inline element
|
|
/// </summary>
|
|
private static string GetInlineText(Inline inline, string originalMarkdown = "")
|
|
{
|
|
return inline switch
|
|
{
|
|
LiteralInline literal => literal.Content.ToString(),
|
|
CodeInline code => $"`{code.Content}`",
|
|
EmphasisInline emph => emph.DelimiterCount == 2 ? $"**{GetInlinesText(emph, originalMarkdown)}**" : $"*{GetInlinesText(emph, originalMarkdown)}*",
|
|
LinkInline link => $"[{GetInlinesText(link, originalMarkdown)}]({link.Url})",
|
|
MathInline math => GetMathInlineText(math, originalMarkdown),
|
|
LineBreakInline => "\n",
|
|
ContainerInline container => GetInlinesText(container, originalMarkdown),
|
|
_ => ""
|
|
};
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets markdown text for a math inline (LaTeX)
|
|
/// </summary>
|
|
private static string GetMathInlineText(MathInline math, string originalMarkdown)
|
|
{
|
|
var delimiter = math.Delimiter;
|
|
var count = math.DelimiterCount;
|
|
var fence = new string(delimiter, count);
|
|
|
|
// Extract content from original markdown using span
|
|
if (!string.IsNullOrEmpty(originalMarkdown) && math.Span.End - math.Span.Start > count * 2)
|
|
{
|
|
var fullContent = originalMarkdown.Substring(math.Span.Start, math.Span.End - math.Span.Start);
|
|
return fullContent;
|
|
}
|
|
|
|
// Fallback: use delimiter + empty content
|
|
return $"{fence}{fence}";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets combined text from container inline elements
|
|
/// </summary>
|
|
private static string GetInlinesText(ContainerInline container, string originalMarkdown = "")
|
|
{
|
|
var sb = new StringBuilder();
|
|
foreach (var child in container)
|
|
{
|
|
sb.Append(GetInlineText(child, originalMarkdown));
|
|
}
|
|
return sb.ToString();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Collapses multiple consecutive newlines to max 2
|
|
/// </summary>
|
|
private static string CollapseNewlines(string markdown)
|
|
{
|
|
return Regex.Replace(markdown, @"\n{3,}", "\n\n");
|
|
}
|
|
|
|
public (string title, string content) ChopPostForNotification(SnPost 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);
|
|
content ??= localizer["PostOnlyMedia"];
|
|
title ??= localizer["PostOnlyMedia"];
|
|
return (title, content);
|
|
}
|
|
|
|
public async Task<SnPost> PostAsync(
|
|
SnPost 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)
|
|
{
|
|
var queryRequest = new GetFileBatchRequest();
|
|
queryRequest.Ids.AddRange(attachments);
|
|
var queryResponse = await files.GetFileBatchAsync(queryRequest);
|
|
|
|
post.Attachments = queryResponse.Files.Select(SnCloudFileReferenceObject.FromProtoValue).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 SnPostTag { 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.Count != 0)
|
|
{
|
|
var request = new CreateReferenceBatchRequest
|
|
{
|
|
Usage = PostFileUsageIdentifier,
|
|
ResourceId = post.ResourceIdentifier,
|
|
};
|
|
request.FilesId.AddRange(post.Attachments.Select(a => a.Id));
|
|
await fileRefs.CreateReferenceBatchAsync(request);
|
|
}
|
|
|
|
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<Publisher.PublisherService>();
|
|
var nty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
|
|
var accounts = scope.ServiceProvider.GetRequiredService<AccountService.AccountServiceClient>();
|
|
try
|
|
{
|
|
var members = await pub.GetPublisherMembers(post.RepliedPost.PublisherId);
|
|
var queryRequest = new GetAccountBatchRequest();
|
|
queryRequest.Id.AddRange(members.Select(m => m.AccountId.ToString()));
|
|
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
|
|
foreach (var member in queryResponse.Accounts)
|
|
{
|
|
if (member is null) continue;
|
|
CultureService.SetCultureInfo(member);
|
|
await nty.SendPushNotificationToUserAsync(
|
|
new SendPushNotificationToUserRequest
|
|
{
|
|
UserId = member.Id,
|
|
Notification = new PushNotification
|
|
{
|
|
Topic = "post.replies",
|
|
Title = localizer["PostReplyTitle", sender.Nick],
|
|
Body = string.IsNullOrWhiteSpace(post.Title)
|
|
? localizer["PostReplyBody", sender.Nick, ChopPostForNotification(post).content]
|
|
: localizer["PostReplyContentBody", sender.Nick, post.Title,
|
|
ChopPostForNotification(post).content],
|
|
IsSavable = true,
|
|
ActionUri = $"/posts/{post.Id}"
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
catch (Exception err)
|
|
{
|
|
logger.LogError($"Error when sending post reactions notification: {err.Message} {err.StackTrace}");
|
|
}
|
|
});
|
|
}
|
|
|
|
// Process link preview in the background to avoid delaying post creation
|
|
_ = Task.Run(async () => await CreateLinkPreviewAsync(post));
|
|
|
|
return post;
|
|
}
|
|
|
|
public async Task<SnPost> UpdatePostAsync(
|
|
SnPost 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
|
|
var request = new UpdateResourceFilesRequest
|
|
{
|
|
ResourceId = postResourceId,
|
|
Usage = PostFileUsageIdentifier,
|
|
};
|
|
request.FileIds.AddRange(attachments);
|
|
await fileRefs.UpdateResourceFilesAsync(request);
|
|
|
|
// Update post attachments by getting files from database
|
|
var queryRequest = new GetFileBatchRequest();
|
|
queryRequest.Ids.AddRange(attachments);
|
|
var queryResponse = await files.GetFileBatchAsync(queryRequest);
|
|
|
|
post.Attachments = queryResponse.Files.Select(SnCloudFileReferenceObject.FromProtoValue).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 SnPostTag { 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();
|
|
|
|
// Process link preview in the background to avoid delaying post update
|
|
_ = Task.Run(async () => await CreateLinkPreviewAsync(post));
|
|
|
|
return post;
|
|
}
|
|
|
|
[GeneratedRegex(@"https?://(?!.*\.\w{1,6}(?:[#?]|$))[^\s]+", RegexOptions.IgnoreCase)]
|
|
private static partial Regex GetLinkRegex();
|
|
|
|
public async Task<SnPost> PreviewPostLinkAsync(SnPost item)
|
|
{
|
|
if (item.Type != Shared.Models.PostType.Moment || string.IsNullOrEmpty(item.Content)) return item;
|
|
|
|
// Find all URLs in the content
|
|
var matches = GetLinkRegex().Matches(item.Content);
|
|
|
|
if (matches.Count == 0)
|
|
return item;
|
|
|
|
// Initialize meta dictionary if null
|
|
item.Meta ??= new Dictionary<string, object>();
|
|
|
|
// Initialize the embeds' array if it doesn't exist
|
|
if (!item.Meta.TryGetValue("embeds", out var existingEmbeds) || existingEmbeds is not List<EmbeddableBase>)
|
|
{
|
|
item.Meta["embeds"] = new List<Dictionary<string, object>>();
|
|
}
|
|
|
|
var embeds = (List<Dictionary<string, object>>)item.Meta["embeds"];
|
|
|
|
// Process up to 3 links to avoid excessive processing
|
|
const int maxLinks = 3;
|
|
var processedLinks = 0;
|
|
foreach (Match match in matches)
|
|
{
|
|
if (processedLinks >= maxLinks)
|
|
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 reader.GetLinkPreviewAsync(url);
|
|
embeds.Add(EmbeddableBase.ToDictionary(linkEmbed));
|
|
processedLinks++;
|
|
}
|
|
catch
|
|
{
|
|
// ignored
|
|
}
|
|
}
|
|
|
|
item.Meta["embeds"] = embeds;
|
|
|
|
return item;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Process link previews for a post in the background
|
|
/// This method is designed to be called from a background task
|
|
/// </summary>
|
|
/// <param name="post">The post to process link previews for</param>
|
|
private async Task CreateLinkPreviewAsync(SnPost post)
|
|
{
|
|
try
|
|
{
|
|
// Create a new scope for database operations
|
|
using var scope = factory.CreateScope();
|
|
var dbContext = scope.ServiceProvider.GetRequiredService<AppDatabase>();
|
|
|
|
// Preview the links in the post
|
|
var updatedPost = await PreviewPostLinkAsync(post);
|
|
|
|
// If embeds were added, update the post in the database
|
|
if (updatedPost.Meta != null &&
|
|
updatedPost.Meta.TryGetValue("embeds", out var embeds) &&
|
|
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);
|
|
if (dbPost != null)
|
|
{
|
|
// Update the meta field with the new embeds
|
|
dbPost.Meta ??= new Dictionary<string, object>();
|
|
dbPost.Meta["embeds"] = embedsList;
|
|
|
|
// Save changes to the database
|
|
dbContext.Update(dbPost);
|
|
await dbContext.SaveChangesAsync();
|
|
|
|
logger.LogDebug("Updated post {PostId} with {EmbedCount} link previews", post.Id, embedsList.Count);
|
|
}
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
// Log errors but don't rethrow - this is a background task
|
|
logger.LogError(ex, "Error processing link previews for post {PostId}", post.Id);
|
|
}
|
|
}
|
|
|
|
public async Task DeletePostAsync(SnPost post)
|
|
{
|
|
// Delete all file references for this post
|
|
await fileRefs.DeleteResourceReferencesAsync(
|
|
new DeleteResourceReferencesRequest { ResourceId = post.ResourceIdentifier }
|
|
);
|
|
|
|
var now = SystemClock.Instance.GetCurrentInstant();
|
|
await using var transaction = await db.Database.BeginTransactionAsync();
|
|
try
|
|
{
|
|
await db.PostReactions
|
|
.Where(r => r.PostId == post.Id)
|
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.DeletedAt, now));
|
|
await db.Posts
|
|
.Where(p => p.RepliedPostId == post.Id)
|
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.RepliedGone, true));
|
|
await db.Posts
|
|
.Where(p => p.ForwardedPostId == post.Id)
|
|
.ExecuteUpdateAsync(p => p.SetProperty(x => x.ForwardedGone, true));
|
|
|
|
db.Posts.Remove(post);
|
|
await db.SaveChangesAsync();
|
|
|
|
await transaction.CommitAsync();
|
|
}
|
|
catch (Exception)
|
|
{
|
|
await transaction.RollbackAsync();
|
|
throw;
|
|
}
|
|
}
|
|
|
|
public async Task<SnPost> PinPostAsync(SnPost post, Account currentUser, Shared.Models.PostPinMode pinMode)
|
|
{
|
|
var accountId = Guid.Parse(currentUser.Id);
|
|
if (post.RepliedPostId != null)
|
|
{
|
|
if (pinMode != Shared.Models.PostPinMode.ReplyPage)
|
|
throw new InvalidOperationException("Replies can only be pinned in the reply page.");
|
|
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
|
|
|
|
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId,
|
|
Shared.Models.PublisherMemberRole.Editor))
|
|
throw new InvalidOperationException("Only editors of original post can pin replies.");
|
|
|
|
post.PinMode = pinMode;
|
|
}
|
|
else
|
|
{
|
|
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Shared.Models.PublisherMemberRole.Editor))
|
|
throw new InvalidOperationException("Only editors can pin replies.");
|
|
|
|
post.PinMode = pinMode;
|
|
}
|
|
|
|
db.Update(post);
|
|
await db.SaveChangesAsync();
|
|
|
|
return post;
|
|
}
|
|
|
|
public async Task<SnPost> UnpinPostAsync(SnPost post, Account currentUser)
|
|
{
|
|
var accountId = Guid.Parse(currentUser.Id);
|
|
if (post.RepliedPostId != null)
|
|
{
|
|
if (post.RepliedPost == null) throw new ArgumentNullException(nameof(post.RepliedPost));
|
|
|
|
if (!await ps.IsMemberWithRole(post.RepliedPost.PublisherId, accountId,
|
|
Shared.Models.PublisherMemberRole.Editor))
|
|
throw new InvalidOperationException("Only editors of original post can unpin replies.");
|
|
}
|
|
else
|
|
{
|
|
if (!await ps.IsMemberWithRole(post.PublisherId, accountId, Shared.Models.PublisherMemberRole.Editor))
|
|
throw new InvalidOperationException("Only editors can unpin posts.");
|
|
}
|
|
|
|
post.PinMode = null;
|
|
db.Update(post);
|
|
await db.SaveChangesAsync();
|
|
|
|
return post;
|
|
}
|
|
|
|
/// <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(
|
|
SnPost post,
|
|
SnPostReaction reaction,
|
|
Account sender,
|
|
bool isRemoving,
|
|
bool isSelfReact
|
|
)
|
|
{
|
|
var isExistingReaction = await db.Set<SnPostReaction>()
|
|
.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 Shared.Models.PostReactionAttitude.Positive:
|
|
if (isRemoving) post.Upvotes--;
|
|
else post.Upvotes++;
|
|
break;
|
|
case Shared.Models.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<Publisher.PublisherService>();
|
|
var nty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
|
|
var accounts = scope.ServiceProvider.GetRequiredService<AccountService.AccountServiceClient>();
|
|
try
|
|
{
|
|
var members = await pub.GetPublisherMembers(post.PublisherId);
|
|
var queryRequest = new GetAccountBatchRequest();
|
|
queryRequest.Id.AddRange(members.Select(m => m.AccountId.ToString()));
|
|
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
|
|
foreach (var member in queryResponse.Accounts)
|
|
{
|
|
if (member is null) continue;
|
|
CultureService.SetCultureInfo(member);
|
|
|
|
await nty.SendPushNotificationToUserAsync(
|
|
new SendPushNotificationToUserRequest
|
|
{
|
|
UserId = member.Id,
|
|
Notification = new PushNotification
|
|
{
|
|
Topic = "posts.reactions.new",
|
|
Title = localizer["PostReactTitle", sender.Nick],
|
|
Body = string.IsNullOrWhiteSpace(post.Title)
|
|
? localizer["PostReactBody", sender.Nick, reaction.Symbol]
|
|
: localizer["PostReactContentBody", sender.Nick, reaction.Symbol,
|
|
post.Title],
|
|
IsSavable = true,
|
|
ActionUri = $"/posts/{post.Id}"
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
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<SnPostReaction>()
|
|
.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<SnPostReaction>()
|
|
.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<Dictionary<Guid, Dictionary<string, bool>>> GetPostReactionMadeMapBatch(List<Guid> postIds,
|
|
Guid accountId)
|
|
{
|
|
var reactions = await db.Set<SnPostReaction>()
|
|
.Where(r => postIds.Contains(r.PostId) && r.AccountId == accountId)
|
|
.Select(r => new { r.PostId, r.Symbol })
|
|
.ToListAsync();
|
|
|
|
return postIds.ToDictionary(
|
|
postId => postId,
|
|
postId => reactions
|
|
.Where(r => r.PostId == postId)
|
|
.ToDictionary(
|
|
r => r.Symbol,
|
|
_ => true
|
|
)
|
|
);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Increases the view count for a post.
|
|
/// Uses the flush buffer service to batch database updates for better performance.
|
|
/// </summary>
|
|
/// <param name="postId">The ID of the post to mark as viewed</param>
|
|
/// <param name="viewerId">Optional viewer ID for unique view counting (anonymous if null)</param>
|
|
/// <returns>Task representing the asynchronous operation</returns>
|
|
public async Task IncreaseViewCount(Guid postId, string? viewerId = null)
|
|
{
|
|
// Check if this view is already counted in cache to prevent duplicate counting
|
|
if (!string.IsNullOrEmpty(viewerId))
|
|
{
|
|
var cacheKey = $"post:view:{postId}:{viewerId}";
|
|
var (found, _) = await cache.GetAsyncWithStatus<bool>(cacheKey);
|
|
|
|
if (found)
|
|
{
|
|
// Already viewed by this user recently, don't count again
|
|
return;
|
|
}
|
|
|
|
// Mark as viewed in cache for 1 hour to prevent duplicate counting
|
|
await cache.SetAsync(cacheKey, true, TimeSpan.FromHours(1));
|
|
}
|
|
|
|
// Add view info to flush buffer
|
|
flushBuffer.Enqueue(new PostViewInfo
|
|
{
|
|
PostId = postId,
|
|
ViewerId = viewerId,
|
|
ViewedAt = Instant.FromDateTimeUtc(DateTime.UtcNow)
|
|
});
|
|
}
|
|
|
|
public async Task<List<SnPost>> LoadPublishers(List<SnPost> posts)
|
|
{
|
|
var publisherIds = posts
|
|
.SelectMany<SnPost, 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;
|
|
}
|
|
|
|
await ps.LoadIndividualPublisherAccounts(publishers.Values);
|
|
|
|
return posts;
|
|
}
|
|
|
|
public async Task<List<SnPost>> LoadInteractive(List<SnPost> posts, Account? currentUser = null)
|
|
{
|
|
if (posts.Count == 0) return posts;
|
|
|
|
var postsId = posts.Select(e => e.Id).ToList();
|
|
|
|
var reactionMaps = await GetPostReactionMapBatch(postsId);
|
|
var reactionMadeMap = currentUser is not null
|
|
? await GetPostReactionMadeMapBatch(postsId, Guid.Parse(currentUser.Id))
|
|
: new Dictionary<Guid, Dictionary<string, bool>>();
|
|
var repliesCountMap = await GetPostRepliesCountBatch(postsId);
|
|
|
|
// Load user friends if the current user exists
|
|
List<SnPublisher> publishers = [];
|
|
List<Guid> userFriends = [];
|
|
if (currentUser is not null)
|
|
{
|
|
var friendsResponse = await accounts.ListFriendsAsync(new ListRelationshipSimpleRequest { AccountId = currentUser.Id });
|
|
userFriends = friendsResponse.AccountsId.Select(Guid.Parse).ToList();
|
|
publishers = await ps.GetUserPublishers(Guid.Parse(currentUser.Id));
|
|
}
|
|
|
|
foreach (var post in posts)
|
|
{
|
|
// Set reaction count
|
|
post.ReactionsCount = reactionMaps.TryGetValue(post.Id, out var count)
|
|
? count
|
|
: new Dictionary<string, int>();
|
|
|
|
// Set reaction made status
|
|
post.ReactionsMade = reactionMadeMap.TryGetValue(post.Id, out var made)
|
|
? made
|
|
: [];
|
|
|
|
// Set reply count
|
|
post.RepliesCount = repliesCountMap.TryGetValue(post.Id, out var repliesCount)
|
|
? repliesCount
|
|
: 0;
|
|
|
|
// Check visibility for replied post
|
|
if (post.RepliedPost != null)
|
|
{
|
|
if (!CanViewPost(post.RepliedPost, currentUser, publishers, userFriends))
|
|
{
|
|
post.RepliedPost = null;
|
|
post.RepliedGone = true;
|
|
}
|
|
}
|
|
|
|
// Check visibility for forwarded post
|
|
if (post.ForwardedPost != null)
|
|
{
|
|
if (!CanViewPost(post.ForwardedPost, currentUser, publishers, userFriends))
|
|
{
|
|
post.ForwardedPost = null;
|
|
post.ForwardedGone = true;
|
|
}
|
|
}
|
|
|
|
// Track view for each post in the list
|
|
if (currentUser != null)
|
|
await IncreaseViewCount(post.Id, currentUser.Id);
|
|
else
|
|
await IncreaseViewCount(post.Id);
|
|
}
|
|
|
|
return posts;
|
|
}
|
|
|
|
private bool CanViewPost(SnPost post, Account? currentUser, List<SnPublisher> publishers, List<Guid> userFriends)
|
|
{
|
|
var now = SystemClock.Instance.GetCurrentInstant();
|
|
var publishersId = publishers.Select(e => e.Id).ToList();
|
|
|
|
// Check if post is deleted
|
|
if (post.DeletedAt != null)
|
|
return false;
|
|
|
|
if (currentUser is null)
|
|
{
|
|
// Anonymous user can only view public posts that are published
|
|
return post.PublishedAt != null && now >= post.PublishedAt && post.Visibility == Shared.Models.PostVisibility.Public;
|
|
}
|
|
|
|
// Check publication status - either published or user is member
|
|
var isPublished = post.PublishedAt != null && now >= post.PublishedAt;
|
|
var isMember = publishersId.Contains(post.PublisherId);
|
|
if (!isPublished && !isMember)
|
|
return false;
|
|
|
|
// Check visibility
|
|
if (post.Visibility == Shared.Models.PostVisibility.Private && !isMember)
|
|
return false;
|
|
|
|
if (post.Visibility == Shared.Models.PostVisibility.Friends &&
|
|
!(post.Publisher.AccountId.HasValue && userFriends.Contains(post.Publisher.AccountId.Value) || isMember))
|
|
return false;
|
|
|
|
// Public and Unlisted are allowed
|
|
return true;
|
|
}
|
|
|
|
private async Task<Dictionary<Guid, int>> GetPostRepliesCountBatch(List<Guid> postIds)
|
|
{
|
|
return await db.Posts
|
|
.Where(p => p.RepliedPostId != null && postIds.Contains(p.RepliedPostId.Value))
|
|
.GroupBy(p => p.RepliedPostId!.Value)
|
|
.ToDictionaryAsync(
|
|
g => g.Key,
|
|
g => g.Count()
|
|
);
|
|
}
|
|
|
|
public async Task<List<SnPost>> LoadPostInfo(
|
|
List<SnPost> posts,
|
|
Account? currentUser = null,
|
|
bool truncate = false
|
|
)
|
|
{
|
|
if (posts.Count == 0) return posts;
|
|
|
|
posts = await LoadPublishers(posts);
|
|
posts = await LoadInteractive(posts, currentUser);
|
|
|
|
if (truncate)
|
|
posts = TruncatePostContent(posts);
|
|
|
|
return posts;
|
|
}
|
|
|
|
public async Task<SnPost> LoadPostInfo(SnPost post, Account? currentUser = null, bool truncate = false)
|
|
{
|
|
// Convert single post to list, process it, then return the single post
|
|
var posts = await LoadPostInfo([post], currentUser, truncate);
|
|
return posts.First();
|
|
}
|
|
|
|
private const string FeaturedPostCacheKey = "posts:featured";
|
|
|
|
public async Task<List<SnPost>> ListFeaturedPostsAsync(Account? currentUser = null)
|
|
{
|
|
// Check cache first for featured post IDs
|
|
var featuredIds = await cache.GetAsync<List<Guid>>(FeaturedPostCacheKey);
|
|
|
|
if (featuredIds is null)
|
|
{
|
|
// The previous day highest rated posts
|
|
var today = SystemClock.Instance.GetCurrentInstant();
|
|
var periodStart = today.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant()
|
|
.Minus(Duration.FromDays(1));
|
|
var periodEnd = today.InUtc().Date.AtStartOfDayInZone(DateTimeZone.Utc).ToInstant();
|
|
|
|
var postsInPeriod = await db.Posts
|
|
.Where(e => e.Visibility == Shared.Models.PostVisibility.Public)
|
|
.Where(e => e.CreatedAt >= periodStart && e.CreatedAt < periodEnd)
|
|
.Select(e => e.Id)
|
|
.ToListAsync();
|
|
|
|
var reactionScores = await db.PostReactions
|
|
.Where(e => postsInPeriod.Contains(e.PostId))
|
|
.GroupBy(e => e.PostId)
|
|
.Select(e => new
|
|
{
|
|
PostId = e.Key,
|
|
Score = e.Sum(r => r.Attitude == Shared.Models.PostReactionAttitude.Positive ? 1 : -1)
|
|
})
|
|
.ToDictionaryAsync(e => e.PostId, e => e.Score);
|
|
|
|
var repliesCounts = await db.Posts
|
|
.Where(p => p.RepliedPostId != null && postsInPeriod.Contains(p.RepliedPostId.Value))
|
|
.GroupBy(p => p.RepliedPostId!.Value)
|
|
.ToDictionaryAsync(
|
|
g => g.Key,
|
|
g => g.Count()
|
|
);
|
|
|
|
// Load awardsScores for postsInPeriod
|
|
var awardsScores = await db.Posts
|
|
.Where(p => postsInPeriod.Contains(p.Id))
|
|
.ToDictionaryAsync(p => p.Id, p => p.AwardedScore);
|
|
|
|
var reactSocialPoints = postsInPeriod
|
|
.Select(postId => new
|
|
{
|
|
PostId = postId,
|
|
Count =
|
|
(reactionScores.TryGetValue(postId, out var rScore) ? rScore : 0)
|
|
+ (repliesCounts.TryGetValue(postId, out var repCount) ? repCount : 0)
|
|
+ (awardsScores.TryGetValue(postId, out var awardScore) ? (int)(awardScore / 10) : 0)
|
|
})
|
|
.OrderByDescending(e => e.Count)
|
|
.Take(5)
|
|
.ToDictionary(e => e.PostId, e => e.Count);
|
|
|
|
featuredIds = reactSocialPoints.Select(e => e.Key).ToList();
|
|
|
|
await cache.SetAsync(FeaturedPostCacheKey, featuredIds, TimeSpan.FromHours(4));
|
|
|
|
// Create featured record
|
|
var existingFeaturedPostIds = await db.PostFeaturedRecords
|
|
.Where(r => featuredIds.Contains(r.PostId))
|
|
.Select(r => r.PostId)
|
|
.ToListAsync();
|
|
|
|
var records = reactSocialPoints
|
|
.Where(p => !existingFeaturedPostIds.Contains(p.Key))
|
|
.Select(e => new SnPostFeaturedRecord
|
|
{
|
|
PostId = e.Key,
|
|
SocialCredits = e.Value
|
|
}).ToList();
|
|
|
|
if (records.Count != 0)
|
|
{
|
|
db.PostFeaturedRecords.AddRange(records);
|
|
await db.SaveChangesAsync();
|
|
}
|
|
}
|
|
|
|
var posts = await db.Posts
|
|
.Where(e => featuredIds.Contains(e.Id))
|
|
.Include(e => e.ForwardedPost)
|
|
.Include(e => e.RepliedPost)
|
|
.Include(e => e.Categories)
|
|
.Include(e => e.Publisher)
|
|
.Include(e => e.FeaturedRecords)
|
|
.Take(featuredIds.Count)
|
|
.ToListAsync();
|
|
posts = posts.OrderBy(e => featuredIds.IndexOf(e.Id)).ToList();
|
|
posts = await LoadPostInfo(posts, currentUser, true);
|
|
|
|
return posts;
|
|
}
|
|
|
|
public async Task<SnPostAward> AwardPost(
|
|
Guid postId,
|
|
Guid accountId,
|
|
decimal amount,
|
|
Shared.Models.PostReactionAttitude attitude,
|
|
string? message
|
|
)
|
|
{
|
|
var post = await db.Posts.Where(p => p.Id == postId).FirstOrDefaultAsync();
|
|
if (post is null) throw new InvalidOperationException("Post not found");
|
|
|
|
var award = new SnPostAward
|
|
{
|
|
Amount = amount,
|
|
Attitude = attitude,
|
|
Message = message,
|
|
PostId = postId,
|
|
AccountId = accountId
|
|
};
|
|
|
|
db.PostAwards.Add(award);
|
|
await db.SaveChangesAsync();
|
|
|
|
var delta = award.Attitude == Shared.Models.PostReactionAttitude.Positive ? amount : -amount;
|
|
|
|
await db.Posts.Where(p => p.Id == postId)
|
|
.ExecuteUpdateAsync(s => s.SetProperty(p => p.AwardedScore, p => p.AwardedScore + delta));
|
|
|
|
_ = Task.Run(async () =>
|
|
{
|
|
using var scope = factory.CreateScope();
|
|
var pub = scope.ServiceProvider.GetRequiredService<Publisher.PublisherService>();
|
|
var nty = scope.ServiceProvider.GetRequiredService<RingService.RingServiceClient>();
|
|
var accounts = scope.ServiceProvider.GetRequiredService<AccountService.AccountServiceClient>();
|
|
var accountsHelper = scope.ServiceProvider.GetRequiredService<RemoteAccountService>();
|
|
try
|
|
{
|
|
var sender = await accountsHelper.GetAccount(accountId);
|
|
|
|
var members = await pub.GetPublisherMembers(post.PublisherId);
|
|
var queryRequest = new GetAccountBatchRequest();
|
|
queryRequest.Id.AddRange(members.Select(m => m.AccountId.ToString()));
|
|
var queryResponse = await accounts.GetAccountBatchAsync(queryRequest);
|
|
foreach (var member in queryResponse.Accounts)
|
|
{
|
|
if (member is null) continue;
|
|
CultureService.SetCultureInfo(member);
|
|
|
|
await nty.SendPushNotificationToUserAsync(
|
|
new SendPushNotificationToUserRequest
|
|
{
|
|
UserId = member.Id,
|
|
Notification = new PushNotification
|
|
{
|
|
Topic = "posts.awards.new",
|
|
Title = localizer["PostAwardedTitle", sender.Nick],
|
|
Body = string.IsNullOrWhiteSpace(post.Title)
|
|
? localizer["PostAwardedBody", sender.Nick, amount]
|
|
: localizer["PostAwardedContentBody", sender.Nick, amount,
|
|
post.Title],
|
|
IsSavable = true,
|
|
ActionUri = $"/posts/{post.Id}"
|
|
}
|
|
}
|
|
);
|
|
}
|
|
}
|
|
catch (Exception ex)
|
|
{
|
|
logger.LogError($"Error when sending post awarded notification: {ex.Message} {ex.StackTrace}");
|
|
}
|
|
});
|
|
|
|
return award;
|
|
}
|
|
}
|
|
|
|
public static class PostQueryExtensions
|
|
{
|
|
public static IQueryable<SnPost> FilterWithVisibility(
|
|
this IQueryable<SnPost> source,
|
|
Account? currentUser,
|
|
List<Guid> userFriends,
|
|
List<Shared.Models.SnPublisher> publishers,
|
|
bool isListing = false
|
|
)
|
|
{
|
|
var now = SystemClock.Instance.GetCurrentInstant();
|
|
var publishersId = publishers.Select(e => e.Id).ToList();
|
|
|
|
source = isListing switch
|
|
{
|
|
true when currentUser is not null => source.Where(e =>
|
|
e.Visibility != Shared.Models.PostVisibility.Unlisted || publishersId.Contains(e.PublisherId)),
|
|
true => source.Where(e => e.Visibility != Shared.Models.PostVisibility.Unlisted),
|
|
_ => source
|
|
};
|
|
|
|
if (currentUser is null)
|
|
return source
|
|
.Where(e => e.PublishedAt != null && now >= e.PublishedAt)
|
|
.Where(e => e.Visibility == Shared.Models.PostVisibility.Public);
|
|
|
|
return source
|
|
.Where(e => (e.PublishedAt != null && now >= e.PublishedAt) || publishersId.Contains(e.PublisherId))
|
|
.Where(e => e.Visibility != Shared.Models.PostVisibility.Private || publishersId.Contains(e.PublisherId))
|
|
.Where(e => e.Visibility != Shared.Models.PostVisibility.Friends ||
|
|
(e.Publisher.AccountId != null && userFriends.Contains(e.Publisher.AccountId.Value)) ||
|
|
publishersId.Contains(e.PublisherId));
|
|
}
|
|
}
|