Render consecutive twitter embeds as on

Closes #695
pull/882/head
Oleksii Holub 2 years ago
parent 057902f919
commit 23e1850d15

@ -106,7 +106,7 @@ public class EmbedSpecs : IClassFixture<ExportWrapperFixture>
iframeUrl.Should().Be("https://www.youtube.com/embed/qOWW4OlgbvE"); 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() 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 // https://github.com/Tyrrrz/DiscordChatExporter/issues/695

@ -18,7 +18,7 @@ public partial record Embed(
string? Description, string? Description,
IReadOnlyList<EmbedField> Fields, IReadOnlyList<EmbedField> Fields,
EmbedImage? Thumbnail, EmbedImage? Thumbnail,
EmbedImage? Image, IReadOnlyList<EmbedImage> Images,
EmbedFooter? Footer) EmbedFooter? Footer)
{ {
public PlainImageEmbedProjection? TryGetPlainImage() => public PlainImageEmbedProjection? TryGetPlainImage() =>
@ -47,7 +47,18 @@ public partial record Embed
Array.Empty<EmbedField>(); Array.Empty<EmbedField>();
var thumbnail = json.GetPropertyOrNull("thumbnail")?.Pipe(EmbedImage.Parse); 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<EmbedImage>();
var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse); var footer = json.GetPropertyOrNull("footer")?.Pipe(EmbedFooter.Parse);
return new Embed( return new Embed(
@ -59,7 +70,7 @@ public partial record Embed
description, description,
fields, fields,
thumbnail, thumbnail,
image, images,
footer footer
); );
} }

