mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-08-13 14:03:01 -06:00
Render polls in HTML exports
This commit is contained in:
parent
f6865c8216
commit
829a728b72
191
DiscordChatExporter.Cli.Tests/Specs/HtmlPollSpecs.cs
Normal file
191
DiscordChatExporter.Cli.Tests/Specs/HtmlPollSpecs.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
85
DiscordChatExporter.Core/Discord/Data/Poll.cs
Normal file
85
DiscordChatExporter.Core/Discord/Data/Poll.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -12,4 +12,8 @@
|
|||
<PackageReference Include="WebMarkupMin.Core" />
|
||||
<PackageReference Include="YoutubeExplode" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DiscordChatExporter.Cli.Tests" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -766,4 +824,4 @@
|
|||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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 *@
|
||||
<!--wmm:ignore-->
|
||||
<div class="chatlog">
|
||||
<!--/wmm:ignore-->
|
||||
<!--/wmm:ignore-->
|
||||
|
|
|
|||
Loading…
Reference in a new issue