From 23e1850d152f8adb85c5445d84fe9a13a387e954 Mon Sep 17 00:00:00 2001 From: Oleksii Holub <1935960+Tyrrrz@users.noreply.github.com> Date: Thu, 30 Jun 2022 20:56:44 +0300 Subject: [PATCH] Render consecutive twitter embeds as on Closes #695 --- .../Specs/HtmlWriting/EmbedSpecs.cs | 2 +- .../Discord/Data/Embeds/Embed.cs | 17 +++++- .../Discord/Data/Message.cs | 61 ++++++++++++++++++- .../Writers/Html/MessageGroupTemplate.cshtml | 25 +++++--- .../Writers/Html/PreambleTemplate.cshtml | 14 +++++ .../Exporting/Writers/JsonMessageWriter.cs | 50 ++++++++------- .../Writers/PlainTextMessageWriter.cs | 7 ++- .../Utils/Extensions/CollectionExtensions.cs | 18 ++++++ 8 files changed, 155 insertions(+), 39 deletions(-) create mode 100644 DiscordChatExporter.Core/Utils/Extensions/CollectionExtensions.cs diff --git a/DiscordChatExporter.Cli.Tests/Specs/HtmlWriting/EmbedSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/HtmlWriting/EmbedSpecs.cs index a4070d3..72f511e 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/HtmlWriting/EmbedSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/HtmlWriting/EmbedSpecs.cs @@ -106,7 +106,7 @@ public class EmbedSpecs : IClassFixture iframeUrl.Should().Be("https://www.youtube.com/embed/qOWW4OlgbvE"); } - [Fact(Skip = "Unimplemented")] + [Fact] public async Task Message_containing_a_Twitter_post_link_with_multiple_images_is_rendered_as_a_single_embed() { // https://github.com/Tyrrrz/DiscordChatExporter/issues/695 diff --git a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs index 15d333d..3e8363c 100644 --- a/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs +++ b/DiscordChatExporter.Core/Discord/Data/Embeds/Embed.cs @@ -18,7 +18,7 @@ public partial record Embed( string? Description, IReadOnlyList Fields, EmbedImage? Thumbnail, - EmbedImage? Image, + IReadOnlyList Images, EmbedFooter? Footer) { public PlainImageEmbedProjection? TryGetPlainImage() => @@ -47,7 +47,18 @@ public partial record Embed Array.Empty(); var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); - var image = json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse); + + // Under the Discord API model, embeds can only have at most one image. + // Because of that, embeds that are rendered with multiple images on the client + // (e.g. tweet embeds), are exposed from the API as multiple separate embeds. + // Our embed model is consistent with the user-facing side of Discord, so images + // are stored as an array. The API will only ever return one image, but we deal + // with this by merging related embeds at the end of the message parsing process. + // https://github.com/Tyrrrz/DiscordChatExporter/issues/695 + var images = + json.GetPropertyOrNull("image")?.Pipe(EmbedImage.Parse).Enumerate().ToArray() ?? + Array.Empty(); + var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse); return new Embed( @@ -59,7 +70,7 @@ public partial record Embed description, fields, thumbnail, - image, + images, footer ); } diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index de28907..1a67e0e 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -27,6 +27,62 @@ public record Message( MessageReference? Reference, Message? ReferencedMessage) : IHasId { + private static IReadOnlyList NormalizeEmbeds(IReadOnlyList embeds) + { + if (embeds.Count <= 1) + return embeds; + + // Discord API doesn't support embeds with multiple images, even though the Discord client does. + // To work around this, it seems that the API returns multiple consecutive embeds with different images, + // which are then merged together on the client. We need to replicate the same behavior ourselves. + // Currently, only known case where this workaround is required is Twitter embeds. + // https://github.com/Tyrrrz/DiscordChatExporter/issues/695 + + var normalizedEmbeds = new List(); + + for (var i = 0; i < embeds.Count; i++) + { + var embed = embeds[i]; + + if (embed.Url?.Contains("://twitter.com/") == true) + { + // Find embeds with the same URL that only contain a single image and nothing else + var trailingEmbeds = embeds + .Skip(i + 1) + .TakeWhile(e => + e.Url == embed.Url && + e.Timestamp is null && + e.Author is null && + e.Color is null && + string.IsNullOrWhiteSpace(e.Description) && + !e.Fields.Any() && + e.Images.Count == 1 && + e.Footer is null + ) + .ToArray(); + + if (trailingEmbeds.Any()) + { + // Concatenate all images into one embed + var images = embed.Images.Concat(trailingEmbeds.SelectMany(e => e.Images)).ToArray(); + normalizedEmbeds.Add(embed with { Images = images }); + + i += trailingEmbeds.Length; + } + else + { + normalizedEmbeds.Add(embed); + } + } + else + { + normalizedEmbeds.Add(embed); + } + } + + return normalizedEmbeds; + } + public static Message Parse(JsonElement json) { var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); @@ -59,9 +115,10 @@ public record Message( json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ?? Array.Empty(); - var embeds = + var embeds = NormalizeEmbeds( json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ?? - Array.Empty(); + Array.Empty() + ); var stickers = json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ?? diff --git a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml index afe112d..ffa4295 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/Writers/Html/MessageGroupTemplate.cshtml @@ -44,10 +44,9 @@ }
-@for (var i = 0; i < Model.Messages.Count; i++) +@foreach (var (message, i) in Model.Messages.WithIndex()) { var isFirst = i == 0; - var message = Model.Messages[i]; // Hide message content if it only contains a link to an image which is embedded, and nothing else var isContentHidden = @@ -161,7 +160,7 @@ @{/* Attachments */} @foreach (var attachment in message.Attachments) { -
+
@{/* Spoiler caption */} @if (attachment.IsSpoiler) { @@ -399,13 +398,21 @@ }
- @{/* Embed image */} - @if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url)) + @{/* Embed images */} + @if (embed.Images.Any()) { -
- - Image - +
+ @foreach (var image in embed.Images) + { + if (!string.IsNullOrWhiteSpace(image.Url)) + { +
+ + Image + +
+ } + }
} diff --git a/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml index f4e5afe..8fb709c 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/Writers/Html/PreambleTemplate.cshtml @@ -500,13 +500,27 @@ border-radius: 3px; } + .chatlog__embed-images { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0 0.35rem; + } + + .chatlog__embed-images--single { + display: block; + } + .chatlog__embed-image-container { margin-top: 0.6rem; } .chatlog__embed-image { + object-fit: cover; + object-position: center; max-width: 500px; max-height: 400px; + width: 100%; + height: 100%; border-radius: 3px; } diff --git a/DiscordChatExporter.Core/Exporting/Writers/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/Writers/JsonMessageWriter.cs index 3c523bf..c69799b 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/Writers/JsonMessageWriter.cs @@ -20,6 +20,7 @@ internal class JsonMessageWriter : MessageWriter { _writer = new Utf8JsonWriter(stream, new JsonWriterOptions { + // https://github.com/Tyrrrz/DiscordChatExporter/issues/450 Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Indented = true, // Validation errors may mask actual failures @@ -50,7 +51,7 @@ internal class JsonMessageWriter : MessageWriter EmbedAuthor embedAuthor, CancellationToken cancellationToken = default) { - _writer.WriteStartObject("author"); + _writer.WriteStartObject(); _writer.WriteString("name", embedAuthor.Name); _writer.WriteString("url", embedAuthor.Url); @@ -62,27 +63,11 @@ internal class JsonMessageWriter : MessageWriter await _writer.FlushAsync(cancellationToken); } - private async ValueTask WriteEmbedThumbnailAsync( - EmbedImage embedThumbnail, - CancellationToken cancellationToken = default) - { - _writer.WriteStartObject("thumbnail"); - - if (!string.IsNullOrWhiteSpace(embedThumbnail.Url)) - _writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedThumbnail.ProxyUrl ?? embedThumbnail.Url, cancellationToken)); - - _writer.WriteNumber("width", embedThumbnail.Width); - _writer.WriteNumber("height", embedThumbnail.Height); - - _writer.WriteEndObject(); - await _writer.FlushAsync(cancellationToken); - } - private async ValueTask WriteEmbedImageAsync( EmbedImage embedImage, CancellationToken cancellationToken = default) { - _writer.WriteStartObject("image"); + _writer.WriteStartObject(); if (!string.IsNullOrWhiteSpace(embedImage.Url)) _writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken)); @@ -98,7 +83,7 @@ internal class JsonMessageWriter : MessageWriter EmbedFooter embedFooter, CancellationToken cancellationToken = default) { - _writer.WriteStartObject("footer"); + _writer.WriteStartObject(); _writer.WriteString("text", embedFooter.Text); @@ -138,16 +123,37 @@ internal class JsonMessageWriter : MessageWriter _writer.WriteString("color", embed.Color.Value.ToHex()); if (embed.Author is not null) + { + _writer.WritePropertyName("author"); await WriteEmbedAuthorAsync(embed.Author, cancellationToken); + } if (embed.Thumbnail is not null) - await WriteEmbedThumbnailAsync(embed.Thumbnail, cancellationToken); + { + _writer.WritePropertyName("thumbnail"); + await WriteEmbedImageAsync(embed.Thumbnail, cancellationToken); + } - if (embed.Image is not null) - await WriteEmbedImageAsync(embed.Image, cancellationToken); + // Legacy: backwards-compatibility for old embeds with a single image + if (embed.Images.Count > 0) + { + _writer.WritePropertyName("image"); + await WriteEmbedImageAsync(embed.Images[0], cancellationToken); + } if (embed.Footer is not null) + { + _writer.WritePropertyName("footer"); await WriteEmbedFooterAsync(embed.Footer, cancellationToken); + } + + // Images + _writer.WriteStartArray("images"); + + foreach (var image in embed.Images) + await WriteEmbedImageAsync(image, cancellationToken); + + _writer.WriteEndArray(); // Fields _writer.WriteStartArray("fields"); diff --git a/DiscordChatExporter.Core/Exporting/Writers/PlainTextMessageWriter.cs b/DiscordChatExporter.Core/Exporting/Writers/PlainTextMessageWriter.cs index cee268f..cf24a44 100644 --- a/DiscordChatExporter.Core/Exporting/Writers/PlainTextMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/Writers/PlainTextMessageWriter.cs @@ -88,8 +88,11 @@ internal class PlainTextMessageWriter : MessageWriter if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url)) await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken)); - if (!string.IsNullOrWhiteSpace(embed.Image?.Url)) - await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url, cancellationToken)); + foreach (var image in embed.Images) + { + if (!string.IsNullOrWhiteSpace(image.Url)) + await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(image.ProxyUrl ?? image.Url, cancellationToken)); + } if (!string.IsNullOrWhiteSpace(embed.Footer?.Text)) await _writer.WriteLineAsync(embed.Footer.Text); diff --git a/DiscordChatExporter.Core/Utils/Extensions/CollectionExtensions.cs b/DiscordChatExporter.Core/Utils/Extensions/CollectionExtensions.cs new file mode 100644 index 0000000..491f7d6 --- /dev/null +++ b/DiscordChatExporter.Core/Utils/Extensions/CollectionExtensions.cs @@ -0,0 +1,18 @@ +using System.Collections.Generic; + +namespace DiscordChatExporter.Core.Utils.Extensions; + +public static class CollectionExtensions +{ + public static IEnumerable Enumerate(this T obj) + { + yield return obj; + } + + public static IEnumerable<(T value, int index)> WithIndex(this IEnumerable source) + { + var i = 0; + foreach (var o in source) + yield return (o, i++); + } +} \ No newline at end of file