@ -27,6 +27,62 @@ public record Message(
MessageReference? Reference, MessageReference? Reference,
Message? ReferencedMessage) : IHasId Message? ReferencedMessage) : IHasId
{ {
private static IReadOnlyList<Embed> NormalizeEmbeds(IReadOnlyList<Embed> 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<Embed>();
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) public static Message Parse(JsonElement json)
{ {
var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse); var id = json.GetProperty("id").GetNonWhiteSpaceString().Pipe(Snowflake.Parse);
@ -59,9 +115,10 @@ public record Message(
json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ?? json.GetPropertyOrNull("attachments")?.EnumerateArrayOrNull()?.Select(Attachment.Parse).ToArray() ??
Array.Empty<Attachment>(); Array.Empty<Attachment>();
var embeds = var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ?? json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
Array.Empty<Embed>(); Array.Empty<Embed>()
);
var stickers = var stickers =
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ?? json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??

@ -44,10 +44,9 @@
} }
<div class="chatlog__message-group"> <div class="chatlog__message-group">
@for (var i = 0; i < Model.Messages.Count; i++) @foreach (var (message, i) in Model.Messages.WithIndex())
{ {
var isFirst = i == 0; 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 // Hide message content if it only contains a link to an image which is embedded, and nothing else
var isContentHidden = var isContentHidden =
@ -161,7 +160,7 @@
@{/* Attachments */} @{/* Attachments */}
@foreach (var attachment in message.Attachments) @foreach (var attachment in message.Attachments)
{ {
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : "")" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : "")"> <div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
@{/* Spoiler caption */} @{/* Spoiler caption */}
@if (attachment.IsSpoiler) @if (attachment.IsSpoiler)
{ {
@ -399,15 +398,23 @@
} }
</div> </div>
@{/* Embed image */} @{/* Embed images */}
@if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url)) @if (embed.Images.Any())
{
<div class="chatlog__embed-images @(embed.Images.Count == 1 ? "chatlog__embed-images--single" : null)">
@foreach (var image in embed.Images)
{
if (!string.IsNullOrWhiteSpace(image.Url))
{ {
<div class="chatlog__embed-image-container"> <div class="chatlog__embed-image-container">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)"> <a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image" loading="lazy"> <img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
</a> </a>
</div> </div>
} }
}
</div>
}
@{/* Embed footer & icon */} @{/* Embed footer & icon */}
@if (embed.Footer is not null || embed.Timestamp is not null) @if (embed.Footer is not null || embed.Timestamp is not null)

@ -500,13 +500,27 @@
border-radius: 3px; 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 { .chatlog__embed-image-container {
margin-top: 0.6rem; margin-top: 0.6rem;
} }
.chatlog__embed-image { .chatlog__embed-image {
object-fit: cover;
object-position: center;
max-width: 500px; max-width: 500px;
max-height: 400px; max-height: 400px;
width: 100%;
height: 100%;
border-radius: 3px; border-radius: 3px;
} }

@ -20,6 +20,7 @@ internal class JsonMessageWriter : MessageWriter
{ {
_writer = new Utf8JsonWriter(stream, new JsonWriterOptions _writer = new Utf8JsonWriter(stream, new JsonWriterOptions
{ {
// https://github.com/Tyrrrz/DiscordChatExporter/issues/450
Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
Indented = true, Indented = true,
// Validation errors may mask actual failures // Validation errors may mask actual failures
@ -50,7 +51,7 @@ internal class JsonMessageWriter : MessageWriter
EmbedAuthor embedAuthor, EmbedAuthor embedAuthor,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_writer.WriteStartObject("author"); _writer.WriteStartObject();
_writer.WriteString("name", embedAuthor.Name); _writer.WriteString("name", embedAuthor.Name);
_writer.WriteString("url", embedAuthor.Url); _writer.WriteString("url", embedAuthor.Url);
@ -62,27 +63,11 @@ internal class JsonMessageWriter : MessageWriter
await _writer.FlushAsync(cancellationToken); 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( private async ValueTask WriteEmbedImageAsync(
EmbedImage embedImage, EmbedImage embedImage,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_writer.WriteStartObject("image"); _writer.WriteStartObject();
if (!string.IsNullOrWhiteSpace(embedImage.Url)) if (!string.IsNullOrWhiteSpace(embedImage.Url))
_writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken)); _writer.WriteString("url", await Context.ResolveMediaUrlAsync(embedImage.ProxyUrl ?? embedImage.Url, cancellationToken));
@ -98,7 +83,7 @@ internal class JsonMessageWriter : MessageWriter
EmbedFooter embedFooter, EmbedFooter embedFooter,
CancellationToken cancellationToken = default) CancellationToken cancellationToken = default)
{ {
_writer.WriteStartObject("footer"); _writer.WriteStartObject();
_writer.WriteString("text", embedFooter.Text); _writer.WriteString("text", embedFooter.Text);
@ -138,16 +123,37 @@ internal class JsonMessageWriter : MessageWriter
_writer.WriteString("color", embed.Color.Value.ToHex()); _writer.WriteString("color", embed.Color.Value.ToHex());
if (embed.Author is not null) if (embed.Author is not null)
{
_writer.WritePropertyName("author");
await WriteEmbedAuthorAsync(embed.Author, cancellationToken); await WriteEmbedAuthorAsync(embed.Author, cancellationToken);
}
if (embed.Thumbnail is not null) 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) // Legacy: backwards-compatibility for old embeds with a single image
await WriteEmbedImageAsync(embed.Image, cancellationToken); if (embed.Images.Count > 0)
{
_writer.WritePropertyName("image");
await WriteEmbedImageAsync(embed.Images[0], cancellationToken);
}
if (embed.Footer is not null) if (embed.Footer is not null)
{
_writer.WritePropertyName("footer");
await WriteEmbedFooterAsync(embed.Footer, cancellationToken); await WriteEmbedFooterAsync(embed.Footer, cancellationToken);
}
// Images
_writer.WriteStartArray("images");
foreach (var image in embed.Images)
await WriteEmbedImageAsync(image, cancellationToken);
_writer.WriteEndArray();
// Fields // Fields
_writer.WriteStartArray("fields"); _writer.WriteStartArray("fields");

@ -88,8 +88,11 @@ internal class PlainTextMessageWriter : MessageWriter
if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url)) if (!string.IsNullOrWhiteSpace(embed.Thumbnail?.Url))
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken)); await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url, cancellationToken));
if (!string.IsNullOrWhiteSpace(embed.Image?.Url)) foreach (var image in embed.Images)
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url, cancellationToken)); {
if (!string.IsNullOrWhiteSpace(image.Url))
await _writer.WriteLineAsync(await Context.ResolveMediaUrlAsync(image.ProxyUrl ?? image.Url, cancellationToken));
}
if (!string.IsNullOrWhiteSpace(embed.Footer?.Text)) if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
await _writer.WriteLineAsync(embed.Footer.Text); await _writer.WriteLineAsync(embed.Footer.Text);

@ -0,0 +1,18 @@
using System.Collections.Generic;
namespace DiscordChatExporter.Core.Utils.Extensions;
public static class CollectionExtensions
{
public static IEnumerable<T> Enumerate<T>(this T obj)
{
yield return obj;
}
public static IEnumerable<(T value, int index)> WithIndex<T>(this IEnumerable<T> source)
{
var i = 0;
foreach (var o in source)
yield return (o, i++);
}
}
Loading…
Cancel
Save