[HTML] Special case Spotify embeds

Closes #657
pull/678/head
Tyrrrz 3 years ago
parent bb4db21b87
commit f40589dea6

@ -140,7 +140,41 @@ namespace DiscordChatExporter.Cli.Tests
} }
[Fact] [Fact]
public async Task Message_with_YouTube_video_is_rendered_using_an_iframe_player_in_HTML() public async Task Message_with_a_Spotify_track_is_rendered_using_an_iframe_in_HTML()
{
// Arrange
var outputFilePath = _tempOutput.GetTempFilePath("html");
// Act
var htmlData = await GlobalCache.WrapAsync("embed-specs-output-html", async () =>
{
await new ExportChannelsCommand
{
TokenValue = Secrets.DiscordToken,
IsBotToken = Secrets.IsDiscordTokenBot,
ChannelIds = new[] {Snowflake.Parse(ChannelIds.EmbedTestCases)},
ExportFormat = ExportFormat.HtmlDark,
OutputPath = outputFilePath
}.ExecuteAsync(new FakeConsole());
return await File.ReadAllTextAsync(outputFilePath);
});
_testOutput.WriteLine(htmlData);
var html = Html.Parse(htmlData);
var messageHtml = html.QuerySelector("#message-867886632203976775");
var iframeHtml = messageHtml?.QuerySelector("iframe");
// Assert
iframeHtml.Should().NotBeNull();
iframeHtml?.GetAttribute("src").Should()
.StartWithEquivalent("https://open.spotify.com/embed/track/1LHZMWefF9502NPfArRfvP");
}
[Fact]
public async Task Message_with_a_YouTube_video_is_rendered_using_an_iframe_in_HTML()
{ {
// Arrange // Arrange
var outputFilePath = _tempOutput.GetTempFilePath("html"); var outputFilePath = _tempOutput.GetTempFilePath("html");

@ -7,7 +7,7 @@ using System.Text.Json;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data.Embeds
{ {
// https://discord.com/developers/docs/resources/channel#embed-object // https://discord.com/developers/docs/resources/channel#embed-object
public partial class Embed public partial class Embed
@ -56,6 +56,8 @@ namespace DiscordChatExporter.Core.Discord.Data
Footer = footer; Footer = footer;
} }
public SpotifyTrackEmbedProjection? TryGetSpotifyTrack() => SpotifyTrackEmbedProjection.TryResolve(this);
public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() => YouTubeVideoEmbedProjection.TryResolve(this); public YouTubeVideoEmbedProjection? TryGetYouTubeVideo() => YouTubeVideoEmbedProjection.TryResolve(this);
[ExcludeFromCodeCoverage] [ExcludeFromCodeCoverage]

@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Text.Json; using System.Text.Json;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data.Embeds
{ {
// https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-author-structure
public partial class EmbedAuthor public partial class EmbedAuthor

@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Text.Json; using System.Text.Json;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data.Embeds
{ {
// https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-field-structure
public partial class EmbedField public partial class EmbedField

@ -2,7 +2,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Text.Json; using System.Text.Json;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data.Embeds
{ {
// https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-footer-structure
public partial class EmbedFooter public partial class EmbedFooter

@ -1,7 +1,7 @@
using System.Text.Json; using System.Text.Json;
using JsonExtensions.Reading; using JsonExtensions.Reading;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data.Embeds
{ {
// https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure // https://discord.com/developers/docs/resources/channel#embed-object-embed-image-structure
public partial class EmbedImage public partial class EmbedImage

@ -0,0 +1,42 @@
using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Discord.Data.Embeds
{
public partial class SpotifyTrackEmbedProjection
{
public string TrackId { get; }
public string Url => $"https://open.spotify.com/embed/track/{TrackId}";
public SpotifyTrackEmbedProjection(string trackId) => TrackId = trackId;
[ExcludeFromCodeCoverage]
public override string ToString() => Url;
}
public partial class SpotifyTrackEmbedProjection
{
private static string? TryParseTrackId(string embedUrl)
{
// https://open.spotify.com/track/1LHZMWefF9502NPfArRfvP?si=3efac6ce9be04f0a
var trackId = Regex.Match(embedUrl, @"spotify\.com/track/(.*?)(?:\?|&|/|$)").Groups[1].Value;
if (!string.IsNullOrWhiteSpace(trackId))
return trackId;
return null;
}
public static SpotifyTrackEmbedProjection? TryResolve(Embed embed)
{
if (string.IsNullOrWhiteSpace(embed.Url))
return null;
var trackId = TryParseTrackId(embed.Url);
if (string.IsNullOrWhiteSpace(trackId))
return null;
return new SpotifyTrackEmbedProjection(trackId);
}
}
}

@ -1,7 +1,7 @@
using System.Diagnostics.CodeAnalysis; using System.Diagnostics.CodeAnalysis;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
namespace DiscordChatExporter.Core.Discord.Data namespace DiscordChatExporter.Core.Discord.Data.Embeds
{ {
public partial class YouTubeVideoEmbedProjection public partial class YouTubeVideoEmbedProjection
{ {

@ -4,6 +4,7 @@ using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Text.Json; using System.Text.Json;
using DiscordChatExporter.Core.Discord.Data.Common; using DiscordChatExporter.Core.Discord.Data.Common;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Reading; using JsonExtensions.Reading;

@ -176,157 +176,230 @@
@{/* Embeds */} @{/* Embeds */}
@foreach (var embed in message.Embeds) @foreach (var embed in message.Embeds)
{ {
var youTubeVideo = embed.TryGetYouTubeVideo(); // Spotify embed
if (embed.TryGetSpotifyTrack() is { } spotifyTrackEmbed)
{
<div class="chatlog__embed">
<div class="chatlog__embed-spotify-container">
<iframe class="chatlog__embed-spotify" src="@spotifyTrackEmbed.Url" width="400" height="80" allowtransparency="true" allow="encrypted-media"></iframe>
</div>
</div>
}
// YouTube embed
else if (embed.TryGetYouTubeVideo() is { } youTubeVideoEmbed)
{
<div class="chatlog__embed">
@{/* Color pill */}
@if (embed.Color is not null)
{
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
<div class="chatlog__embed"> <div class="chatlog__embed-content-container">
@{/* Color pill */} <div class="chatlog__embed-content">
@if (embed.Color is not null) <div class="chatlog__embed-text">
{ @{/* Embed author */}
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div> @if (embed.Author is not null)
} {
else <div class="chatlog__embed-author">
{ @if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div> {
} <img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
<span class="chatlog__embed-author-name">
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
}
else
{
@embed.Author.Name
}
</span>
}
</div>
}
<div class="chatlog__embed-content-container"> @{/* Embed title */}
<div class="chatlog__embed-content"> @if (!string.IsNullOrWhiteSpace(embed.Title))
<div class="chatlog__embed-text"> {
@{/* Embed author */} <div class="chatlog__embed-title">
@if (embed.Author is not null) @if (!string.IsNullOrWhiteSpace(embed.Url))
{ {
<div class="chatlog__embed-author"> <a class="chatlog__embed-title-link" href="@embed.Url">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl)) <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
{ </a>
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'"> }
} else
{
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
}
</div>
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name)) @{/* Video player */}
{ <div class="chatlog__embed-youtube-container">
<span class="chatlog__embed-author-name"> <iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
}
else
{
@embed.Author.Name
}
</span>
}
</div> </div>
} </div>
</div>
</div>
</div>
}
// Generic embed
else
{
<div class="chatlog__embed">
@{/* Color pill */}
@if (embed.Color is not null)
{
<div class="chatlog__embed-color-pill" style="background-color: rgba(@embed.Color?.R,@embed.Color?.G,@embed.Color?.B,@embed.Color?.A)"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
@{/* Embed title */} <div class="chatlog__embed-content-container">
@if (!string.IsNullOrWhiteSpace(embed.Title)) <div class="chatlog__embed-content">
{ <div class="chatlog__embed-text">
<div class="chatlog__embed-title"> @{/* Embed author */}
@if (!string.IsNullOrWhiteSpace(embed.Url)) @if (embed.Author is not null)
{ {
<a class="chatlog__embed-title-link" href="@embed.Url"> <div class="chatlog__embed-author">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{
<img class="chatlog__embed-author-icon" src="@await ResolveUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
<span class="chatlog__embed-author-name">
@if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-name-link" href="@embed.Author.Url">@embed.Author.Name</a>
}
else
{
@embed.Author.Name
}
</span>
}
</div>
}
@{/* Embed title */}
@if (!string.IsNullOrWhiteSpace(embed.Title))
{
<div class="chatlog__embed-title">
@if (!string.IsNullOrWhiteSpace(embed.Url))
{
<a class="chatlog__embed-title-link" href="@embed.Url">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a>
}
else
{
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div> <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
</a> }
} </div>
else }
{
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Title))</div>
}
</div>
}
@{/* Embed description (with special casing for YouTube videos) */} @{/* Embed description */}
@if (youTubeVideo is not null) @if (!string.IsNullOrWhiteSpace(embed.Description))
{ {
<div class="chatlog__embed-youtube-container"> <div class="chatlog__embed-description">
<iframe class="chatlog__embed-youtube" src="@youTubeVideo.Url" width="400" height="225"></iframe> <div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
</div> </div>
} }
else if (!string.IsNullOrWhiteSpace(embed.Description))
{
<div class="chatlog__embed-description">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(embed.Description))</div>
</div>
}
@{/* Embed fields */} @{/* Embed fields */}
@if (embed.Fields.Any()) @if (embed.Fields.Any())
{
<div class="chatlog__embed-fields">
@foreach (var field in embed.Fields)
{
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)">
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
</div>
}
</div>
}
</div>
}
</div>
@{/* Embed content */}
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
{ {
<div class="chatlog__embed-fields"> <div class="chatlog__embed-thumbnail-container">
@foreach (var field in embed.Fields) <a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
{ <img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
<div class="chatlog__embed-field @(field.IsInline ? "chatlog__embed-field--inline" : null)"> </a>
@if (!string.IsNullOrWhiteSpace(field.Name))
{
<div class="chatlog__embed-field-name">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Name))</div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="markdown preserve-whitespace">@Raw(FormatEmbedMarkdown(field.Value))</div>
</div>
}
</div>
}
</div> </div>
} }
</div> </div>
@{/* Embed content (not shown for YouTube videos) */} @{/* Embed image */}
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url) && youTubeVideo is null) @if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.Image.Url))
{ {
<div class="chatlog__embed-thumbnail-container"> <div class="chatlog__embed-image-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)"> <a class="chatlog__embed-image-link" href="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy"> <img class="chatlog__embed-image" src="@await ResolveUrlAsync(embed.Image.ProxyUrl ?? embed.Image.Url)" alt="Image" loading="lazy">
</a> </a>
</div> </div>
} }
</div>
@{/* Embed image */}
@if (embed.Image is not null && !string.IsNullOrWhiteSpace(embed.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>
</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)
{ {
<div class="chatlog__embed-footer"> <div class="chatlog__embed-footer">
@{/* Footer icon */} @{/* Footer icon */}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl)) @if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{
<img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
}
<span class="chatlog__embed-footer-text">
@{/* Footer text */}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
{ {
@embed.Footer.Text <img class="chatlog__embed-footer-icon" src="@await ResolveUrlAsync(embed.Footer.IconProxyUrl ?? embed.Footer.IconUrl)" alt="Footer icon" loading="lazy">
} }
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null) <span class="chatlog__embed-footer-text">
{ @{/* Footer text */}
@(" • ") @if (!string.IsNullOrWhiteSpace(embed.Footer?.Text))
} {
@embed.Footer.Text
}
@{/* Embed timestamp */} @if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
@if (embed.Timestamp is not null) {
{ @(" • ")
@FormatDate(embed.Timestamp.Value) }
}
</span> @{/* Embed timestamp */}
</div> @if (embed.Timestamp is not null)
} {
@FormatDate(embed.Timestamp.Value)
}
</span>
</div>
}
</div>
</div> </div>
</div> }
} }
@{/* Message reactions */} @{/* Message reactions */}

@ -558,6 +558,10 @@
font-weight: 500; font-weight: 500;
} }
.chatlog__embed-spotify {
border: 0;
}
.chatlog__embed-youtube-container { .chatlog__embed-youtube-container {
margin-top: 0.6em; margin-top: 0.6em;
} }

@ -3,6 +3,7 @@ using System.Text.Encodings.Web;
using System.Text.Json; using System.Text.Json;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors; using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
using DiscordChatExporter.Core.Utils.Extensions; using DiscordChatExporter.Core.Utils.Extensions;
using JsonExtensions.Writing; using JsonExtensions.Writing;

@ -3,6 +3,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using DiscordChatExporter.Core.Discord.Data; using DiscordChatExporter.Core.Discord.Data;
using DiscordChatExporter.Core.Discord.Data.Embeds;
using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors; using DiscordChatExporter.Core.Exporting.Writers.MarkdownVisitors;
using Tyrrrz.Extensions; using Tyrrrz.Extensions;

Loading…
Cancel
Save