diff --git a/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs new file mode 100644 index 00000000..f723a21c --- /dev/null +++ b/DiscordChatExporter.Cli.Tests/Specs/ComponentParsingSpecs.cs @@ -0,0 +1,83 @@ +using System.Text.Json; +using DiscordChatExporter.Core.Discord.Data; +using FluentAssertions; +using Xunit; + +namespace DiscordChatExporter.Cli.Tests.Specs; + +public class ComponentParsingSpecs +{ + [Fact] + public void I_can_parse_a_link_button_component_from_a_message_payload() + { + // Arrange + using var document = JsonDocument.Parse( + """ + { + "id": "123456789012345678", + "type": 0, + "author": { + "id": "987654321098765432", + "username": "Tester", + "discriminator": "0", + "avatar": null + }, + "timestamp": "2026-02-25T00:00:00.000000+00:00", + "content": "", + "attachments": [], + "components": [ + { + "type": 1, + "components": [ + { + "type": 2, + "style": 5, + "label": "Direct Link", + "url": "https://www.example.com", + "custom_id": null, + "sku_id": null, + "disabled": false, + "emoji": { + "id": null, + "name": "📎", + "animated": false + } + } + ] + } + ], + "embeds": [], + "sticker_items": [], + "reactions": [], + "mentions": [] + } + """ + ); + + // Act + var message = Message.Parse(document.RootElement); + + // Assert + message.Components.Should().HaveCount(1); + message.IsEmpty.Should().BeFalse(); + + var actionRow = message.Components[0]; + actionRow.Components.Should().HaveCount(1); + + var button = actionRow.Components[0]; + button.Style.Should().Be(DiscordChatExporter.Core.Discord.Data.Components.ButtonStyle.Link); + button.Label.Should().Be("Direct Link"); + button + .Url.Should() + .Be( + "https://www.example.com" + ); + button.IsUrlButton.Should().BeTrue(); + button.IsDisabled.Should().BeFalse(); + + button.Emoji.Should().NotBeNull(); + button.Emoji!.Id.Should().BeNull(); + button.Emoji.Name.Should().Be("📎"); + button.Emoji.Code.Should().Be("paperclip"); + } +} diff --git a/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs b/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs index ec1e7f1f..0f9701cf 100644 --- a/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs +++ b/DiscordChatExporter.Cli.Tests/Specs/JsonContentSpecs.cs @@ -88,4 +88,4 @@ public class JsonContentSpecs "866674314627121232" ); } -} +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs b/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs new file mode 100644 index 00000000..6b597dd8 --- /dev/null +++ b/DiscordChatExporter.Core/Discord/Data/Components/ActionRowComponent.cs @@ -0,0 +1,32 @@ +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using JsonExtensions.Reading; + +namespace DiscordChatExporter.Core.Discord.Data.Components; + +// https://discord.com/developers/docs/components/reference#action-row +public partial record ActionRowComponent(IReadOnlyList Components) +{ + public bool HasButtons => Components.Any(); +} + +public partial record ActionRowComponent +{ + public static ActionRowComponent? Parse(JsonElement json) + { + var type = json.GetPropertyOrNull("type")?.GetInt32OrNull(); + if (type != 1) + return null; + + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Where(c => c.GetPropertyOrNull("type")?.GetInt32OrNull() == 2) + ?.Select(ButtonComponent.Parse) + .ToArray() + ?? []; + + return new ActionRowComponent(components); + } +} 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/Message.cs b/DiscordChatExporter.Core/Discord/Data/Message.cs index 9b1c4f92..60932828 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; @@ -21,6 +22,7 @@ public partial record Message( bool IsPinned, string Content, IReadOnlyList Attachments, + IReadOnlyList Components, IReadOnlyList Embeds, IReadOnlyList Stickers, IReadOnlyList Reactions, @@ -34,6 +36,7 @@ public partial record Message( public bool IsEmpty { get; } = string.IsNullOrWhiteSpace(Content) && !Attachments.Any() + && !Components.Any() && !Embeds.Any() && !Stickers.Any(); @@ -149,6 +152,14 @@ public partial record Message .ToArray() ?? []; + var components = + json.GetPropertyOrNull("components") + ?.EnumerateArrayOrNull() + ?.Select(ActionRowComponent.Parse) + .WhereNotNull() + .ToArray() + ?? []; + var embeds = NormalizeEmbeds( json.GetPropertyOrNull("embeds")?.EnumerateArrayOrNull()?.Select(Embed.Parse).ToArray() ?? [] @@ -198,6 +209,7 @@ public partial record Message isPinned, content, attachments, + components, embeds, stickers, reactions, @@ -208,4 +220,4 @@ public partial record Message interaction ); } -} +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs b/DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs index 6bb8cc11..c3c38739 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; @@ -352,7 +353,7 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteEndObject(); await _writer.FlushAsync(cancellationToken); } - + private async ValueTask WriteStickerAsync( Sticker sticker, CancellationToken cancellationToken = default @@ -369,6 +370,48 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) ); _writer.WriteEndObject(); + await _writer.FlushAsync(cancellationToken); + } + + private async ValueTask WriteButtonComponentAsync( + ButtonComponent button, + { + _writer.WriteStartObject(); + + _writer.WriteString("type", "Button"); + _writer.WriteString("style", button.Style.ToString()); + _writer.WriteString("label", button.Label); + _writer.WriteString("url", button.Url); + _writer.WriteString("customId", button.CustomId); + _writer.WriteString("skuId", button.SkuId?.ToString()); + _writer.WriteBoolean("isDisabled", button.IsDisabled); + + if (button.Emoji is not null) + { + _writer.WritePropertyName("emoji"); + await WriteEmojiAsync(button.Emoji, cancellationToken); + } + + _writer.WriteEndObject(); + await _writer.FlushAsync(cancellationToken); + } + + private async ValueTask WriteActionRowComponentAsync( + ActionRowComponent actionRow, + CancellationToken cancellationToken = default + ) + { + _writer.WriteStartObject(); + + _writer.WriteString("type", "ActionRow"); + + _writer.WriteStartArray("components"); + foreach (var button in actionRow.Components) + await WriteButtonComponentAsync(button, cancellationToken); + _writer.WriteEndArray(); + + _writer.WriteEndObject(); + await _writer.FlushAsync(cancellationToken); } public override async ValueTask WritePreambleAsync( @@ -477,6 +520,14 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) _writer.WriteEndArray(); + // Components + _writer.WriteStartArray("components"); + + foreach (var component in message.Components) + await WriteActionRowComponentAsync(component, cancellationToken); + + _writer.WriteEndArray(); + // Embeds _writer.WriteStartArray("embeds"); @@ -645,4 +696,4 @@ internal class JsonMessageWriter(Stream stream, ExportContext context) await _writer.DisposeAsync(); await base.DisposeAsync(); } -} +} \ No newline at end of file diff --git a/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml b/DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml index cefb8bfb..b14e4122 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.HasButtons)) + { +
+ @foreach (var actionRow in message.Components.Where(c => c.HasButtons)) + { +
+ @foreach (var button in actionRow.Components) + { + 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..a43a4db3 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: #4e5058; + color: #ffffff; + } + + .chatlog__component-button--premium { + background-color: #5865f2; + color: #ffffff; + } + .chatlog__attachment { position: relative; width: fit-content;