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..82622aa9 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponent.cs @@ -0,0 +1,49 @@ +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#what-is-a-component +public partial record MessageComponent( + MessageComponentKind 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(MessageComponentKind), type)) + return null; + + var kind = (MessageComponentKind)type; + + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Select(Parse) + .WhereNotNull() + .ToArray() + ?? []; + + var button = kind == MessageComponentKind.Button ? ButtonComponent.Parse(json) : null; + + return new MessageComponent(kind, components, button); + } +} diff --git a/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentKind.cs b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentKind.cs new file mode 100644 index 00000000..a7e1c122 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/MessageComponentKind.cs @@ -0,0 +1,22 @@ +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#component-object-component-types +public enum MessageComponentKind +{ + 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 9b1c4f92..7f4b1a16 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 DiscordChatExporter.Core.Utils.Extensions; using JsonExtensions.Reading; @@ -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 6bb8cc11..988b4464 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 DiscordChatExporter.Core.Utils.Extensions; @@ -369,6 +370,46 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) ); _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(); + await _writer.FlushAsync(cancellationToken); } public override async ValueTask WritePreambleAsync( @@ -477,6 +518,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 cefb8bfb..7777619f 100644 --- a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml @@ -3,6 +3,7 @@ @using System.Linq @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 DiscordChatExporter.Core.Utils.Extensions @@ -731,6 +732,61 @@ } } + @* Components *@ + @if (message.Components.Any(c => c.Kind == MessageComponentKind.ActionRow && c.HasButtons)) + { +
+ @foreach (var actionRow in message.Components.Where(c => c.Kind == MessageComponentKind.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; + + if (isUrlButton) + { + + @if (button.Emoji is not null) + { + @button.Emoji.Name + } + + @if (!string.IsNullOrWhiteSpace(button.Label)) + { + @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 3b9cad68..e2286d67 100644 --- a/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml +++ b/DiscordChatExporter.Core/Exporting/PreambleTemplate.cshtml @@ -430,6 +430,72 @@ 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:hover { + 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;