diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlPollSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlPollSpecs.cs new file mode 100644 index 00000000..8b53072b --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlPollSpecs.cs @@ -0,0 +1,191 @@ +using System.IO; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using DiscordChatExporter.Cli.Tests.Utils; +using DiscordChatExporter.Core.Discord; +using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Core.Exporting; +using DiscordChatExporter.Core.Exporting.Filtering; +using DiscordChatExporter.Core.Exporting.Partitioning; +using FluentAssertions; +using Xunit; + +namespace DiscordChatExporter.Cli.Tests.Specs; + +public class HtmlPollSpecs +{ + private static Message ParsePollMessage() + { + using var document = JsonDocument.Parse( + """ + { + "id": "1503394223213383801", + "type": 0, + "author": { + "id": "1503390786950135868", + "username": "poll-author", + "global_name": "Poll Author", + "avatar": null + }, + "timestamp": "2026-05-11T14:00:00+00:00", + "content": "", + "attachments": [], + "embeds": [], + "sticker_items": [], + "reactions": [], + "mentions": [], + "poll": { + "question": { "text": "What we eat?" }, + "answers": [ + { + "answer_id": 1, + "poll_media": { "text": "Pizza & pasta", "emoji": { "id": null, "name": "🍕" } } + }, + { + "answer_id": 5, + "poll_media": { "text": "Tacos", "emoji": { "id": "1503391152383070351", "name": "taco", "animated": false } } + }, + { + "answer_id": 9, + "poll_media": { "text": "Salad" } + } + ], + "expiry": "2026-05-12T14:00:00+00:00", + "allow_multiselect": true, + "layout_type": 1, + "results": { + "is_finalized": true, + "answer_counts": [ + { "id": 1, "count": 3, "me_voted": false }, + { "id": 5, "count": 2, "me_voted": true } + ] + } + } + } + """ + ); + + return Message.Parse(document.RootElement); + } + + [Fact] + public void I_can_parse_a_poll_only_message() + { + // Act + var message = ParsePollMessage(); + + // Assert + message.IsEmpty.Should().BeFalse(); + message.Poll.Should().NotBeNull(); + + var poll = message.Poll!; + poll.Question.Should().Be("What we eat?"); + poll.Answers.Select(a => a.Id).Should().Equal(1, 5, 9); + poll.Answers[1].Emoji.Should().NotBeNull(); + poll.AllowsMultipleAnswers.Should().BeTrue(); + poll.Results.Should().NotBeNull(); + poll.Results!.IsFinalized.Should().BeTrue(); + poll.Results.TotalVoteCount.Should().Be(5); + poll.Results.GetAnswerCount(5).DidCurrentUserVote.Should().BeTrue(); + poll.Results.GetAnswerCount(9).Count.Should().Be(0); + } + + [Fact] + public void I_can_parse_a_poll_without_results() + { + // Arrange + using var document = JsonDocument.Parse( + """ + { + "question": { "text": "Still voting?" }, + "answers": [ + { "answer_id": 42, "poll_media": { "text": "Yes" } } + ], + "expiry": null, + "allow_multiselect": false, + "layout_type": 1 + } + """ + ); + + // Act + var poll = Poll.Parse(document.RootElement); + + // Assert + poll.Results.Should().BeNull(); + poll.ExpiresAt.Should().BeNull(); + poll.Answers.Should().ContainSingle().Which.Id.Should().Be(42); + } + + [Fact] + public async Task I_can_render_a_poll_in_the_HTML_format() + { + // Arrange + var message = ParsePollMessage(); + var guild = new Guild(new Snowflake(1), "Guild", ""); + var channel = new Channel( + new Snowflake(2), + ChannelKind.GuildTextChat, + guild.Id, + null, + "polls", + 0, + null, + null, + false, + message.Id + ); + var request = new ExportRequest( + guild, + channel, + Path.Combine(Path.GetTempPath(), "poll.html"), + null, + ExportFormat.HtmlDark, + null, + null, + PartitionLimit.Null, + MessageFilter.Null, + false, + true, + false, + false, + "en-US", + true + ); + var context = new ExportContext(new DiscordClient("unused"), request); + + // Act + var html = await new MessageGroupTemplate + { + Context = context, + Messages = [message], + }.RenderAsync(); + var document = Html.Parse(html); + var poll = document.QuerySelector(".chatlog__poll"); + + // Assert + poll.Should().NotBeNull(); + poll! + .QuerySelector(".chatlog__poll-question")! + .TextContent.Should() + .Be("What we eat?"); + poll.QuerySelector("should").Should().BeNull(); + poll.QuerySelectorAll(".chatlog__poll-answer").Should().HaveCount(3); + poll.QuerySelectorAll(".chatlog__poll-answer-text") + .Select(e => e.TextContent) + .Should() + .Equal("Pizza & pasta", "Tacos", "Salad"); + poll.QuerySelectorAll(".chatlog__poll-answer-count") + .Select(e => e.TextContent) + .Should() + .Equal("3", "2", "0"); + poll.QuerySelector(".chatlog__poll-answer--selected .chatlog__poll-answer-text")! + .TextContent.Should() + .Be("Tacos"); + poll.QuerySelector(".chatlog__poll-answer-emoji[alt='🍕']").Should().NotBeNull(); + poll.QuerySelector(".chatlog__poll-footer")! + .TextContent.Should() + .ContainAll("5 votes", "Multiple answers allowed", "Final results", "Ended"); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 29465ab5..12502e84 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -28,14 +28,16 @@ public partial record Message( MessageReference? Reference, Message? ReferencedMessage, MessageSnapshot? ForwardedMessage, - Interaction? Interaction + Interaction? Interaction, + Poll? Poll ) : IHasId { public bool IsEmpty { get; } = string.IsNullOrWhiteSpace(Content) && !Attachments.Any() && !Embeds.Any() - && !Stickers.Any(); + && !Stickers.Any() + && Poll is null; public bool IsSystemNotification { get; } = Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated; @@ -186,6 +188,7 @@ public partial record Message .FirstOrDefault(); var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse); + var poll = json.GetPropertyOrNull("poll")?.Pipe(Poll.Parse); return new Message( id, @@ -205,7 +208,8 @@ public partial record Message messageReference, referencedMessage, forwardedMessage, - interaction + interaction, + poll ); } } diff --git a/DiscordChatExporter.Core/Discord/Data/Poll.cs b/DiscordChatExporter.Core/Discord/Data/Poll.cs new file mode 100644 index 00000000..10912d46 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Poll.cs @@ -0,0 +1,85 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using JsonExtensions.Reading; +using PowerKit.Extensions; + +namespace DiscordChatExporter.Core.Discord.Data; + +// https://discord.com/developers/docs/resources/poll#poll-object +public record Poll( + string Question, + IReadOnlyList Answers, + DateTimeOffset? ExpiresAt, + bool AllowsMultipleAnswers, + PollResults? Results +) +{ + public static Poll Parse(JsonElement json) + { + var question = + json.GetProperty("question").GetPropertyOrNull("text")?.GetStringOrNull() ?? ""; + + var answers = + json.GetPropertyOrNull("answers") + ?.EnumerateArrayOrNull() + ?.Select(PollAnswer.Parse) + .ToArray() + ?? []; + + var expiresAt = json.GetPropertyOrNull("expiry")?.GetDateTimeOffsetOrNull(); + var allowsMultipleAnswers = + json.GetPropertyOrNull("allow_multiselect")?.GetBooleanOrNull() ?? false; + var results = json.GetPropertyOrNull("results")?.Pipe(PollResults.Parse); + + return new Poll(question, answers, expiresAt, allowsMultipleAnswers, results); + } +} + +public record PollAnswer(int Id, string Text, Emoji? Emoji) +{ + public static PollAnswer Parse(JsonElement json) + { + var id = json.GetProperty("answer_id").GetInt32(); + var media = json.GetProperty("poll_media"); + var text = media.GetPropertyOrNull("text")?.GetStringOrNull() ?? ""; + var emoji = media.GetPropertyOrNull("emoji")?.Pipe(Emoji.Parse); + + return new PollAnswer(id, text, emoji); + } +} + +public record PollAnswerCount(int AnswerId, int Count, bool DidCurrentUserVote) +{ + public static PollAnswerCount Parse(JsonElement json) + { + var answerId = json.GetProperty("id").GetInt32(); + var count = json.GetProperty("count").GetInt32(); + var didCurrentUserVote = json.GetPropertyOrNull("me_voted")?.GetBooleanOrNull() ?? false; + + return new PollAnswerCount(answerId, count, didCurrentUserVote); + } +} + +public record PollResults(bool IsFinalized, IReadOnlyDictionary AnswerCounts) +{ + public int TotalVoteCount { get; } = AnswerCounts.Values.Sum(c => c.Count); + + public PollAnswerCount GetAnswerCount(int answerId) => + AnswerCounts.GetValueOrDefault(answerId) ?? new PollAnswerCount(answerId, 0, false); + + public static PollResults Parse(JsonElement json) + { + var isFinalized = json.GetPropertyOrNull("is_finalized")?.GetBooleanOrNull() ?? false; + + var answerCounts = + json.GetPropertyOrNull("answer_counts") + ?.EnumerateArrayOrNull() + ?.Select(PollAnswerCount.Parse) + .ToDictionary(c => c.AnswerId) + ?? []; + + return new PollResults(isFinalized, answerCounts); + } +} diff --git a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj index 19498bbb..6f1b8a32 100644 --- a/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj +++ b/DiscordChatExporter.Core/DiscordChatExporter.Core.csproj @@ -12,4 +12,8 @@ + + + + diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index b3c06837..b869a1b0 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -401,6 +401,64 @@ } + @* Poll *@ + @if (message.Poll is { } poll) + { +
+
@poll.Question
+ +
+ @foreach (var answer in poll.Answers) + { + var answerCount = poll.Results?.GetAnswerCount(answer.Id); + +
+
+ @if (answer.Emoji is not null) + { + @answer.Emoji.Name + } + @answer.Text +
+ + @if (answerCount is not null) + { + @answerCount.Count.ToString("N0", Context.Request.CultureInfo) + } +
+ } +
+ + @if (poll.Results is not null || poll.AllowsMultipleAnswers || poll.ExpiresAt is not null) + { + + } +
+ } + @* Invites *@ @{ var inviteCodes = MarkdownParser @@ -766,4 +824,4 @@ } - \ No newline at end of file + diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index 1b5f300a..c7c8b99c 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -431,6 +431,82 @@ font-weight: 500; } + .chatlog__poll { + box-sizing: border-box; + width: 100%; + max-width: 32rem; + margin-top: 0.35rem; + } + + .chatlog__poll-question { + margin-bottom: 0.6rem; + color: @Themed("#f2f3f5", "#060607"); + font-size: 1rem; + font-weight: 600; + word-wrap: break-word; + } + + .chatlog__poll-answers { + display: flex; + flex-direction: column; + gap: 0.4rem; + } + + .chatlog__poll-answer { + display: flex; + align-items: center; + justify-content: space-between; + min-height: 2.25rem; + padding: 0.35rem 0.65rem; + border: 1px solid @Themed("#4e5058", "#c4c9ce"); + border-radius: 0.5rem; + background-color: @Themed("#2b2d31", "#f2f3f5"); + color: @Themed("#dbdee1", "#2e3338"); + } + + .chatlog__poll-answer--selected { + border-color: #5865f2; + background-color: @Themed("#31344a", "#e8eaff"); + } + + .chatlog__poll-answer-content { + display: flex; + align-items: center; + min-width: 0; + } + + .chatlog__poll-answer-emoji { + width: 1.25rem; + height: 1.25rem; + margin-right: 0.45rem; + object-fit: contain; + } + + .chatlog__poll-answer-text { + overflow-wrap: anywhere; + } + + .chatlog__poll-answer-count { + margin-left: 0.75rem; + color: @Themed("#b5bac1", "#5c646c"); + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + } + + .chatlog__poll-footer { + display: flex; + flex-wrap: wrap; + margin-top: 0.45rem; + color: @Themed("#b5bac1", "#5c646c"); + font-size: 0.75rem; + } + + .chatlog__poll-footer-item + .chatlog__poll-footer-item::before { + margin: 0 0.35rem; + content: "•"; + } + .chatlog__attachment { position: relative; width: fit-content; @@ -1090,4 +1166,4 @@ @* Preamble cuts off at this point *@
- \ No newline at end of file +