Render polls in HTML exports (#1581)

Co-authored-by: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com>
This commit is contained in:
Morax
2026-08-27 09:12:17 -07:00
committed by GitHub
parent f6865c8216
commit b049fb2919
13 changed files with 610 additions and 8 deletions
@@ -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<EmbedKind>(s, true))
?? EmbedKind.Rich;
@@ -8,4 +8,5 @@ public enum EmbedKind
Video,
Gifv,
Link,
PollResult,
}
@@ -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
);
}
}
@@ -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
);
}
}
@@ -14,4 +14,5 @@ public enum MessageKind
ThreadCreated = 18,
Reply = 19,
ThreadStarterMessage = 21,
PollResult = 46,
}
@@ -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<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);
}
}
@@ -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);
}
}
@@ -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);
}
}
@@ -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<PollAnswerResult> 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);
}
}
@@ -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)
@@ -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();
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message">
@* 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 *@
<span class="chatlog__system-notification-author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorDisplayName</span>
@* Space out the content *@
<span> </span>
@* Space out the content (poll-result text starts with an apostrophe) *@
@if (message.Kind != MessageKind.PollResult)
{
<span> </span>
}
@* System notification content *@
<span class="chatlog__system-notification-content">
@@ -126,6 +136,32 @@
{
<span>joined the server.</span>
}
else if (message.Kind == MessageKind.PollResult && pollResult is not null)
{
@if (!string.IsNullOrWhiteSpace(pollResult.QuestionText))
{
<span>'s poll </span>
@if (message.Reference?.MessageId is not null)
{
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId">@pollResult.QuestionText</a>
}
else
{
<span class="chatlog__system-notification-link">@pollResult.QuestionText</span>
}
<span> has closed.</span>
}
else
{
<span>'s poll has closed.</span>
}
}
else if (message.Kind == MessageKind.PollResult)
{
<span>'s poll has closed.</span>
}
else
{
<span>@message.Content.ToLowerInvariant()</span>
@@ -136,6 +172,50 @@
<span class="chatlog__system-notification-timestamp" title="@FormatDate(message.Timestamp, "f")">
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
@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-answer">
@if (pollResult.WinningAnswerEmoji is not null)
{
<div class="chatlog__poll-result-emoji-container">
<img class="chatlog__poll-result-emoji" alt="@pollResult.WinningAnswerEmoji.Name" title="@pollResult.WinningAnswerEmoji.Code" src="@await ResolveAssetUrlAsync(pollResult.WinningAnswerEmoji.ImageUrl)" loading="lazy">
</div>
}
<div class="chatlog__poll-result-answer-info">
<div class="chatlog__poll-result-answer-text">
@(hasWinningAnswer ? pollResult.WinningAnswerText : "Poll closed")
@if (hasWinningAnswer)
{
<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 class="chatlog__poll-result-answer-details">
@if (hasWinningAnswer)
{
<span>Winning answer</span>
<span aria-hidden="true"> • </span>
<span>@pollResult.WinningVotePercentage.ToString("P0", Context.Request.CultureInfo)</span>
}
else
{
<span>@pollResult.TotalVoteCount.ToString("N0", Context.Request.CultureInfo) @(pollResult.TotalVoteCount == 1 ? "vote" : "votes")</span>
}
</div>
</div>
</div>
@if (message.Reference?.MessageId is not null)
{
<a class="chatlog__poll-result-button" href="#chatlog__message-container-@message.Reference.MessageId">View Poll</a>
}
</div>
}
</div>
}
// Regular message
@@ -401,6 +481,87 @@
</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 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(" ", "");
<div class="chatlog__poll-answer @(isWinning ? "chatlog__poll-answer--winning" : isActiveSelection ? "chatlog__poll-answer--selected" : null)" style="--poll-answer-fill: @answerVotePercentageCss">
<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>
<div class="chatlog__poll-answer-details">
@if (pollResults is not null)
{
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-percentage">@answerVotePercentage.ToString("P0", Context.Request.CultureInfo)</span>
}
@if (isSelected)
{
<span class="chatlog__poll-answer-selected" title="Selected"><span aria-hidden="true">✓</span><span class="chatlog__poll-accessible-label">Selected</span></span>
}
</div>
</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">Poll closed</span>
}
@if (poll.ExpiresAt is not null && poll.Results?.IsFinalized != true)
{
<span class="chatlog__poll-footer-item">
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 +927,4 @@
</div>
</div>
}
</div>
</div>
@@ -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,
};
@@ -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 @@
<symbol id="pin-icon" viewBox="0 0 18 18">
<path fill="#b9bbbe" d="m16.908 8.39684-8.29587-8.295827-1.18584 1.184157 1.18584 1.18584-4.14834 4.1475v.00167l-1.18583-1.18583-1.185 1.18583 3.55583 3.55502-4.740831 4.74 1.185001 1.185 4.74083-4.74 3.55581 3.555 1.185-1.185-1.185-1.185 4.1475-4.14836h.0009l1.185 1.185z" />
</symbol>
<symbol id="poll-icon" viewBox="0 0 18 18">
<path fill="#b9bbbe" d="M2 3h2v2H2V3Zm4 0h10v2H6V3ZM2 8h2v2H2V8Zm4 0h10v2H6V8ZM2 13h2v2H2v-2Zm4 0h10v2H6v-2Z" />
</symbol>
<symbol id="channel-icon" viewBox="0 0 24 24">
<path fill="#b9bbbe" d="M5.88657 21C5.57547 21 5.3399 20.7189 5.39427 20.4126L6.00001 17H2.59511C2.28449 17 2.04905 16.7198 2.10259 16.4138L2.27759 15.4138C2.31946 15.1746 2.52722 15 2.77011 15H6.35001L7.41001 9H4.00511C3.69449 9 3.45905 8.71977 3.51259 8.41381L3.68759 7.41381C3.72946 7.17456 3.93722 7 4.18011 7H7.76001L8.39677 3.41262C8.43914 3.17391 8.64664 3 8.88907 3H9.87344C10.1845 3 10.4201 3.28107 10.3657 3.58738L9.76001 7H15.76L16.3968 3.41262C16.4391 3.17391 16.6466 3 16.8891 3H17.8734C18.1845 3 18.4201 3.28107 18.3657 3.58738L17.76 7H21.1649C21.4755 7 21.711 7.28023 21.6574 7.58619L21.4824 8.58619C21.4406 8.82544 21.2328 9 20.9899 9H17.41L16.35 15H19.7549C20.0655 15 20.301 15.2802 20.2474 15.5862L20.0724 16.5862C20.0306 16.8254 19.8228 17 19.5799 17H16L15.3632 20.5874C15.3209 20.8261 15.1134 21 14.8709 21H13.8866C13.5755 21 13.3399 20.7189 13.3943 20.4126L14 17H8.00001L7.36325 20.5874C7.32088 20.8261 7.11337 21 6.87094 21H5.88657ZM9.41045 9L8.35045 15H14.3504L15.4104 9H9.41045Z" />
</symbol>
@@ -1090,4 +1316,4 @@
@* Preamble cuts off at this point *@
<!--wmm:ignore-->
<div class="chatlog">
<!--/wmm:ignore-->
<!--/wmm:ignore-->