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");
}
[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

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

@ -27,6 +27,62 @@ public record Message(
MessageReference? Reference,
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)
{
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<Attachment>();
var embeds =
var embeds = NormalizeEmbeds(
json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ??
Array.Empty<Embed>();
Array.Empty<Embed>()
);
var stickers =
json.GetPropertyOrNull("sticker_items")?.EnumerateArrayOrNull()?.Select(Sticker.Parse).ToArray() ??

@ -44,10 +44,9 @@
}
<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 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)
{
<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 */}
@if (attachment.IsSpoiler)
{
@ -399,15 +398,23 @@
}
</div>
@{/* Embed image */}
@if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url))
@{/* Embed images */}
@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">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image" loading="lazy">
<a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveUrlAsync(image.ProxyUrl ?? image.Url)" alt="Image" loading="lazy">
</a>
</div>
}
}
</div>
}
@{/* Embed footer & icon */}
@if (embed.Footer is not null || embed.Timestamp is not null)

@ -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;
}

@ -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");

@ -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);

@ -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