Refactor polls

This commit is contained in:
tyrrrz
2026-08-27 19:56:50 +03:00
parent b049fb2919
commit 33c9e77524
9 changed files with 73 additions and 79 deletions
@@ -28,6 +28,9 @@ public partial record Embed(
// but the client can render multiple images in some cases. // but the client can render multiple images in some cases.
public EmbedImage? Image => Images.FirstOrDefault(); public EmbedImage? Image => Images.FirstOrDefault();
public EmbedField? TryGetField(string name) =>
Fields.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.Ordinal));
public PollResultEmbedProjection? TryGetPollResult() => public PollResultEmbedProjection? TryGetPollResult() =>
PollResultEmbedProjection.TryResolve(this); PollResultEmbedProjection.TryResolve(this);
@@ -1,50 +1,37 @@
using System;
using System.Globalization; using System.Globalization;
using System.Linq;
using DiscordChatExporter.Core.Discord;
using DiscordChatExporter.Core.Discord.Data;
using PowerKit.Extensions; using PowerKit.Extensions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds; namespace DiscordChatExporter.Core.Discord.Data.Embeds;
// https://docs.discord.com/developers/resources/message#embed-fields-by-embed-type-poll-result-embed-fields // https://docs.discord.com/developers/resources/message#embed-fields-by-embed-type-poll-result-embed-fields
public partial record PollResultEmbedProjection( public partial record PollResultEmbedProjection(
string QuestionText, string Question,
int WinningVoteCount,
int TotalVoteCount, int TotalVoteCount,
int WinningVoteCount,
int? WinningAnswerId, int? WinningAnswerId,
string? WinningAnswerText, string? WinningAnswerText,
Emoji? WinningAnswerEmoji Emoji? WinningAnswerEmoji
) )
{ {
public double WinningVotePercentage { get; } = public double WinningVoteShare { get; } =
TotalVoteCount > 0 ? (double)WinningVoteCount / TotalVoteCount : 0; TotalVoteCount > 0 ? (double)WinningVoteCount / TotalVoteCount : 0;
} }
public partial record PollResultEmbedProjection public partial record PollResultEmbedProjection
{ {
private static string? TryGetFieldValue(Embed embed, string name) =>
embed
.Fields.FirstOrDefault(f => string.Equals(f.Name, name, StringComparison.Ordinal))
?.Value;
private static Emoji? TryParseWinningAnswerEmoji(Embed embed) private static Emoji? TryParseWinningAnswerEmoji(Embed embed)
{ {
var name = TryGetFieldValue(embed, "victor_answer_emoji_name"); var name = embed.TryGetField("victor_answer_emoji_name")?.Value;
if (string.IsNullOrWhiteSpace(name)) if (string.IsNullOrWhiteSpace(name))
return null; return null;
var id = var id = embed
TryGetFieldValue(embed, "victor_answer_emoji_id") is { } idValue .TryGetField("victor_answer_emoji_id")
&& Snowflake.TryParse(idValue) is { } parsedId ?.Value?.Pipe(v => Snowflake.TryParse(v));
? parsedId
: (Snowflake?)null;
var isAnimated = var isAnimated =
bool.TryParse( embed.TryGetField("victor_answer_emoji_animated")?.Value?.Pipe(bool.ParseOrNull)
TryGetFieldValue(embed, "victor_answer_emoji_animated"), ?? false;
out var parsedIsAnimated
) && parsedIsAnimated;
return new Emoji(id, name, isAnimated); return new Emoji(id, name, isAnimated);
} }
@@ -54,27 +41,31 @@ public partial record PollResultEmbedProjection
if (embed.Kind != EmbedKind.PollResult) if (embed.Kind != EmbedKind.PollResult)
return null; return null;
var questionText = TryGetFieldValue(embed, "poll_question_text") ?? ""; var question = embed.TryGetField("poll_question_text")?.Value ?? "";
var winningVoteCount = int.ParseOrDefault(
TryGetFieldValue(embed, "victor_answer_votes"),
CultureInfo.InvariantCulture
);
var totalVoteCount = int.ParseOrDefault(
TryGetFieldValue(embed, "total_votes"),
CultureInfo.InvariantCulture
);
var winningAnswerId = int.ParseOrNull(
TryGetFieldValue(embed, "victor_answer_id"),
CultureInfo.InvariantCulture
);
var winningAnswerText = TryGetFieldValue(embed, "victor_answer_text"); var totalVoteCount =
embed
.TryGetField("total_votes")
?.Value?.Pipe(v => int.ParseOrNull(v, CultureInfo.InvariantCulture))
?? 0;
var winningVoteCount =
embed
.TryGetField("victor_answer_votes")
?.Value?.Pipe(v => int.ParseOrNull(v, CultureInfo.InvariantCulture))
?? 0;
var winningAnswerId = embed
.TryGetField("victor_answer_id")
?.Value?.Pipe(v => int.ParseOrNull(v, CultureInfo.InvariantCulture));
var winningAnswerText = embed.TryGetField("victor_answer_text")?.Value;
var winningAnswerEmoji = TryParseWinningAnswerEmoji(embed); var winningAnswerEmoji = TryParseWinningAnswerEmoji(embed);
return new PollResultEmbedProjection( return new PollResultEmbedProjection(
questionText, question,
winningVoteCount,
totalVoteCount, totalVoteCount,
winningVoteCount,
winningAnswerId, winningAnswerId,
winningAnswerText, winningAnswerText,
winningAnswerEmoji winningAnswerEmoji
@@ -41,8 +41,9 @@ public partial record Message(
&& Poll is null; && Poll is null;
public bool IsSystemNotification { get; } = public bool IsSystemNotification { get; } =
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated Kind
|| Kind == MessageKind.PollResult; is (>= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated)
or MessageKind.PollResult;
public bool IsReply { get; } = Kind == MessageKind.Reply; public bool IsReply { get; } = Kind == MessageKind.Reply;
@@ -1,5 +1,4 @@
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data;
using JsonExtensions.Reading; using JsonExtensions.Reading;
using PowerKit.Extensions; using PowerKit.Extensions;
@@ -11,9 +10,14 @@ public record PollAnswer(int Id, string Text, Emoji? Emoji)
public static PollAnswer Parse(JsonElement json) public static PollAnswer Parse(JsonElement json)
{ {
var id = json.GetProperty("answer_id").GetInt32(); var id = json.GetProperty("answer_id").GetInt32();
var media = json.GetProperty("poll_media");
var text = media.GetPropertyOrNull("text")?.GetStringOrNull() ?? ""; var text =
var emoji = media.GetPropertyOrNull("emoji")?.Pipe(Emoji.Parse); json.GetPropertyOrNull("poll_media")?.GetPropertyOrNull("text")?.GetStringOrNull()
?? "";
var emoji = json.GetPropertyOrNull("poll_media")
?.GetPropertyOrNull("emoji")
?.Pipe(Emoji.Parse);
return new PollAnswer(id, text, emoji); return new PollAnswer(id, text, emoji);
} }
@@ -4,14 +4,14 @@ using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data.Polls; namespace DiscordChatExporter.Core.Discord.Data.Polls;
// https://discord.com/developers/docs/resources/poll#poll-answer-count-object // https://discord.com/developers/docs/resources/poll#poll-answer-count-object
public record PollAnswerResult(int AnswerId, int Count, bool DidCurrentUserVote) public record PollAnswerResult(int Id, int Count, bool DidCurrentUserVote)
{ {
public static PollAnswerResult Parse(JsonElement json) public static PollAnswerResult Parse(JsonElement json)
{ {
var answerId = json.GetProperty("id").GetInt32(); var id = json.GetProperty("id").GetInt32();
var count = json.GetProperty("count").GetInt32(); var count = json.GetProperty("count").GetInt32();
var didCurrentUserVote = json.GetPropertyOrNull("me_voted")?.GetBooleanOrNull() ?? false; var didCurrentUserVote = json.GetPropertyOrNull("me_voted")?.GetBooleanOrNull() ?? false;
return new PollAnswerResult(answerId, count, didCurrentUserVote); return new PollAnswerResult(id, count, didCurrentUserVote);
} }
} }
@@ -13,7 +13,7 @@ public record PollResults(bool IsFinalized, IReadOnlyList<PollAnswerResult> Answ
public int WinningVoteCount { get; } = Answers.Select(a => a.Count).DefaultIfEmpty().Max(); public int WinningVoteCount { get; } = Answers.Select(a => a.Count).DefaultIfEmpty().Max();
public PollAnswerResult? TryGetAnswerResult(int answerId) => public PollAnswerResult? TryGetAnswerResult(int answerId) =>
Answers.FirstOrDefault(a => a.AnswerId == answerId); Answers.FirstOrDefault(a => a.Id == answerId);
public static PollResults Parse(JsonElement json) public static PollResults Parse(JsonElement json)
{ {
@@ -22,13 +22,13 @@ internal class ContainsMessageFilter(string text) : MessageFilter
public override bool IsMatch(Message message) => public override bool IsMatch(Message message) =>
IsMatch(message.Content) IsMatch(message.Content)
|| IsMatch(message.Poll?.Question)
|| message.Poll?.Answers.Any(a => IsMatch(a.Text)) == true
|| message.Embeds.Any(e => || message.Embeds.Any(e =>
IsMatch(e.Title) IsMatch(e.Title)
|| IsMatch(e.Author?.Name) || IsMatch(e.Author?.Name)
|| IsMatch(e.Description) || IsMatch(e.Description)
|| IsMatch(e.Footer?.Text) || IsMatch(e.Footer?.Text)
|| e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value)) || e.Fields.Any(f => IsMatch(f.Name) || IsMatch(f.Value))
); )
|| IsMatch(message.Poll?.Question)
|| message.Poll?.Answers.Any(a => IsMatch(a.Text)) == true;
} }
@@ -138,17 +138,17 @@
} }
else if (message.Kind == MessageKind.PollResult && pollResult is not null) else if (message.Kind == MessageKind.PollResult && pollResult is not null)
{ {
@if (!string.IsNullOrWhiteSpace(pollResult.QuestionText)) @if (!string.IsNullOrWhiteSpace(pollResult.Question))
{ {
<span>'s poll </span> <span>'s poll </span>
@if (message.Reference?.MessageId is not null) @if (message.Reference?.MessageId is not null)
{ {
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId">@pollResult.QuestionText</a> <a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId">@pollResult.Question</a>
} }
else else
{ {
<span class="chatlog__system-notification-link">@pollResult.QuestionText</span> <span class="chatlog__system-notification-link">@pollResult.Question</span>
} }
<span> has closed.</span> <span> has closed.</span>
@@ -175,8 +175,6 @@
@if (message.Kind == MessageKind.PollResult && pollResult is not null) @if (message.Kind == MessageKind.PollResult && pollResult is not null)
{ {
var hasWinningAnswer = pollResult.WinningAnswerId is not null;
<div class="chatlog__poll-result"> <div class="chatlog__poll-result">
<div class="chatlog__poll-result-answer"> <div class="chatlog__poll-result-answer">
@if (pollResult.WinningAnswerEmoji is not null) @if (pollResult.WinningAnswerEmoji is not null)
@@ -188,19 +186,19 @@
<div class="chatlog__poll-result-answer-info"> <div class="chatlog__poll-result-answer-info">
<div class="chatlog__poll-result-answer-text"> <div class="chatlog__poll-result-answer-text">
@(hasWinningAnswer ? pollResult.WinningAnswerText : "Poll closed") @(pollResult.WinningAnswerId is not null ? pollResult.WinningAnswerText : "Poll closed")
@if (hasWinningAnswer) @if (pollResult.WinningAnswerId is not null)
{ {
<span class="chatlog__poll-result-winner" title="Winning answer"><span aria-hidden="true">✓</span><span class="chatlog__poll-accessible-label">Winning answer</span></span> <span class="chatlog__poll-result-winner" title="Winning answer"><span aria-hidden="true">✓</span><span class="chatlog__poll-accessible-label">Winning answer</span></span>
} }
</div> </div>
<div class="chatlog__poll-result-answer-details"> <div class="chatlog__poll-result-answer-details">
@if (hasWinningAnswer) @if (pollResult.WinningAnswerId is not null)
{ {
<span>Winning answer</span> <span>Winning answer</span>
<span aria-hidden="true"> • </span> <span aria-hidden="true"> • </span>
<span>@pollResult.WinningVotePercentage.ToString("P0", Context.Request.CultureInfo)</span> <span>@pollResult.WinningVoteShare.ToString("P0", Context.Request.CultureInfo)</span>
} }
else else
{ {
@@ -490,22 +488,19 @@
<div class="chatlog__poll-answers"> <div class="chatlog__poll-answers">
@foreach (var answer in poll.Answers) @foreach (var answer in poll.Answers)
{ {
var pollResults = poll.Results; var answerResult = poll.Results?.TryGetAnswerResult(answer.Id);
var answerResult = pollResults?.TryGetAnswerResult(answer.Id);
var isSelected = answerResult?.DidCurrentUserVote == true;
var isWinning =
pollResults is { IsFinalized: true, WinningVoteCount: > 0 }
&& answerResult?.Count == pollResults.WinningVoteCount;
var isActiveSelection = pollResults?.IsFinalized != true && isSelected;
var answerVotePercentage = pollResults is { TotalVoteCount: > 0 }
? (double)(answerResult?.Count ?? 0) / pollResults.TotalVoteCount
: 0;
var answerVotePercentageCss =
answerVotePercentage
.ToString("P2", CultureInfo.InvariantCulture)
.Replace(" ", "");
<div class="chatlog__poll-answer @(isWinning ? "chatlog__poll-answer--winning" : isActiveSelection ? "chatlog__poll-answer--selected" : null)" style="--poll-answer-fill: @answerVotePercentageCss"> var isSelected = answerResult?.DidCurrentUserVote == true;
var isWinning =
poll.Results is { IsFinalized: true, WinningVoteCount: > 0 }
&& answerResult?.Count >= poll.Results.WinningVoteCount;
var answerVoteShare = poll.Results?.TotalVoteCount > 0
? (double)(answerResult?.Count ?? 0) / poll.Results.TotalVoteCount
: 0;
<div class="chatlog__poll-answer @(isWinning ? "chatlog__poll-answer--winning" : poll.Results?.IsFinalized != true && isSelected ? "chatlog__poll-answer--selected" : null)" style="--poll-answer-fill: @answerVoteShare.ToString("P2", CultureInfo.InvariantCulture).Replace(" ", "")">
<div class="chatlog__poll-answer-content"> <div class="chatlog__poll-answer-content">
@if (answer.Emoji is not null) @if (answer.Emoji is not null)
{ {
@@ -515,12 +510,12 @@
</div> </div>
<div class="chatlog__poll-answer-details"> <div class="chatlog__poll-answer-details">
@if (pollResults is not null) @if (poll.Results is not null)
{ {
var answerVoteCount = answerResult?.Count ?? 0; var answerVoteCount = answerResult?.Count ?? 0;
<span class="chatlog__poll-answer-count">@answerVoteCount.ToString("N0", Context.Request.CultureInfo) @(answerVoteCount == 1 ? "vote" : "votes")</span> <span class="chatlog__poll-answer-count">@answerVoteCount.ToString("N0", Context.Request.CultureInfo) @(answerVoteCount == 1 ? "vote" : "votes")</span>
<span class="chatlog__poll-answer-percentage">@answerVotePercentage.ToString("P0", Context.Request.CultureInfo)</span> <span class="chatlog__poll-answer-percentage">@answerVoteShare.ToString("P0", Context.Request.CultureInfo)</span>
} }
@if (isSelected) @if (isSelected)
@@ -42,9 +42,9 @@ internal static class PlainTextMessageExtensions
.WhereNotNull() .WhereNotNull()
.FirstOrDefault() .FirstOrDefault()
is { } pollResult is { } pollResult
? string.IsNullOrWhiteSpace(pollResult.QuestionText) ? string.IsNullOrWhiteSpace(pollResult.Question)
? $"{message.Author.DisplayName}'s poll has closed." ? $"{message.Author.DisplayName}'s poll has closed."
: $"{message.Author.DisplayName}'s poll {pollResult.QuestionText} has closed." : $"{message.Author.DisplayName}'s poll {pollResult.Question} has closed."
: "A poll has closed.", : "A poll has closed.",
_ => message.Content, _ => message.Content,