Render polls in HTML exports

This commit is contained in:
Morax 2026-08-12 02:21:16 +02:00
parent f6865c8216
commit 829a728b72
6 changed files with 423 additions and 5 deletions

View file

@ -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 <should> 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 <should> 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 <should> 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");
}
}

View file

@ -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
);
}
}

View file

@ -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<PollAnswer> 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<int, PollAnswerCount> 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);
}
}

View file

@ -12,4 +12,8 @@
<PackageReference Include="WebMarkupMin.Core" />
<PackageReference Include="YoutubeExplode" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="DiscordChatExporter.Cli.Tests" />
</ItemGroup>
</Project>

View file

@ -401,6 +401,64 @@
</div>
}
@* Poll *@
@if (message.Poll is { } poll)
{
<div class="chatlog__poll">
<div class="chatlog__poll-question">@poll.Question</div>
<div class="chatlog__poll-answers">
@foreach (var answer in poll.Answers)
{
var answerCount = poll.Results?.GetAnswerCount(answer.Id);
<div class="chatlog__poll-answer @(answerCount?.DidCurrentUserVote == true ? "chatlog__poll-answer--selected" : null)">
<div class="chatlog__poll-answer-content">
@if (answer.Emoji is not null)
{
<img class="chatlog__poll-answer-emoji" alt="@answer.Emoji.Name" title="@answer.Emoji.Code" src="@await ResolveAssetUrlAsync(answer.Emoji.ImageUrl)" loading="lazy">
}
<span class="chatlog__poll-answer-text">@answer.Text</span>
</div>
@if (answerCount is not null)
{
<span class="chatlog__poll-answer-count">@answerCount.Count.ToString("N0", Context.Request.CultureInfo)</span>
}
</div>
}
</div>
@if (poll.Results is not null || poll.AllowsMultipleAnswers || poll.ExpiresAt is not null)
{
<div class="chatlog__poll-footer">
@if (poll.Results is not null)
{
<span class="chatlog__poll-footer-item">@poll.Results.TotalVoteCount.ToString("N0", Context.Request.CultureInfo) @(poll.Results.TotalVoteCount == 1 ? "vote" : "votes")</span>
}
@if (poll.AllowsMultipleAnswers)
{
<span class="chatlog__poll-footer-item">Multiple answers allowed</span>
}
@if (poll.Results?.IsFinalized == true)
{
<span class="chatlog__poll-footer-item">Final results</span>
}
@if (poll.ExpiresAt is not null)
{
<span class="chatlog__poll-footer-item">
@(poll.Results?.IsFinalized == true ? "Ended" : "Ends")
<time datetime="@poll.ExpiresAt.Value.ToString("O")" title="@FormatDate(poll.ExpiresAt.Value, "f")">@FormatDate(poll.ExpiresAt.Value)</time>
</span>
}
</div>
}
</div>
}
@* Invites *@
@{
var inviteCodes = MarkdownParser

View file

@ -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;