You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
DiscordChatExporter/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml

667 lines
40 KiB

@using System
@using System.Collections.Generic
@using System.Globalization
@using System.Linq
@using System.Threading.Tasks
@using DiscordChatExporter.Core.Discord.Data
@using DiscordChatExporter.Core.Discord.Data.Embeds
@using DiscordChatExporter.Core.Markdown.Parsing
@using DiscordChatExporter.Core.Utils.Extensions
@inherits RazorBlade.HtmlTemplate
@functions {
public required ExportContext Context { get; init; }
public required IReadOnlyList<Message> Messages { get; init; }
}
@{
ValueTask<string> ResolveAssetUrlAsync(string url) =>
Context.ResolveAssetUrlAsync(url, CancellationToken);
string FormatDate(DateTimeOffset instant) =>
Context.FormatDate(instant);
async ValueTask<string> FormatMarkdownAsync(string markdown) =>
Context.Request.ShouldFormatMarkdown
? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, true, CancellationToken)
: markdown;
async ValueTask<string> FormatEmbedMarkdownAsync(string markdown) =>
Context.Request.ShouldFormatMarkdown
? await HtmlMarkdownVisitor.FormatAsync(Context, markdown, false, CancellationToken)
: markdown;
}
<div class="chatlog__message-group">
@foreach (var (message, i) in Messages.WithIndex())
{
var isFirst = i == 0;
var authorMember = Context.TryGetMember(message.Author.Id);
var authorColor = Context.TryGetUserColor(message.Author.Id);
var authorDisplayName = message.Author.IsBot
? message.Author.DisplayName
: authorMember?.DisplayName ?? message.Author.DisplayName;
<div id="chatlog__message-container-@message.Id" class="chatlog__message-container @(message.IsPinned ? "chatlog__message-container--pinned" : null)" data-message-id="@message.Id">
<div class="chatlog__message">
@* System notification *@
@if (message.Kind.IsSystemNotification())
{
<div class="chatlog__message-aside">
<svg class="chatlog__system-notification-icon">
@{
var icon = message.Kind switch {
MessageKind.RecipientAdd => "join-icon",
MessageKind.RecipientRemove => "leave-icon",
MessageKind.Call => "call-icon",
MessageKind.ChannelNameChange => "pencil-icon",
MessageKind.ChannelIconChange => "pencil-icon",
MessageKind.ChannelPinnedMessage => "pin-icon",
MessageKind.GuildMemberJoin => "join-icon",
MessageKind.ThreadCreated => "thread-icon",
_ => "pencil-icon"
};
}
<use href="#@icon"></use>
</svg>
</div>
<div class="chatlog__message-primary">
@* Author name *@
<span class="chatlog__system-notification-author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorDisplayName</span>
@* Space out the content *@
<span> </span>
@* System notification content *@
<span class="chatlog__system-notification-content">
@if (message.Kind == MessageKind.RecipientAdd && message.MentionedUsers.Any())
{
<span>added </span>
<a class="chatlog__system-notification-link" title="@message.MentionedUsers.First().FullName">@message.MentionedUsers.First().DisplayName</a>
<span> to the group.</span>
}
else if (message.Kind == MessageKind.RecipientRemove && message.MentionedUsers.Any())
{
if (message.Author.Id == message.MentionedUsers.First().Id)
{
<span>left the group.</span>
}
else
{
<span>removed </span>
<a class="chatlog__system-notification-link" title="@message.MentionedUsers.First().FullName">@message.MentionedUsers.First().DisplayName</a>
<span> from the group.</span>
}
}
else if (message.Kind == MessageKind.Call)
{
<span>started a call that lasted @(((message.CallEndedTimestamp ?? message.Timestamp) - message.Timestamp).TotalMinutes.ToString("n0", CultureInfo.InvariantCulture)) minutes</span>
}
else if (message.Kind == MessageKind.ChannelNameChange)
{
<span>changed the channel name: </span>
<span class="chatlog__system-notification-link">@message.Content</span>
}
else if (message.Kind == MessageKind.ChannelIconChange)
{
<span>changed the channel icon.</span>
}
else if (message.Kind == MessageKind.ChannelPinnedMessage && message.Reference is not null)
{
<span>pinned </span>
<a class="chatlog__system-notification-link" href="#chatlog__message-container-@message.Reference.MessageId">a message</a>
<span> to this channel.</span>
}
else if (message.Kind == MessageKind.ThreadCreated)
{
<span>started a thread.</span>
}
else if (message.Kind == MessageKind.GuildMemberJoin)
{
<span>joined the server.</span>
}
else
{
<span>@message.Content.ToLowerInvariant()</span>
}
</span>
@* Timestamp *@
<span class="chatlog__system-notification-timestamp">
<a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a>
</span>
</div>
}
// Regular message
else
{
<div class="chatlog__message-aside">
@if (isFirst)
{
// Reply symbol
if (message.IsReplyLike)
{
<div class="chatlog__reply-symbol"></div>
}
// Avatar
<img class="chatlog__avatar" src="@await ResolveAssetUrlAsync(authorMember?.AvatarUrl ?? message.Author.AvatarUrl)" alt="Avatar" loading="lazy">
}
else
{
<div class="chatlog__short-timestamp" title="@FormatDate(message.Timestamp)">@message.Timestamp.ToLocalString("t")</div>
}
</div>
<div class="chatlog__message-primary">
@if (isFirst)
{
// Message referenced by the reply
if (message.IsReplyLike)
{
<div class="chatlog__reply">
@if (message.ReferencedMessage is not null)
{
var referencedUserMember = Context.TryGetMember(message.ReferencedMessage.Author.Id);
var referencedUserColor = Context.TryGetUserColor(message.ReferencedMessage.Author.Id);
var referencedUserDisplayName = message.ReferencedMessage.Author.IsBot
? message.ReferencedMessage.Author.DisplayName
: referencedUserMember?.DisplayName ?? message.ReferencedMessage.Author.DisplayName;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(referencedUserMember?.AvatarUrl ?? message.ReferencedMessage.Author.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reply-author" style="@(referencedUserColor is not null ? $"color: rgb({referencedUserColor.Value.R}, {referencedUserColor.Value.G}, {referencedUserColor.Value.B})" : null)" title="@message.ReferencedMessage.Author.FullName">@referencedUserDisplayName</div>
<div class="chatlog__reply-content">
<span class="chatlog__reply-link" onclick="scrollToMessage(event, '@message.ReferencedMessage.Id')">
@if (!string.IsNullOrWhiteSpace(message.ReferencedMessage.Content) && !message.ReferencedMessage.IsContentHidden())
{
<!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(message.ReferencedMessage.Content))<!--/wmm:ignore-->
}
else if (message.ReferencedMessage.Attachments.Any() || message.ReferencedMessage.Embeds.Any())
{
<em>Click to see attachment</em>
<span>🖼️</span>
}
else
{
<em>Click to see original message</em>
}
</span>
@if (message.ReferencedMessage.EditedTimestamp is not null)
{
<span class="chatlog__reply-edited-timestamp" title="@FormatDate(message.ReferencedMessage.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
else if (message.Interaction is not null)
{
var interactionUserMember = Context.TryGetMember(message.Interaction.User.Id);
var interactionUserColor = Context.TryGetUserColor(message.Interaction.User.Id);
var interactionUserDisplayName = message.Interaction.User.IsBot
? message.Interaction.User.DisplayName
: interactionUserMember?.DisplayName ?? message.Interaction.User.DisplayName;
<img class="chatlog__reply-avatar" src="@await ResolveAssetUrlAsync(interactionUserMember?.AvatarUrl ?? message.Interaction.User.AvatarUrl)" alt="Avatar" loading="lazy">
<div class="chatlog__reply-author" style="@(interactionUserColor is not null ? $"color: rgb({interactionUserColor.Value.R}, {interactionUserColor.Value.G}, {interactionUserColor.Value.B})" : null)" title="@message.Interaction.User.FullName">@interactionUserDisplayName</div>
<div class="chatlog__reply-content">
used /@message.Interaction.Name
</div>
}
else
{
<div class="chatlog__reply-unknown">
Original message was deleted or could not be loaded.
</div>
}
</div>
}
// Header
<div class="chatlog__header">
@* Author name *@
<span class="chatlog__author" style="@(authorColor is not null ? $"color: rgb({authorColor.Value.R}, {authorColor.Value.G}, {authorColor.Value.B})" : null)" title="@message.Author.FullName" data-user-id="@message.Author.Id">@authorDisplayName</span>
@* Bot tag *@
@if (message.Author.IsBot)
{
// For cross-posts, the BOT tag is replaced with the SERVER tag
if (message.Flags.HasFlag(MessageFlags.CrossPost))
{
<span class="chatlog__author-tag">SERVER</span>
}
else
{
<span class="chatlog__author-tag">BOT</span>
}
}
@* Timestamp *@
<span class="chatlog__timestamp"><a href="#chatlog__message-container-@message.Id">@FormatDate(message.Timestamp)</a></span>
</div>
}
@* Content *@
@if ((!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden()) || message.EditedTimestamp is not null)
{
<div class="chatlog__content chatlog__markdown">
@* Text *@
@if (!string.IsNullOrWhiteSpace(message.Content) && !message.IsContentHidden())
{
<span class="chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatMarkdownAsync(message.Content))<!--/wmm:ignore--></span>
}
@* Edited timestamp *@
@if (message.EditedTimestamp is not null)
{
<span class="chatlog__edited-timestamp" title="@FormatDate(message.EditedTimestamp.Value)">(edited)</span>
}
</div>
}
@* Attachments *@
@foreach (var attachment in message.Attachments)
{
<div class="chatlog__attachment @(attachment.IsSpoiler ? "chatlog__attachment--hidden" : null)" onclick="@(attachment.IsSpoiler ? "showSpoiler(event, this)" : null)">
@* Spoiler caption *@
@if (attachment.IsSpoiler)
{
<div class="chatlog__attachment-spoiler-caption">SPOILER</div>
}
@* Attachment preview *@
@if (attachment.IsImage)
{
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
<img class="chatlog__attachment-media" src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Image attachment")" title="Image: @attachment.FileName (@attachment.FileSize)" loading="lazy">
</a>
}
else if (attachment.IsVideo)
{
<video class="chatlog__attachment-media" controls>
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Video attachment")" title="Video: @attachment.FileName (@attachment.FileSize)">
</video>
}
else if (attachment.IsAudio)
{
<audio class="chatlog__attachment-media" controls>
<source src="@await ResolveAssetUrlAsync(attachment.Url)" alt="@(attachment.Description ?? "Audio attachment")" title="Audio: @attachment.FileName (@attachment.FileSize)">
</audio>
}
else
{
<div class="chatlog__attachment-generic">
<svg class="chatlog__attachment-generic-icon">
<use href="#attachment-icon"/>
</svg>
<div class="chatlog__attachment-generic-name">
<a href="@await ResolveAssetUrlAsync(attachment.Url)">
@attachment.FileName
</a>
</div>
<div class="chatlog__attachment-generic-size">
@attachment.FileSize
</div>
</div>
}
</div>
}
@* Invites *@
@{
var inviteCodes = MarkdownParser
.ExtractLinks(message.Content)
.Select(l => l.Url)
.Select(Invite.TryGetCodeFromUrl)
.WhereNotNull()
.ToArray();
foreach (var inviteCode in inviteCodes)
{
var invite = await Context.Discord.TryGetInviteAsync(inviteCode, CancellationToken);
if (invite is null)
{
continue;
}
<div class="chatlog__embed">
<div class="chatlog__embed-invite-container">
<div class="chatlog__embed-invite-title">@(invite.Channel?.Kind.IsDirect() == true ? "Invite to join a group DM" : "Invite to join a server")</div>
<div class="chatlog__embed-invite">
<div class="chatlog__embed-invite-guild-icon-container">
<img class="chatlog__embed-invite-guild-icon" src="@await ResolveAssetUrlAsync(invite.Channel?.IconUrl ?? invite.Guild.IconUrl)" alt="Guild icon" loading="lazy">
</div>
<div class="chatlog__embed-invite-info">
<div class="chatlog__embed-invite-guild-name">
<a href="https://discord.gg/@invite.Code">
@(invite.Guild.Name)
</a>
</div>
<div class="chatlog__embed-invite-channel-name">
<svg class="chatlog__embed-invite-channel-icon">
<use href="#channel-icon"></use>
</svg>
<span> @(invite.Channel?.Name ?? "Unknown Channel")</span>
</div>
</div>
</div>
</div>
</div>
}
}
@* Embeds *@
@foreach (var embed in message.Embeds)
{
// 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.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
@* Embed author *@
@if (embed.Author is not null)
{
<div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
<div class="chatlog__embed-author">@embed.Author.Name</div>
</a>
}
else
{
<div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</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="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
</a>
}
else
{
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
}
</div>
}
@* Video player *@
<div class="chatlog__embed-youtube-container">
<iframe class="chatlog__embed-youtube" src="@youTubeVideoEmbed.Url" width="400" height="225"></iframe>
</div>
</div>
</div>
</div>
</div>
}
// Generic image embed
else if (embed.Kind == EmbedKind.Image && !string.IsNullOrWhiteSpace(embed.Url))
{
var embedImageUrl =
embed.Image?.ProxyUrl ?? embed.Image?.Url ??
embed.Thumbnail?.ProxyUrl ?? embed.Thumbnail?.Url ??
embed.Url;
<div class="chatlog__embed">
<a href="@await ResolveAssetUrlAsync(embedImageUrl)">
<img class="chatlog__embed-generic-image" src="@await ResolveAssetUrlAsync(embedImageUrl)" alt="Embedded image" loading="lazy">
</a>
</div>
}
// Generic video embed
else if (embed.Kind == EmbedKind.Video && !string.IsNullOrWhiteSpace(embed.Url))
{
var embedVideoUrl =
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
embed.Url;
<div class="chatlog__embed">
<video class="chatlog__embed-generic-video" width="@embed.Video?.Width" height="@embed.Video?.Height" controls>
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded video">
</video>
</div>
}
// Generic gifv embed
else if (embed.Kind == EmbedKind.Gifv && !string.IsNullOrWhiteSpace(embed.Url))
{
var embedVideoUrl =
embed.Video?.ProxyUrl ?? embed.Video?.Url ??
embed.Url;
<div class="chatlog__embed">
<video class="chatlog__embed-generic-gifv" width="@embed.Video?.Width" height="@embed.Video?.Height" loop onmouseover="this.play()" onmouseout="this.pause()">
<source src="@await ResolveAssetUrlAsync(embedVideoUrl)" alt="Embedded gifv">
</video>
</div>
}
// Rich 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.Value.R, @embed.Color.Value.G, @embed.Color.Value.B, @embed.Color.Value.A)"></div>
}
else
{
<div class="chatlog__embed-color-pill chatlog__embed-color-pill--default"></div>
}
<div class="chatlog__embed-content-container">
<div class="chatlog__embed-content">
<div class="chatlog__embed-text">
@* Embed author *@
@if (embed.Author is not null)
{
<div class="chatlog__embed-author-container">
@if (!string.IsNullOrWhiteSpace(embed.Author.IconUrl))
{
<img class="chatlog__embed-author-icon" src="@await ResolveAssetUrlAsync(embed.Author.IconProxyUrl ?? embed.Author.IconUrl)" alt="Author icon" loading="lazy" onerror="this.style.visibility='hidden'">
}
@if (!string.IsNullOrWhiteSpace(embed.Author.Name))
{
if (!string.IsNullOrWhiteSpace(embed.Author.Url))
{
<a class="chatlog__embed-author-link" href="@embed.Author.Url">
<div class="chatlog__embed-author">@embed.Author.Name</div>
</a>
}
else
{
<div class="chatlog__embed-author">@embed.Author.Name</div>
}
}
</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="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
</a>
}
else
{
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Title))<!--/wmm:ignore--></div>
}
</div>
}
@* Embed description *@
@if (!string.IsNullOrWhiteSpace(embed.Description))
{
<div class="chatlog__embed-description">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(embed.Description))<!--/wmm:ignore--></div>
</div>
}
@* Embed fields *@
@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="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Name))<!--/wmm:ignore--></div>
</div>
}
@if (!string.IsNullOrWhiteSpace(field.Value))
{
<div class="chatlog__embed-field-value">
<div class="chatlog__markdown chatlog__markdown-preserve"><!--wmm:ignore-->@Html.Raw(await FormatEmbedMarkdownAsync(field.Value))<!--/wmm:ignore--></div>
</div>
}
</div>
}
</div>
}
</div>
@* Embed content *@
@if (embed.Thumbnail is not null && !string.IsNullOrWhiteSpace(embed.Thumbnail.Url))
{
<div class="chatlog__embed-thumbnail-container">
<a class="chatlog__embed-thumbnail-link" href="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)">
<img class="chatlog__embed-thumbnail" src="@await ResolveAssetUrlAsync(embed.Thumbnail.ProxyUrl ?? embed.Thumbnail.Url)" alt="Thumbnail" loading="lazy">
</a>
</div>
}
</div>
@* 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 ResolveAssetUrlAsync(image.ProxyUrl ?? image.Url)">
<img class="chatlog__embed-image" src="@await ResolveAssetUrlAsync(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)
{
<div class="chatlog__embed-footer">
@* Footer icon *@
@if (!string.IsNullOrWhiteSpace(embed.Footer?.IconUrl))
{
<img class="chatlog__embed-footer-icon" src="@await ResolveAssetUrlAsync(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
}
@if (!string.IsNullOrWhiteSpace(embed.Footer?.Text) && embed.Timestamp is not null)
{
@(" • ")
}
@* Embed timestamp *@
@if (embed.Timestamp is not null)
{
@FormatDate(embed.Timestamp.Value)
}
</span>
</div>
}
</div>
</div>
}
}
@* Stickers *@
@foreach (var sticker in message.Stickers)
{
<div class="chatlog__sticker" title="@sticker.Name">
@if (sticker.Format is StickerFormat.Png or StickerFormat.Apng)
{
<img class="chatlog__sticker--media" src="@await ResolveAssetUrlAsync(sticker.SourceUrl)" alt="Sticker">
}
else if (sticker.Format == StickerFormat.Lottie)
{
<div class="chatlog__sticker--media" data-source="@await ResolveAssetUrlAsync(sticker.SourceUrl)"></div>
}
</div>
}
@* Message reactions *@
@if (message.Reactions.Any())
{
<div class="chatlog__reactions">
@foreach (var reaction in message.Reactions)
{
<div class="chatlog__reaction" title="@reaction.Emoji.Code">
<img class="chatlog__emoji chatlog__emoji--small" alt="@reaction.Emoji.Name" src="@await ResolveAssetUrlAsync(reaction.Emoji.ImageUrl)" loading="lazy">
<span class="chatlog__reaction-count">@reaction.Count</span>
</div>
}
</div>
}
</div>
}
</div>
</div>
}
</div>