From 709dc44d57f4c119fad1e82008cfe7ad0dede714 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Tue, 5 Aug 2025 19:53:19 +0800 Subject: [PATCH] :sparkles: Post with polls --- DysonNetwork.Sphere/Poll/Poll.cs | 23 -------- DysonNetwork.Sphere/Poll/PollController.cs | 4 +- DysonNetwork.Sphere/Poll/PollEmbed.cs | 39 ++++++++++++++ DysonNetwork.Sphere/Poll/PollService.cs | 61 ++++++++++++++++++---- DysonNetwork.Sphere/Post/PostController.cs | 47 ++++++++++++++++- DysonNetwork.Sphere/Post/PostService.cs | 60 +++++++++++++++++++-- 6 files changed, 195 insertions(+), 39 deletions(-) create mode 100644 DysonNetwork.Sphere/Poll/PollEmbed.cs diff --git a/DysonNetwork.Sphere/Poll/Poll.cs b/DysonNetwork.Sphere/Poll/Poll.cs index 65fc453..aef88b0 100644 --- a/DysonNetwork.Sphere/Poll/Poll.cs +++ b/DysonNetwork.Sphere/Poll/Poll.cs @@ -20,29 +20,6 @@ public class Poll : ModelBase public Publisher.Publisher Publisher { get; set; } = null!; } -public class PollWithAnswer : Poll -{ - public PollAnswer? UserAnswer { get; set; } - public Dictionary> Stats { get; set; } = new(); // question id -> (option id -> count) - - public static PollWithAnswer FromPoll(Poll poll, PollAnswer? userAnswer = null) - { - return new PollWithAnswer - { - Id = poll.Id, - Title = poll.Title, - Description = poll.Description, - EndedAt = poll.EndedAt, - PublisherId = poll.PublisherId, - Publisher = poll.Publisher, - Questions = poll.Questions, - CreatedAt = poll.CreatedAt, - UpdatedAt = poll.UpdatedAt, - UserAnswer = userAnswer - }; - } -} - public enum PollQuestionType { SingleChoice, diff --git a/DysonNetwork.Sphere/Poll/PollController.cs b/DysonNetwork.Sphere/Poll/PollController.cs index 3a5a697..d644fdc 100644 --- a/DysonNetwork.Sphere/Poll/PollController.cs +++ b/DysonNetwork.Sphere/Poll/PollController.cs @@ -14,13 +14,13 @@ namespace DysonNetwork.Sphere.Poll; public class PollController(AppDatabase db, PollService polls, PublisherService pub) : ControllerBase { [HttpGet("{id:guid}")] - public async Task> GetPoll(Guid id) + public async Task> GetPoll(Guid id) { var poll = await db.Polls .Include(p => p.Questions) .FirstOrDefaultAsync(p => p.Id == id); if (poll is null) return NotFound("Poll not found"); - var pollWithAnswer = PollWithAnswer.FromPoll(poll); + var pollWithAnswer = PollWithStats.FromPoll(poll); if (HttpContext.Items["CurrentUser"] is not Account currentUser) return Ok(pollWithAnswer); diff --git a/DysonNetwork.Sphere/Poll/PollEmbed.cs b/DysonNetwork.Sphere/Poll/PollEmbed.cs new file mode 100644 index 0000000..f4e025c --- /dev/null +++ b/DysonNetwork.Sphere/Poll/PollEmbed.cs @@ -0,0 +1,39 @@ +using DysonNetwork.Sphere.WebReader; + +namespace DysonNetwork.Sphere.Poll; + +public class PollWithStats : Poll +{ + public PollAnswer? UserAnswer { get; set; } + public Dictionary> Stats { get; set; } = new(); // question id -> (option id -> count) + + public static PollWithStats FromPoll(Poll poll, PollAnswer? userAnswer = null) + { + return new PollWithStats + { + Id = poll.Id, + Title = poll.Title, + Description = poll.Description, + EndedAt = poll.EndedAt, + PublisherId = poll.PublisherId, + Publisher = poll.Publisher, + Questions = poll.Questions, + CreatedAt = poll.CreatedAt, + UpdatedAt = poll.UpdatedAt, + UserAnswer = userAnswer + }; + } +} + +public class PollEmbed : EmbeddableBase +{ + public override string Type => "poll"; + + public required Guid Id { get; set; } + + /// + /// Do not store this to the database + /// Only set this when sending the embed + /// + public PollWithStats? Poll { get; set; } +} \ No newline at end of file diff --git a/DysonNetwork.Sphere/Poll/PollService.cs b/DysonNetwork.Sphere/Poll/PollService.cs index e6548db..cd813cd 100644 --- a/DysonNetwork.Sphere/Poll/PollService.cs +++ b/DysonNetwork.Sphere/Poll/PollService.cs @@ -31,6 +31,28 @@ public class PollService(AppDatabase db, ICacheService cache) } } + private const string PollCachePrefix = "poll:"; + + public async Task GetPoll(Guid id) + { + var cacheKey = $"{PollCachePrefix}{id}"; + var cachedPoll = await cache.GetAsync(cacheKey); + if (cachedPoll is not null) + return cachedPoll; + + var poll = await db.Polls + .Where(e => e.Id == id) + .Include(e => e.Questions) + .FirstOrDefaultAsync(); + + if (poll is not null) + { + await cache.SetAsync(cacheKey, poll, TimeSpan.FromMinutes(30)); + } + + return poll; + } + private const string PollAnswerCachePrefix = "poll:answer:"; public async Task GetPollAnswer(Guid pollId, Guid accountId) @@ -44,10 +66,8 @@ public class PollService(AppDatabase db, ICacheService cache) .Where(e => e.PollId == pollId && e.AccountId == accountId) .FirstOrDefaultAsync(); - if (answer is not null) - { - await cache.SetAsync(cacheKey, answer, TimeSpan.FromMinutes(30)); - } + // Set the answer even it is null, which stands for unanswered + await cache.SetAsync(cacheKey, answer, TimeSpan.FromMinutes(30)); return answer; } @@ -126,7 +146,7 @@ public class PollService(AppDatabase db, ICacheService cache) // Update cache for this poll answer and invalidate stats cache var answerCacheKey = $"poll:answer:{pollId}:{accountId}"; await cache.SetAsync(answerCacheKey, answerRecord, TimeSpan.FromMinutes(30)); - + // Invalidate all stats cache for this poll since answers have changed await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId); @@ -141,17 +161,17 @@ public class PollService(AppDatabase db, ICacheService cache) .ExecuteUpdateAsync(s => s.SetProperty(e => e.DeletedAt, now)) > 0; if (!result) return result; - + // Remove the cached answer if it exists var answerCacheKey = $"poll:answer:{pollId}:{accountId}"; await cache.RemoveAsync(answerCacheKey); - + // Invalidate all stats cache for this poll since answers have changed await cache.RemoveGroupAsync(PollCacheGroupPrefix + pollId); return result; } - + private const string PollStatsCachePrefix = "poll:stats:"; private const string PollCacheGroupPrefix = "poll:"; @@ -159,7 +179,7 @@ public class PollService(AppDatabase db, ICacheService cache) public async Task> GetPollQuestionStats(Guid questionId) { var cacheKey = $"{PollStatsCachePrefix}{questionId}"; - + // Try to get from cache first var (found, cachedStats) = await cache.GetAsyncWithStatus>(cacheKey); if (found && cachedStats != null) @@ -282,4 +302,27 @@ public class PollService(AppDatabase db, ICacheService cache) return result; } + + public async Task MakePollEmbed(Guid pollId) + { + // Do not read the cache here + var poll = await db.Polls + .Where(e => e.Id == pollId) + .FirstOrDefaultAsync(); + if (poll is null) + throw new Exception("Poll not found"); + return new PollEmbed { Id = poll.Id }; + } + + public async Task LoadPollEmbed(Guid pollId, Guid? accountId) + { + var poll = await GetPoll(pollId); + if (poll is null) + throw new Exception("Poll not found"); + var pollWithStats = PollWithStats.FromPoll(poll); + pollWithStats.Stats = await GetPollStats(poll.Id); + if (accountId is not null) + pollWithStats.UserAnswer = await GetPollAnswer(poll.Id, accountId.Value); + return new PollEmbed { Id = poll.Id, Poll = pollWithStats }; + } } \ No newline at end of file diff --git a/DysonNetwork.Sphere/Post/PostController.cs b/DysonNetwork.Sphere/Post/PostController.cs index 1d1e9ee..9ff8442 100644 --- a/DysonNetwork.Sphere/Post/PostController.cs +++ b/DysonNetwork.Sphere/Post/PostController.cs @@ -3,7 +3,9 @@ using DysonNetwork.Shared.Auth; using DysonNetwork.Shared.Content; using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; +using DysonNetwork.Sphere.Poll; using DysonNetwork.Sphere.Publisher; +using DysonNetwork.Sphere.WebReader; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Mvc; using Microsoft.EntityFrameworkCore; @@ -18,7 +20,8 @@ public class PostController( PostService ps, PublisherService pub, AccountService.AccountServiceClient accounts, - ActionLogService.ActionLogServiceClient als + ActionLogService.ActionLogServiceClient als, + PollService polls ) : ControllerBase { @@ -275,6 +278,8 @@ public class PostController( public Instant? PublishedAt { get; set; } public Guid? RepliedPostId { get; set; } public Guid? ForwardedPostId { get; set; } + + public Guid? PollId { get; set; } } [HttpPost] @@ -336,6 +341,25 @@ public class PostController( post.ForwardedPostId = forwardedPost.Id; } + if (request.PollId is not null) + { + try + { + var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + post.Meta ??= new Dictionary(); + if (!post.Meta.TryGetValue("embeds", out var existingEmbeds) || + existingEmbeds is not List) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + embeds.Add(pollEmbed.ToDictionary()); + post.Meta["embeds"] = embeds; + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + try { post = await ps.PostAsync( @@ -469,6 +493,27 @@ public class PostController( if (request.Type is not null) post.Type = request.Type.Value; if (request.Meta is not null) post.Meta = request.Meta; + if (request.PollId is not null) + { + try + { + var pollEmbed = await polls.MakePollEmbed(request.PollId.Value); + post.Meta ??= new Dictionary(); + if (!post.Meta.TryGetValue("embeds", out var existingEmbeds) || + existingEmbeds is not List) + post.Meta["embeds"] = new List>(); + var embeds = (List>)post.Meta["embeds"]; + // Remove all old poll embeds + embeds.RemoveAll(e => e.TryGetValue("type", out var type) && type.ToString() == "poll"); + embeds.Add(pollEmbed.ToDictionary()); + post.Meta["embeds"] = embeds; + } + catch (Exception ex) + { + return BadRequest(ex.Message); + } + } + try { post = await ps.UpdatePostAsync( diff --git a/DysonNetwork.Sphere/Post/PostService.cs b/DysonNetwork.Sphere/Post/PostService.cs index a56e516..aecb89b 100644 --- a/DysonNetwork.Sphere/Post/PostService.cs +++ b/DysonNetwork.Sphere/Post/PostService.cs @@ -1,10 +1,12 @@ using System.Text.RegularExpressions; +using AngleSharp.Common; using DysonNetwork.Shared; using DysonNetwork.Shared.Cache; using DysonNetwork.Shared.Data; using DysonNetwork.Shared.Proto; using DysonNetwork.Sphere.WebReader; using DysonNetwork.Sphere.Localization; +using DysonNetwork.Sphere.Poll; using DysonNetwork.Sphere.Publisher; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Localization; @@ -21,6 +23,7 @@ public partial class PostService( ILogger logger, FileService.FileServiceClient files, FileReferenceService.FileReferenceServiceClient fileRefs, + PollService polls, WebReaderService reader ) { @@ -306,10 +309,11 @@ public partial class PostService( var embeds = (List>)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 >= 3) + if (processedLinks >= maxLinks) break; var url = match.Value; @@ -520,7 +524,8 @@ public partial class PostService( ); } - public async Task>> GetPostReactionMadeMapBatch(List postIds, Guid accountId) + public async Task>> GetPostReactionMadeMapBatch(List postIds, + Guid accountId) { var reactions = await db.Set() .Where(r => postIds.Contains(r.PostId) && r.AccountId == accountId) @@ -630,12 +635,12 @@ public partial class PostService( post.ReactionsMade = reactionMadeMap.TryGetValue(post.Id, out var made) ? made : []; - + // Set reply count post.RepliesCount = repliesCountMap.TryGetValue(post.Id, out var repliesCount) ? repliesCount : 0; - + // Track view for each post in the list if (currentUser != null) await IncreaseViewCount(post.Id, currentUser.Id); @@ -656,6 +661,46 @@ public partial class PostService( g => g.Count() ); } + + private async Task LoadPollEmbed(Post post, Account? currentUser) + { + if (!post.Meta!.TryGetValue("embeds", out var existingEmbeds) || + existingEmbeds is not List) + { + post.Meta["embeds"] = new List>(); + return; + } + + var embeds = (List>)post.Meta["embeds"]; + + // Find the index of the poll embed first + var pollIndex = embeds.FindIndex(e => + e.ContainsKey("type") && (string)e["type"] == "poll"); + + if (pollIndex < 0) return; + + var pollEmbed = embeds[pollIndex]; + try + { + var pollId = pollEmbed["Id"] switch + { + Guid g => g, + string s when !string.IsNullOrEmpty(s) => Guid.Parse(s), + _ => default(Guid?) + }; + + if (pollId.HasValue) + { + var currentUserId = currentUser is not null ? Guid.Parse(currentUser.Id) : (Guid?)null; + var updatedPoll = await polls.LoadPollEmbed(pollId.Value, currentUserId); + embeds[pollIndex] = updatedPoll.ToDictionary(); + } + } + catch (Exception ex) + { + logger?.LogError(ex, "Failed to load poll embed for post {PostId}", post.Id); + } + } public async Task> LoadPostInfo( List posts, @@ -668,6 +713,13 @@ public partial class PostService( posts = await LoadPublishers(posts); posts = await LoadInteractive(posts, currentUser); + var postsWithEmbed = posts + .Where(e => e.Meta is not null && e.Meta.ContainsKey("embeds")) + .ToList(); + + foreach (var post in postsWithEmbed) + await LoadPollEmbed(post, currentUser); + if (truncate) posts = TruncatePostContent(posts);