Skip to content
Open
Show file tree
Hide file tree
Changes from 7 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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);

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
public bool IsUrlButton => !string.IsNullOrWhiteSpace(Url);
public bool IsLink => !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

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can just do Enum.ParseOrNull<ButtonStyle>(s) here (you may need to merge latest prime to get access to this method via PowerKit.Extensions). We will handle unknown styles during rendering (which your code already does by falling back to secondary style).

)
?? 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);
}
}
12 changes: 12 additions & 0 deletions DiscordChatExporter.Core/Discord/Data/Components/ButtonStyle.cs
Original file line number Diff line number Diff line change
@@ -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,
}
Original file line number Diff line number Diff line change
@@ -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
Comment thread
solareon marked this conversation as resolved.
public partial record MessageComponent(
Comment thread
Tyrrrz marked this conversation as resolved.

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The design I was thinking about was more like this:

  • MessageComponent: abstract class, essentially a marker (basically a union in other languages). Has a Parse(JsonElement) method that checks the rawType and calls actual components' own Parse(JsonElement) methods.
  • ButtonMessageComponent: inherits from MessageComponent, has its own Parse(JsonElement), and the properties that are inherent to a button.
  • ActionRowComponent: inherits from MessageComponent, has its own Parse(JsonElement) (that recursively calls into MessageComponent.Parse to parse children), and the properties that are inherent to an action row (i.e. Children).

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The rendering code would then pattern match via:

component switch
{
		ButtonMessageComponent button => ...
		ActionRowComponent actionRow => ...
}

MessageComponentType Kind,
IReadOnlyList<MessageComponent> Components,
ButtonComponent? Button
)
{
public bool HasButtons => Button is not null || Components.Any(c => c.HasButtons);

public IReadOnlyList<ButtonComponent> 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;
Comment on lines +32 to +33

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are you sure this check is necessary? If that value is not defined in the enum, your current logic would flow through to ParseDefault anyway. Is ParseDefault not meant to act as a fallback?


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()
?? [];
}
}
Original file line number Diff line number Diff line change
@@ -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,
}
12 changes: 12 additions & 0 deletions DiscordChatExporter.Core/Discord/Data/Message.cs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
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;
Expand All @@ -23,6 +24,7 @@ public partial record Message(
IReadOnlyList<Attachment> Attachments,
IReadOnlyList<Embed> Embeds,
IReadOnlyList<Sticker> Stickers,
IReadOnlyList<MessageComponent> Components,
IReadOnlyList<Reaction> Reactions,
IReadOnlyList<User> MentionedUsers,
MessageReference? Reference,
Expand All @@ -34,6 +36,7 @@ public partial record Message(
public bool IsEmpty { get; } =
string.IsNullOrWhiteSpace(Content)
&& !Attachments.Any()
&& !Components.Any()
&& !Embeds.Any()
&& !Stickers.Any();

Expand Down Expand Up @@ -161,6 +164,14 @@ public static Message Parse(JsonElement json)
.ToArray()
?? [];

var components =
json.GetPropertyOrNull("components")
?.EnumerateArrayOrNull()
?.Select(MessageComponent.Parse)
.WhereNotNull()
.ToArray()
?? [];

var reactions =
json.GetPropertyOrNull("reactions")
?.EnumerateArrayOrNull()
Expand Down Expand Up @@ -200,6 +211,7 @@ public static Message Parse(JsonElement json)
attachments,
embeds,
stickers,
components,
reactions,
mentionedUsers,
messageReference,
Expand Down
49 changes: 49 additions & 0 deletions DiscordChatExporter.Core/Exporting/JsonMessageWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
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;
Expand Down Expand Up @@ -369,6 +370,46 @@ 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();
await _writer.FlushAsync(cancellationToken);
Comment thread
solareon marked this conversation as resolved.
Outdated
}

public override async ValueTask WritePreambleAsync(
Expand Down Expand Up @@ -477,6 +518,14 @@ await FormatMarkdownAsync(message.Content, cancellationToken)

_writer.WriteEndArray();

// Components
_writer.WriteStartArray("components");

foreach (var component in message.Components)
await WriteComponentAsync(component, cancellationToken);

_writer.WriteEndArray();
Comment thread
Tyrrrz marked this conversation as resolved.

// Embeds
_writer.WriteStartArray("embeds");

Expand Down
56 changes: 56 additions & 0 deletions DiscordChatExporter.Core/Exporting/MessageGroupTemplate.cshtml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -731,6 +732,61 @@
}
}

@* Components *@
@if (message.Components.Any(c => c.Kind == MessageComponentType.ActionRow && c.HasButtons))
{
<div class="chatlog__components">
@foreach (var actionRow in message.Components.Where(c => c.Kind == MessageComponentType.ActionRow && c.HasButtons))
{
<div class="chatlog__action-row">
@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)
{
<a class="chatlog__component-button @styleClass" href="@button.Url" target="_blank" rel="noreferrer noopener">
@if (button.Emoji is not null)
{
<img class="chatlog__emoji chatlog__emoji--small" alt="@button.Emoji.Name" src="@await ResolveAssetUrlAsync(button.Emoji.ImageUrl)" loading="lazy">
}

@if (!string.IsNullOrWhiteSpace(button.Label))
{
<span>@button.Label</span>
}
</a>
Comment thread
solareon marked this conversation as resolved.
Outdated
}
Comment thread
solareon marked this conversation as resolved.
else
{
<button class="chatlog__component-button @styleClass" type="button" disabled>
@if (button.Emoji is not null)
{
<img class="chatlog__emoji chatlog__emoji--small" alt="@button.Emoji.Name" src="@await ResolveAssetUrlAsync(button.Emoji.ImageUrl)" loading="lazy">
}

@if (!string.IsNullOrWhiteSpace(button.Label))
{
<span>@button.Label</span>
}
</button>
Comment thread
solareon marked this conversation as resolved.
Outdated
}
}
</div>
}
</div>
}

@* Stickers *@
@foreach (var sticker in message.Stickers)
{
Expand Down
Loading