mirror of
https://github.com/Tyrrrz/DiscordChatExporter.git
synced 2026-08-15 23:13:02 -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,
|
MessageReference? Reference,
|
||||||
Message? ReferencedMessage,
|
Message? ReferencedMessage,
|
||||||
MessageSnapshot? ForwardedMessage,
|
MessageSnapshot? ForwardedMessage,
|
||||||
Interaction? Interaction
|
Interaction? Interaction,
|
||||||
|
Poll? Poll
|
||||||
) : IHasId
|
) : IHasId
|
||||||
{
|
{
|
||||||
public bool IsEmpty { get; } =
|
public bool IsEmpty { get; } =
|
||||||
string.IsNullOrWhiteSpace(Content)
|
string.IsNullOrWhiteSpace(Content)
|
||||||
&& !Attachments.Any()
|
&& !Attachments.Any()
|
||||||
&& !Embeds.Any()
|
&& !Embeds.Any()
|
||||||
&& !Stickers.Any();
|
&& !Stickers.Any()
|
||||||
|
&& Poll is null;
|
||||||
|
|
||||||
public bool IsSystemNotification { get; } =
|
public bool IsSystemNotification { get; } =
|
||||||
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
Kind is >= MessageKind.RecipientAdd and <= MessageKind.ThreadCreated;
|
||||||
|
|
@ -186,6 +188,7 @@ public partial record Message
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
|
|
||||||
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
var interaction = json.GetPropertyOrNull("interaction")?.Pipe(Interaction.Parse);
|
||||||
|
var poll = json.GetPropertyOrNull("poll")?.Pipe(Poll.Parse);
|
||||||
|
|
||||||
return new Message(
|
return new Message(
|
||||||
id,
|
id,
|
||||||
|
|
@ -205,7 +208,8 @@ public partial record Message
|
||||||
messageReference,
|
messageReference,
|
||||||
referencedMessage,
|
referencedMessage,
|
||||||
forwardedMessage,
|
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="WebMarkupMin.Core" />
|
||||||
<PackageReference Include="YoutubeExplode" />
|
<PackageReference Include="YoutubeExplode" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<InternalsVisibleTo Include="DiscordChatExporter.Cli.Tests" />
|
||||||
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -401,6 +401,64 @@
|
||||||
</div>
|
</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 *@
|
@* Invites *@
|
||||||
@{
|
@{
|
||||||
var inviteCodes = MarkdownParser
|
var inviteCodes = MarkdownParser
|
||||||
|
|
@ -766,4 +824,4 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -431,6 +431,82 @@
|
||||||
font-weight: 500;
|
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 {
|
.chatlog__attachment {
|
||||||
position: relative;
|
position: relative;
|
||||||
width: fit-content;
|
width: fit-content;
|
||||||
|
|
@ -1090,4 +1166,4 @@
|
||||||
@* Preamble cuts off at this point *@
|
@* Preamble cuts off at this point *@
|
||||||
<!--wmm:ignore-->
|
<!--wmm:ignore-->
|
||||||
<div class="chatlog">
|
<div class="chatlog">
|
||||||
<!--/wmm:ignore-->
|
<!--/wmm:ignore-->
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue