diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ButtonComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/ButtonComponent.cs new file mode 100644 index 00000000..b719f210 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/ButtonComponent.cs @@ -0,0 +1,48 @@ +using System; +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data.Common; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#button +public partial record ButtonComponent( + ButtonStyle Style, + string? Label, + Emoji? Emoji, + string? Url, + string? CustomId, + Snowflake? SkuId, + bool IsDisabled +) +{ + public bool IsUrlButton => !string.IsNullOrWhiteSpace(Url); +} + +public partial record ButtonComponent +{ + public static ButtonComponent Parse(JsonElement json) + { + var style = + json.GetPropertyOrNull("style") + ?.GetInt32OrNull() + ?.Pipe(s => + Enum.IsDefined(typeof(ButtonStyle), s) ? (ButtonStyle)s : (ButtonStyle?)null + ) + ?? ButtonStyle.Secondary; + + var label = json.GetPropertyOrNull("label")?.GetStringOrNull(); + var emoji = json.GetPropertyOrNull("emoji")?.Pipe(Emoji.Parse); + + var url = json.GetPropertyOrNull("url")?.GetNonWhiteSpaceStringOrNull(); + var customId = json.GetPropertyOrNull("custom_id")?.GetNonWhiteSpaceStringOrNull(); + var skuId = json.GetPropertyOrNull("sku_id") + ?.GetNonWhiteSpaceStringOrNull() + ?.Pipe(Snowflake.Parse); + + var isDisabled = json.GetPropertyOrNull("disabled")?.GetBooleanOrNull() ?? false; + + return new ButtonComponent(style, label, emoji, url, customId, skuId, isDisabled); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs b/DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs new file mode 100644 index 00000000..a00de9e8 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs @@ -0,0 +1,12 @@ +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#button-button-styles +public enum ButtonStyle +{ + Primary = 1, + Secondary = 2, + Success = 3, + Danger = 4, + Link = 5, + Premium = 6, +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs new file mode 100644 index 00000000..3881bd2b --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using DiscordChatExporter.Core.Utils.Extensions; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://docs.discord.com/developers/components/reference#component-object +public partial record MessageComponent( + MessageComponentType Kind, + IReadOnlyList Components, + ButtonComponent? Button +) +{ + public bool HasButtons => Button is not null || Components.Any(c => c.HasButtons); + + public IReadOnlyList Buttons => + Components.Select(c => c.Button).WhereNotNull().ToArray(); +} + +public partial record MessageComponent +{ + public static MessageComponent? Parse(JsonElement json) + { + var rawType = json.GetPropertyOrNull("type")?.GetInt32OrNull(); + if (rawType is null) + return null; + + var type = rawType.Value; + if (!Enum.IsDefined(typeof(MessageComponentType), type)) + return null; + + return Parse((MessageComponentType)type, json); + } + + private static MessageComponent Parse(MessageComponentType type, JsonElement json) + { + return type switch + { + MessageComponentType.Button => ParseButton(json), + _ => ParseDefault(type, json), + }; + } + + private static MessageComponent ParseDefault(MessageComponentType type, JsonElement json) + { + var components = ParseComponents(json); + + return new MessageComponent(type, components, null); + } + + private static MessageComponent ParseButton(JsonElement json) + { + var components = ParseComponents(json); + var button = ButtonComponent.Parse(json); + + return new MessageComponent(MessageComponentType.Button, components, button); + } + + private static MessageComponent[] ParseComponents(JsonElement json) + { + return json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Select(Parse) + .WhereNotNull() + .ToArray() + ?? []; + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentType.cs b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentType.cs new file mode 100644 index 00000000..527a97f5 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentType.cs @@ -0,0 +1,22 @@ +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#component-object-component-types +public enum MessageComponentType +{ + ActionRow = 1, + Button = 2, + StringSelect = 3, + TextInput = 4, + UserSelect = 5, + RoleSelect = 6, + MentionableSelect = 7, + ChannelSelect = 8, + Section = 9, + TextDisplay = 10, + Thumbnail = 11, + MediaGallery = 12, + File = 13, + Separator = 14, + Container = 17, + Label = 18, +} diff --git a/DiscordChatExporter.Core/Discord/Data/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 29465ab5..29a2b9e2 100644 --- a/DiscordChatExporter.Core/Discord/Data/Message.cs +++ b/DiscordChatExporter.Core/Discord/Data/Message.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; using System.Text.Json; using DiscordChatExporter.Core.Discord.Data.Common; +using DiscordChatExporter.Core.Discord.Data.Components; using DiscordChatExporter.Core.Discord.Data.Embeds; using JsonExtensions.Reading; using PowerKit.Extensions; @@ -23,6 +24,7 @@ public partial record Message( IReadOnlyList Attachments, IReadOnlyList Embeds, IReadOnlyList Stickers, + IReadOnlyList Components, IReadOnlyList Reactions, IReadOnlyList MentionedUsers, MessageReference? Reference, @@ -34,6 +36,7 @@ public partial record Message( public bool IsEmpty { get; } = string.IsNullOrWhiteSpace(Content) && !Attachments.Any() + && !Components.Any() && !Embeds.Any() && !Stickers.Any(); @@ -161,6 +164,14 @@ public partial record Message .ToArray() ?? []; + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Select(MessageComponent.Parse) + .WhereNotNull() + .ToArray() + ?? []; + var reactions = json.GetPropertyOrNull("reactions") ?.EnumerateArrayOrNull() @@ -200,6 +211,7 @@ public partial record Message attachments, embeds, stickers, + components, reactions, mentionedUsers, messageReference, diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index 4d2e19cd..ec345e9b 100644 --- a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs +++ b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs @@ -7,6 +7,7 @@ using System.Text.Json; using System.Threading; using System.Threading.Tasks; using DiscordChatExporter.Core.Discord.Data; +using DiscordChatExporter.Core.Discord.Data.Components; using DiscordChatExporter.Core.Discord.Data.Embeds; using DiscordChatExporter.Core.Markdown.Parsing; using JsonExtensions.Writing; @@ -368,6 +369,45 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) await Context.ResolveAssetUrlAsync(sticker.SourceUrl, cancellationToken) ); + _writer.WriteEndObject(); + await _writer.FlushAsync(cancellationToken); + } + + private async ValueTask WriteComponentAsync( + MessageComponent component, + CancellationToken cancellationToken = default + ) + { + _writer.WriteStartObject(); + + _writer.WriteString("type", component.Kind.ToString()); + + if (component.Button is not null) + { + _writer.WriteString("style", component.Button.Style.ToString()); + _writer.WriteString("label", component.Button.Label); + _writer.WriteString("url", component.Button.Url); + _writer.WriteString("customId", component.Button.CustomId); + _writer.WriteString("skuId", component.Button.SkuId?.ToString()); + _writer.WriteBoolean("isDisabled", component.Button.IsDisabled); + + if (component.Button.Emoji is not null) + { + _writer.WritePropertyName("emoji"); + await WriteEmojiAsync(component.Button.Emoji, cancellationToken); + } + } + + if (component.Components.Any()) + { + _writer.WriteStartArray("components"); + + foreach (var child in component.Components) + await WriteComponentAsync(child, cancellationToken); + + _writer.WriteEndArray(); + } + _writer.WriteEndObject(); } @@ -477,6 +517,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteEndArray(); + // Components + _writer.WriteStartArray("components"); + + foreach (var component in message.Components) + await WriteComponentAsync(component, cancellationToken); + + _writer.WriteEndArray(); + // Embeds _writer.WriteStartArray("embeds"); diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index b3c06837..70927671 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -4,6 +4,7 @@ @using System.Threading.Tasks @using RazorBlade @using DiscordChatExporter.Core.Discord.Data +@using DiscordChatExporter.Core.Discord.Data.Components @using DiscordChatExporter.Core.Discord.Data.Embeds @using DiscordChatExporter.Core.Markdown.Parsing @using DiscordChatExporter.Core.Utils.Extensions @@ -733,6 +734,67 @@ } } + @* Components *@ + @if (message.Components.Any(c => c.Kind == MessageComponentType.ActionRow && c.HasButtons)) + { +
+ @foreach (var actionRow in message.Components.Where(c => c.Kind == MessageComponentType.ActionRow && c.HasButtons)) + { +
+ @foreach (var button in actionRow.Buttons) + { + var styleClass = button.Style switch + { + ButtonStyle.Primary => "chatlog__component-button--primary", + ButtonStyle.Secondary => "chatlog__component-button--secondary", + ButtonStyle.Success => "chatlog__component-button--success", + ButtonStyle.Danger => "chatlog__component-button--danger", + ButtonStyle.Link => "chatlog__component-button--link", + ButtonStyle.Premium => "chatlog__component-button--premium", + _ => "chatlog__component-button--secondary" + }; + + var isUrlButton = button.IsUrlButton; + var isDisabled = button.IsDisabled; + var hasEmoji = button.Emoji is not null; + var hasLabel = !string.IsNullOrWhiteSpace(button.Label); + var emojiUrl = hasEmoji ? await ResolveAssetUrlAsync(button.Emoji.ImageUrl) : null; + var cssClass = $"chatlog__component-button {styleClass}"; + + if (isUrlButton && !isDisabled) + { + + @if (hasEmoji) + { + @button.Emoji.Name + } + + @if (hasLabel) + { + @button.Label + } + + } + else + { + + } + } +
+ } +
+ } + @* Stickers *@ @foreach (var sticker in message.Stickers) { diff --git a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml index 1b5f300a..86dbca47 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -431,6 +431,81 @@ font-weight: 500; } + .chatlog__components { + margin-top: 0.3rem; + } + + .chatlog__action-row { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.3rem; + } + + .chatlog__component-button { + display: inline-flex; + gap: 0.35rem; + align-items: center; + justify-content: center; + min-height: 2rem; + min-width: 3.75rem; + padding: 0 1rem; + border: 0; + border-radius: 3px; + font-family: inherit; + font-size: 0.875rem; + font-weight: 500; + line-height: 1.2; + text-decoration: none; + white-space: nowrap; + cursor: default; + box-sizing: border-box; + } + + a.chatlog__component-button { + cursor: pointer; + } + + .chatlog__component-button:disabled { + opacity: 0.5; + cursor: not-allowed; + } + + .chatlog__component-button:hover:not(:disabled) { + text-decoration: none; + filter: brightness(0.95); + } + + .chatlog__component-button--primary { + background-color: #5865f2; + color: #ffffff; + } + + .chatlog__component-button--secondary { + background-color: @Themed("#4e5058", "#e3e5e8"); + color: @Themed("#ffffff", "#313338"); + } + + .chatlog__component-button--success { + background-color: #248046; + color: #ffffff; + } + + .chatlog__component-button--danger { + background-color: #da373c; + color: #ffffff; + } + + .chatlog__component-button--link { + background-color: @Themed("#4e5058", "#97979f"); + color: @Themed("#ffffff", "#2F3035"); + } + + .chatlog__component-button--premium { + background-color: #5865f2; + color: #ffffff; + } + .chatlog__attachment { position: relative; width: fit-content;