diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs index 50df5163..a5fe30bf 100644 --- a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs @@ -28,6 +28,9 @@ public partial record Embed( // but the client can render multiple images in some cases. public EmbedImage? Image => Images.FirstOrDefault(); + public PollResultEmbedProjection? TryGetPollResult() => + PollResultEmbedProjection.TryResolve(this); + public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => SpotifyTrackEmbedProjection.TryResolve(this); @@ -47,6 +50,7 @@ public partial record Embed var kind = json.GetPropertyOrNull("type") ?.GetStringOrNull() + ?.Replace("_", "") .Pipe(s => Enum.ParseOrNull(s, true)) ?? EmbedKind.Rich; diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/EmbedKind.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/EmbedKind.cs index b1240b0f..5ffc82f4 100644 --- a/DiscordChatExporter.Core/Discord/Data/Embeds/EmbedKind.cs +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/EmbedKind.cs @@ -8,4 +8,5 @@ public enum EmbedKind Video, Gifv, Link, + PollResult, } diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/PollResultEmbedProjection.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/PollResultEmbedProjection.cs new file mode 100644 index 00000000..41054e87 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/PollResultEmbedProjection.cs @@ -0,0 +1,83 @@ +using System; +using System.Globalization; +using System.Linq; +using DiscordChatExporter.Core.Discord; +using DiscordChatExporter.Core.Discord.Data; +using PowerKit.Extensions; + +namespace DiscordChatExporter.Core.Discord.Data.Embeds; + +// https://docs.discord.com/developers/resources/message#embed-fields-by-embed-type-poll-result-embed-fields +public partial record PollResultEmbedProjection( + string QuestionText, + int WinningVoteCount, + int TotalVoteCount, + int? WinningAnswerId, + string? WinningAnswerText, + Emoji? WinningAnswerEmoji +) +{ + public double WinningVotePercentage { get; } = + TotalVoteCount > 0 ? (double)WinningVoteCount / TotalVoteCount : 0; +} + +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) + { + var name = TryGetFieldValue(embed, "victor_answer_emoji_name"); + if (string.IsNullOrWhiteSpace(name)) + return null; + + var id = + TryGetFieldValue(embed, "victor_answer_emoji_id") is { } idValue + && Snowflake.TryParse(idValue) is { } parsedId + ? parsedId + : (Snowflake?)null; + + var isAnimated = + bool.TryParse( + TryGetFieldValue(embed, "victor_answer_emoji_animated"), + out var parsedIsAnimated + ) && parsedIsAnimated; + + return new Emoji(id, name, isAnimated); + } + + public static PollResultEmbedProjection? TryResolve(Embed embed) + { + if (embed.Kind != EmbedKind.PollResult) + return null; + + var questionText = TryGetFieldValue(embed, "poll_question_text") ?? ""; + 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 winningAnswerEmoji = TryParseWinningAnswerEmoji(embed); + + return new PollResultEmbedProjection( + questionText, + winningVoteCount, + totalVoteCount, + winningAnswerId, + winningAnswerText, + winningAnswerEmoji + ); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 29465ab5..bae435ad 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -4,6 +4,7 @@ using System.Linq; using System.Text.Json; using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Embeds; +using DiscordChatExporter.Core.Discord.Data.Polls; using JsonExtensions.Reading; using PowerKit.Extensions; @@ -28,17 +29,20 @@ 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; + Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated + || Kind == MessageKind.PollResult; public bool IsReply { get; } = Kind == MessageKind.Reply; @@ -187,6 +191,8 @@ public partial record Message var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse); + var poll = json.GetPropertyOrNull("poll")?.Pipe(Poll.Parse); + return new Message( id, kind, @@ -205,7 +211,8 @@ public partial record Message messageReference, referencedMessage, forwardedMessage, - interaction + interaction, + poll ); } } diff --git a/DiscordChatExporter.Core/Discord/Data/MessageKind.cs b/DiscordChatExporter.Core/Discord/Data/MessageKind.cs index 07eb431a..66518a9c 100644 --- a/DiscordChatExporter.Core/Discord/Data/MessageKind.cs +++ b/DiscordChatExporter.Core/Discord/Data/MessageKind.cs @@ -14,4 +14,5 @@ public enum MessageKind ThreadCreated = 18, Reply = 19, ThreadStarterMessage = 21, + PollResult = 46, } diff --git a/DiscordChatExporter.Core/Discord/Data/Polls/Poll.cs b/DiscordChatExporter.Core/Discord/Data/Polls/Poll.cs new file mode 100644 index 00000000..51c84529 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Polls/Poll.cs @@ -0,0 +1,40 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using JsonExtensions.Reading; +using PowerKit.Extensions; + +namespace DiscordChatExporter.Core.Discord.Data.Polls; + +// 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); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Polls/PollAnswer.cs b/DiscordChatExporter.Core/Discord/Data/Polls/PollAnswer.cs new file mode 100644 index 00000000..a93038cb --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Polls/PollAnswer.cs @@ -0,0 +1,20 @@ +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data; +using JsonExtensions.Reading; +using PowerKit.Extensions; + +namespace DiscordChatExporter.Core.Discord.Data.Polls; + +// https://discord.com/developers/docs/resources/poll#poll-answer-object +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); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Polls/PollAnswerResult.cs b/DiscordChatExporter.Core/Discord/Data/Polls/PollAnswerResult.cs new file mode 100644 index 00000000..d07192ac --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Polls/PollAnswerResult.cs @@ -0,0 +1,17 @@ +using System.Text.Json; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Polls; + +// https://discord.com/developers/docs/resources/poll#poll-answer-count-object +public record PollAnswerResult(int AnswerId, int Count, bool DidCurrentUserVote) +{ + public static PollAnswerResult 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 PollAnswerResult(answerId, count, didCurrentUserVote); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Polls/PollResults.cs b/DiscordChatExporter.Core/Discord/Data/Polls/PollResults.cs new file mode 100644 index 00000000..b30ddce1 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Polls/PollResults.cs @@ -0,0 +1,31 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Polls; + +// https://discord.com/developers/docs/resources/poll#poll-results-object +public record PollResults(bool IsFinalized, IReadOnlyList Answers) +{ + public int TotalVoteCount { get; } = Answers.Sum(a => a.Count); + + public int WinningVoteCount { get; } = Answers.Select(a => a.Count).DefaultIfEmpty().Max(); + + public PollAnswerResult? TryGetAnswerResult(int answerId) => + Answers.FirstOrDefault(a => a.AnswerId == answerId); + + public static PollResults Parse(JsonElement json) + { + var isFinalized = json.GetPropertyOrNull("is_finalized")?.GetBooleanOrNull() ?? false; + + var answers = + json.GetPropertyOrNull("answer_counts") + ?.EnumerateArrayOrNull() + ?.Select(PollAnswerResult.Parse) + .ToArray() + ?? []; + + return new PollResults(isFinalized, answers); + } +} diff --git a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs index ba47a10f..22d61e96 100644 --- a/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs +++ b/DiscordChatExporter.Core/Exporting/Filtering/ContainsMessageFilter.cs @@ -22,6 +22,8 @@ internal class ContainsMessageFilter(string text) : MessageFilter public override bool IsMatch(Message message) => IsMatch(message.Content) + || IsMatch(message.Poll?.Question) + || message.Poll?.Answers.Any(a => IsMatch(a.Text)) == true || message.Embeds.Any(e => IsMatch(e.Title) || IsMatch(e.Author?.Name) diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index b3c06837..9c50f563 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -1,5 +1,6 @@ @using System @using System.Collections.Generic +@using System.Globalization @using System.Linq @using System.Threading.Tasks @using RazorBlade @@ -46,6 +47,11 @@ ? message.Author.DisplayName : authorMember?.DisplayName ?? message.Author.DisplayName; + var pollResult = message.Embeds + .Select(e => e.TryGetPollResult()) + .WhereNotNull() + .FirstOrDefault(); +
@* System notification *@ @@ -63,6 +69,7 @@ MessageKind.ChannelPinnedMessage => "pin-icon", MessageKind.GuildMemberJoin => "join-icon", MessageKind.ThreadCreated => "thread-icon", + MessageKind.PollResult => "poll-icon", _ => "pencil-icon" }; } @@ -75,8 +82,11 @@ @* Author name *@ @authorDisplayName - @* Space out the content *@ - + @* Space out the content (poll-result text starts with an apostrophe) *@ + @if (message.Kind != MessageKind.PollResult) + { + + } @* System notification content *@ @@ -126,6 +136,32 @@ { joined the server. } + else if (message.Kind == MessageKind.PollResult && pollResult is not null) + { + @if (!string.IsNullOrWhiteSpace(pollResult.QuestionText)) + { + 's poll + + @if (message.Reference?.MessageId is not null) + { + @pollResult.QuestionText + } + else + { + @pollResult.QuestionText + } + + has closed. + } + else + { + 's poll has closed. + } + } + else if (message.Kind == MessageKind.PollResult) + { + 's poll has closed. + } else { @message.Content.ToLowerInvariant() @@ -136,6 +172,50 @@ @FormatDate(message.Timestamp) + + @if (message.Kind == MessageKind.PollResult && pollResult is not null) + { + var hasWinningAnswer = pollResult.WinningAnswerId is not null; + +
+
+ @if (pollResult.WinningAnswerEmoji is not null) + { +
+ @pollResult.WinningAnswerEmoji.Name +
+ } + +
+
+ @(hasWinningAnswer ? pollResult.WinningAnswerText : "Poll closed") + + @if (hasWinningAnswer) + { + Winning answer + } +
+
+ @if (hasWinningAnswer) + { + Winning answer + + @pollResult.WinningVotePercentage.ToString("P0", Context.Request.CultureInfo) + } + else + { + @pollResult.TotalVoteCount.ToString("N0", Context.Request.CultureInfo) @(pollResult.TotalVoteCount == 1 ? "vote" : "votes") + } +
+
+
+ + @if (message.Reference?.MessageId is not null) + { + View Poll + } +
+ }
} // Regular message @@ -401,6 +481,87 @@
} + @* Poll *@ + @if (message.Poll is { } poll) + { +
+
@poll.Question
+ +
+ @foreach (var answer in poll.Answers) + { + var pollResults = poll.Results; + 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(" ", ""); + +
+
+ @if (answer.Emoji is not null) + { + @answer.Emoji.Name + } + @answer.Text +
+ +
+ @if (pollResults is not null) + { + var answerVoteCount = answerResult?.Count ?? 0; + + @answerVoteCount.ToString("N0", Context.Request.CultureInfo) @(answerVoteCount == 1 ? "vote" : "votes") + @answerVotePercentage.ToString("P0", Context.Request.CultureInfo) + } + + @if (isSelected) + { + Selected + } +
+
+ } +
+ + @if (poll.Results is not null || poll.AllowsMultipleAnswers || poll.ExpiresAt is not null) + { + + } +
+ } + @* Invites *@ @{ var inviteCodes = MarkdownParser @@ -766,4 +927,4 @@ } - \ No newline at end of file + diff --git a/DiscordChatExporter.Core/Exporting/PlainTextMessageExtensions.cs b/DiscordChatExporter.Core/Exporting/PlainTextMessageExtensions.cs index e12ada26..3763580e 100644 --- a/DiscordChatExporter.Core/Exporting/PlainTextMessageExtensions.cs +++ b/DiscordChatExporter.Core/Exporting/PlainTextMessageExtensions.cs @@ -37,6 +37,15 @@ internal static class PlainTextMessageExtensions MessageKind.ChannelPinnedMessage => "Pinned a message.", MessageKind.ThreadCreated => "Started a thread.", MessageKind.GuildMemberJoin => "Joined the server.", + MessageKind.PollResult => message + .Embeds.Select(e => e.TryGetPollResult()) + .WhereNotNull() + .FirstOrDefault() + is { } pollResult + ? string.IsNullOrWhiteSpace(pollResult.QuestionText) + ? $"{message.Author.DisplayName}'s poll has closed." + : $"{message.Author.DisplayName}'s poll {pollResult.QuestionText} has closed." + : "A poll has closed.", _ => message.Content, }; diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index 1b5f300a..a1b2b61f 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -431,6 +431,229 @@ 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; + position: relative; + 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"); + overflow: hidden; + } + + .chatlog__poll-answer::before { + position: absolute; + inset: 0 auto 0 0; + width: var(--poll-answer-fill, 0%); + background-color: @Themed("#3a3c42", "#dfe1e5"); + content: ""; + } + + .chatlog__poll-answer--winning { + border-color: #23a559; + } + + .chatlog__poll-answer--winning::before { + background-color: @Themed("#2f5142", "#d8f3e3"); + } + + .chatlog__poll-answer--selected { + border-color: #5865f2; + } + + .chatlog__poll-answer--selected::before { + background-color: @Themed("#3c4164", "#e1e4ff"); + } + + .chatlog__poll-answer-content { + display: flex; + position: relative; + z-index: 1; + align-items: center; + min-width: 0; + } + + .chatlog__poll-answer-details { + display: flex; + position: relative; + z-index: 1; + align-items: center; + gap: 0.5rem; + margin-left: 0.75rem; + } + + .chatlog__poll-answer-selected { + display: inline-flex; + align-items: center; + justify-content: center; + width: 1.25rem; + height: 1.25rem; + border-radius: 50%; + background-color: @Themed("#f2f3f5", "#313338"); + color: @Themed("#313338", "#f2f3f5"); + font-size: 0.75rem; + font-weight: 700; + } + + .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, + .chatlog__poll-answer-percentage { + color: @Themed("#b5bac1", "#5c646c"); + font-size: 0.8rem; + font-weight: 600; + white-space: nowrap; + } + + .chatlog__poll-answer-percentage { + color: @Themed("#f2f3f5", "#2e3338"); + font-size: 0.9rem; + } + + .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__poll-accessible-label { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + border: 0; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + } + + .chatlog__poll-result { + display: flex; + box-sizing: border-box; + align-items: center; + justify-content: space-between; + width: 100%; + max-width: 25rem; + min-height: 3.5rem; + margin-top: 0.45rem; + padding: 0.45rem; + border: 1px solid @Themed("#3f4147", "#d5d8dc"); + border-radius: 0.5rem; + background-color: @Themed("#2b2d31", "#f2f3f5"); + } + + .chatlog__poll-result-answer { + display: flex; + align-items: center; + min-width: 0; + } + + .chatlog__poll-result-emoji-container { + display: flex; + flex-shrink: 0; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + margin-right: 0.55rem; + border-radius: 0.4rem; + background-color: @Themed("#383a40", "#e3e5e8"); + } + + .chatlog__poll-result-emoji { + width: 1.65rem; + height: 1.65rem; + } + + .chatlog__poll-result-answer-info { + min-width: 0; + } + + .chatlog__poll-result-answer-text { + color: @Themed("#f2f3f5", "#060607"); + font-size: 0.9rem; + font-weight: 600; + overflow-wrap: anywhere; + } + + .chatlog__poll-result-winner { + display: inline-flex; + align-items: center; + justify-content: center; + width: 0.9rem; + height: 0.9rem; + margin-left: 0.15rem; + border-radius: 50%; + background-color: #23a559; + color: #ffffff; + font-size: 0.65rem; + vertical-align: 0.05rem; + } + + .chatlog__poll-result-answer-details { + margin-top: 0.1rem; + color: @Themed("#b5bac1", "#5c646c"); + font-size: 0.75rem; + } + + .chatlog__poll-result-button { + flex-shrink: 0; + margin-left: 0.75rem; + padding: 0.55rem 0.75rem; + border-radius: 0.25rem; + background-color: @Themed("#4e5058", "#d6d9dc"); + color: @Themed("#ffffff", "#1e1f22"); + font-size: 0.8rem; + font-weight: 500; + } + + .chatlog__poll-result-button:hover { + background-color: @Themed("#6d6f78", "#c4c8cc"); + text-decoration: none; + } + .chatlog__attachment { position: relative; width: fit-content; @@ -1039,6 +1262,9 @@ + + + @@ -1090,4 +1316,4 @@ @* Preamble cuts off at this point *@
- \ No newline at end of file +