diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ButtonBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ButtonBuilder.cs index 0b4b5f3330..06ddbd5e08 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ButtonBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ButtonBuilder.cs @@ -9,6 +9,7 @@ namespace Discord; /// public class ButtonBuilder : IInteractableComponentBuilder { + /// public ComponentType Type => ComponentType.Button; /// diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentBuilder.cs index e2a3b65798..28e6cce5ce 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentBuilder.cs @@ -10,7 +10,7 @@ namespace Discord; public class ComponentBuilder { /// - /// The max length of a . + /// The max length of and . /// public const int MaxCustomIdLength = 100; diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentContainerExtensions.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentContainerExtensions.cs index 5d57d39654..c9b3059c63 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentContainerExtensions.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/ComponentContainerExtensions.cs @@ -469,6 +469,54 @@ public static BuilderT WithActionRow(this BuilderT container, return container.WithActionRow(cont); } + /// + /// Adds a to the container. + /// + /// + /// The current container. + /// + public static BuilderT WithFileUpload(this BuilderT container, FileUploadComponentBuilder fileUpload) + where BuilderT : class, IInteractableComponentContainer + { + container.AddComponent(fileUpload); + return container; + } + + /// + /// Adds a to the container. + /// + /// + /// The current container. + /// + public static BuilderT WithFileUpload(this BuilderT container, + string customId, + int? minValues = null, + int? maxValues = null, + bool isRequired = true, + int? id = null) + where BuilderT : class, IInteractableComponentContainer + => container.WithFileUpload(new FileUploadComponentBuilder() + .WithCustomId(customId) + .WithMinValues(minValues) + .WithMaxValues(maxValues) + .WithRequired(isRequired) + .WithId(id)); + + /// + /// Adds a to the container. + /// + /// + /// The current container. + /// + public static BuilderT WithFileUpload(this BuilderT container, + Action options) + where BuilderT : class, IInteractableComponentContainer + { + var comp = new FileUploadComponentBuilder(); + options(comp); + return container.WithFileUpload(comp); + } + /// /// Finds the first in the /// or any of its child s with matching id. diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/FileComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/FileComponentBuilder.cs index 951ef063a1..f5ebb13f62 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/FileComponentBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/FileComponentBuilder.cs @@ -2,6 +2,9 @@ namespace Discord; +/// +/// Represents a class used to build 's. +/// public class FileComponentBuilder : IMessageComponentBuilder { /// diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/FileUploadComponentBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/FileUploadComponentBuilder.cs new file mode 100644 index 0000000000..582d998a10 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/FileUploadComponentBuilder.cs @@ -0,0 +1,192 @@ +using System; + +namespace Discord; + +/// +/// Represents a class used to build 's. +/// +public class FileUploadComponentBuilder : IInteractableComponentBuilder +{ + /// + /// The maximum number of values for the and properties. + /// + public const int MaxFileCount = 10; + + /// + public ComponentType Type => ComponentType.FileUpload; + + /// + public int? Id { get; set; } + + /// + /// Gets or sets the custom id of the current file upload. + /// + /// length exceeds . + /// length subceeds 1. + public string CustomId + { + get => _customId; + set + { + if (value is not null) + { + Preconditions.AtLeast(value.Length, 1, nameof(CustomId)); + Preconditions.AtMost(value.Length, ModalComponentBuilder.MaxCustomIdLength, nameof(CustomId)); + } + + _customId = value; + } + } + + /// + /// Gets or sets the minimum number of items that must be uploaded (defaults to 1). + /// + /// exceeds . + /// length subceeds 0. + public int? MinValues + { + get => _minValues; + set + { + if (value is not null) + { + Preconditions.AtLeast(value.Value, 0, nameof(MinValues)); + Preconditions.AtMost(value.Value, MaxFileCount, nameof(MinValues)); + } + + _minValues = value; + } + } + + /// + /// Gets or sets the maximum number of items that can be uploaded (defaults to 1). + /// + /// exceeds . + public int? MaxValues + { + get => _maxValues; + set + { + if (value is not null) + { + Preconditions.AtMost(value.Value, MaxFileCount, nameof(MaxValues)); + } + + _maxValues = value; + } + } + + /// + /// Gets or sets a value indicating whether the current file upload requires files to be uploaded before submitting the modal (defaults to ). + /// + public bool IsRequired { get; set; } = true; + + /// + /// Sets the custom id of the current file upload. + /// + /// The id to use for the current file upload. + /// + /// The current builder. + public FileUploadComponentBuilder WithCustomId(string customId) + { + CustomId = customId; + return this; + } + + /// + /// Sets the minimum number of items that must be uploaded (defaults to 1). + /// + /// Sets the minimum number of items that must be uploaded. + /// + /// + /// The current builder. + /// + public FileUploadComponentBuilder WithMinValues(int? minValues) + { + MinValues = minValues; + return this; + } + + /// + /// Sets the maximum number of items that can be uploaded (defaults to 1). + /// + /// The maximum number of items that can be uploaded. + /// + /// + /// The current builder. + /// + public FileUploadComponentBuilder WithMaxValues(int? maxValues) + { + MaxValues = maxValues; + return this; + } + + /// + /// Sets whether the current file upload requires files to be uploaded before submitting the modal. + /// + /// Whether the current file upload requires files to be uploaded before submitting the modal. + /// + /// The current builder. + /// + public FileUploadComponentBuilder WithRequired(bool isRequired) + { + IsRequired = isRequired; + return this; + } + + private string _customId; + private int? _minValues; + private int? _maxValues; + + /// + /// Initializes a new instance of the . + /// + public FileUploadComponentBuilder() {} + + /// + /// Initializes a new instance of the . + /// + /// The custom id of the current file upload. + /// The minimum number of items that must be uploaded (defaults to 1). + /// the maximum number of items that can be uploaded (defaults to 1). + /// Whether the current file upload requires files to be uploaded before submitting the modal. + /// The id for the component. + public FileUploadComponentBuilder(string customId, int? minValues = null, int? maxValues = null, bool isRequired = true, int? id = null) + { + CustomId = customId; + MinValues = minValues; + MaxValues = maxValues; + IsRequired = isRequired; + Id = id; + } + + /// + /// Initializes a new instance of the class from an existing . + /// + /// The component. + public FileUploadComponentBuilder(FileUploadComponent fileUpload) + { + CustomId = fileUpload.CustomId; + MinValues = fileUpload.MinValues; + MaxValues = fileUpload.MaxValues; + IsRequired = fileUpload.IsRequired; + Id = fileUpload.Id; + } + + /// + public FileUploadComponent Build() + { + Preconditions.NotNullOrWhitespace(CustomId, nameof(CustomId)); + + if (MinValues is not null && MaxValues is not null) + Preconditions.AtLeast(MaxValues.Value, MinValues.Value, nameof(MaxValues)); + + Preconditions.AtMost(MinValues ?? 0, MaxFileCount, nameof(MinValues)); + Preconditions.AtMost(MaxValues ?? 0, MaxFileCount, nameof(MaxValues)); + + return new FileUploadComponent(Id, CustomId, MinValues, MaxValues, IsRequired); + } + + /// + IMessageComponent IMessageComponentBuilder.Build() => Build(); +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/LabelBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/LabelBuilder.cs new file mode 100644 index 0000000000..29e42ce4b2 --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/LabelBuilder.cs @@ -0,0 +1,169 @@ +using System; +using System.Collections.Immutable; + +namespace Discord; + +/// +/// Represents a class used to build 's. +/// +public class LabelBuilder : IMessageComponentBuilder +{ + /// + public ImmutableArray SupportedComponentTypes { get; } = + [ + ComponentType.SelectMenu, + ComponentType.TextInput, + ComponentType.UserSelect, + ComponentType.RoleSelect, + ComponentType.MentionableSelect, + ComponentType.ChannelSelect, + ComponentType.FileUpload + ]; + + /// + /// The maximum length of the label. + /// + public const int MaxLabelLength = 45; + + /// + /// The maximum length of the description. + /// + public const int MaxDescriptionLength = 100; + + /// + public ComponentType Type => ComponentType.Label; + + /// + public int? Id { get; set; } + + /// + /// Gets or sets the label text. + /// + public string Label + { + get => _label; + set + { + if (value is not null) + { + Preconditions.AtMost(value.Length, MaxLabelLength, nameof(Label)); + } + + _label = value; + } + } + + /// + /// Gets or sets the description text for the label. + /// + public string Description + { + get => _description; + set + { + if (value is not null) + { + Preconditions.AtMost(value.Length, MaxDescriptionLength, nameof(Description)); + } + + _description = value; + } + } + + /// + /// Gets or sets the component within the label. + /// + public IMessageComponentBuilder Component { get; set; } + + /// + /// Sets the label text. + /// + /// The label text. + /// + /// The current builder. + /// + public LabelBuilder WithLabel(string label) + { + Label = label; + return this; + } + + /// + /// Sets the description text for the label. + /// + /// The description text for the label. + /// + /// The current builder. + /// + public LabelBuilder WithDescription(string description) + { + Description = description; + return this; + } + + /// + /// Sets the component within the label. + /// + /// The component within the label. + /// + /// The current builder. + /// + public LabelBuilder WithComponent(IMessageComponentBuilder component) + { + Component = component; + return this; + } + + private string _label; + private string _description; + + /// + /// Initializes a new . + /// + public LabelBuilder() { } + + /// + /// Initializes a new with the specified content. + /// + /// The label text. + /// The component within the label. + /// The description text for the label. + /// The id for the component. + public LabelBuilder(string label, IMessageComponentBuilder component, string description = null, int? id = null) + { + Id = id; + Label = label; + Component = component; + Description = description; + } + + /// + /// Initializes a new from existing component. + /// + public LabelBuilder(LabelComponent label) + { + Label = label.Label; + Description = label.Description; + Id = label.Id; + Component = label.Component.ToBuilder(); + } + + /// + public LabelComponent Build() + { + Preconditions.NotNullOrWhitespace(Label, nameof(Label)); + Preconditions.AtMost(Label.Length, MaxLabelLength, nameof(Label)); + + Preconditions.AtMost(Description?.Length ?? 0, MaxDescriptionLength, nameof(Description)); + + Preconditions.NotNull(Component, nameof(Component)); + + if (!SupportedComponentTypes.Contains(Component.Type)) + throw new InvalidOperationException($"Component can only be {nameof(SelectMenuBuilder)}, {nameof(TextInputBuilder)} or {nameof(FileUploadComponentBuilder)}."); + + return new LabelComponent(Id, Label, Description, Component.Build()); + } + + /// + IMessageComponent IMessageComponentBuilder.Build() => Build(); +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/MediaGalleryBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/MediaGalleryBuilder.cs index a6fcb65b55..94e3cf8f73 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/MediaGalleryBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/MediaGalleryBuilder.cs @@ -18,7 +18,7 @@ public class MediaGalleryBuilder : IMessageComponentBuilder /// public int? Id { get; set; } - private List _items = new(); + private List _items = []; /// /// Initializes a new instance of the . diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextDisplayBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextDisplayBuilder.cs index e50d011814..e0bd5d9f0e 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextDisplayBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextDisplayBuilder.cs @@ -2,6 +2,9 @@ namespace Discord; +/// +/// Represents a class used to build 's. +/// public class TextDisplayBuilder : IMessageComponentBuilder { /// diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextInputBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextInputBuilder.cs index abecb1f50e..3659ecd6ad 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextInputBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/Builders/TextInputBuilder.cs @@ -14,12 +14,16 @@ public class TextInputBuilder : IInteractableComponentBuilder /// The max length of a . /// public const int MaxPlaceholderLength = 100; + + /// + /// The max value for and , and the max length for . + /// public const int LargestMaxLength = 4000; /// /// Gets or sets the custom id of the current text input. /// - /// length exceeds + /// length exceeds . /// length subceeds 1. public string CustomId { @@ -29,7 +33,7 @@ public string CustomId if (value is not null) { Preconditions.AtLeast(value.Length, 1, nameof(CustomId)); - Preconditions.AtMost(value.Length, ComponentBuilder.MaxCustomIdLength, nameof(CustomId)); + Preconditions.AtMost(value.Length, ModalComponentBuilder.MaxCustomIdLength, nameof(CustomId)); } _customId = value; @@ -44,6 +48,7 @@ public string CustomId /// /// Gets or sets the label of the current text input. /// + [Obsolete("Label is no longer supported", error: false)] public string Label { get; set; } /// @@ -55,7 +60,8 @@ public string Placeholder get => _placeholder; set => _placeholder = (value?.Length ?? 0) <= MaxPlaceholderLength ? value - : throw new ArgumentException($"Placeholder cannot have more than {MaxPlaceholderLength} characters. Value: \"{value}\""); + : throw new ArgumentException( + $"Placeholder cannot have more than {MaxPlaceholderLength} characters. Value: \"{value}\""); } /// @@ -72,7 +78,8 @@ public int? MinLength if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be less than 0"); if (value > LargestMaxLength) - throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must not be greater than {LargestMaxLength}"); + throw new ArgumentOutOfRangeException(nameof(value), + $"MinLength must not be greater than {LargestMaxLength}"); if (value > (MaxLength ?? LargestMaxLength)) throw new ArgumentOutOfRangeException(nameof(value), $"MinLength must be less than MaxLength"); _minLength = value; @@ -93,9 +100,11 @@ public int? MaxLength if (value < 0) throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must not be less than 0"); if (value > LargestMaxLength) - throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength most not be greater than {LargestMaxLength}"); + throw new ArgumentOutOfRangeException(nameof(value), + $"MaxLength most not be greater than {LargestMaxLength}"); if (value < (MinLength ?? -1)) - throw new ArgumentOutOfRangeException(nameof(value), $"MaxLength must be greater than MinLength ({MinLength})"); + throw new ArgumentOutOfRangeException(nameof(value), + $"MaxLength must be greater than MinLength ({MinLength})"); _maxLength = value; } } @@ -105,6 +114,7 @@ public int? MaxLength /// public bool? Required { get; set; } + /// public int? Id { get; set; } /// @@ -123,9 +133,11 @@ public string Value set { if (value?.Length > (MaxLength ?? LargestMaxLength)) - throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be longer than {MaxLength ?? LargestMaxLength}. Value: \"{value}\""); + throw new ArgumentOutOfRangeException(nameof(value), + $"Value must not be longer than {MaxLength ?? LargestMaxLength}. Value: \"{value}\""); if (value?.Length < (MinLength ?? 0)) - throw new ArgumentOutOfRangeException(nameof(value), $"Value must not be shorter than {MinLength}. Value: \"{value}\""); + throw new ArgumentOutOfRangeException(nameof(value), + $"Value must not be shorter than {MinLength}. Value: \"{value}\""); _value = value; } @@ -140,17 +152,25 @@ public string Value /// /// Creates a new instance of a . /// - /// The text input's label. /// The text input's style. /// The text input's custom id. /// The text input's placeholder. /// The text input's minimum length. /// The text input's maximum length. /// The text input's required value. - public TextInputBuilder(string label, string customId, TextInputStyle style = TextInputStyle.Short, string placeholder = null, - int? minLength = null, int? maxLength = null, bool? required = null, string value = null, int? id = null) + /// The text input's default value. + /// The id for the component. + public TextInputBuilder( + string customId, + TextInputStyle style = TextInputStyle.Short, + string placeholder = null, + int? minLength = null, + int? maxLength = null, + bool? required = null, + string value = null, + int? id = null + ) { - Label = label; Style = style; CustomId = customId; Placeholder = placeholder; @@ -164,9 +184,34 @@ public TextInputBuilder(string label, string customId, TextInputStyle style = Te /// /// Creates a new instance of a . /// - public TextInputBuilder() + /// The text input's label. + /// The text input's style. + /// The text input's custom id. + /// The text input's placeholder. + /// The text input's minimum length. + /// The text input's maximum length. + /// The text input's required value. + [Obsolete("label is no longer supported", error: false)] + public TextInputBuilder( + string label, + string customId, + TextInputStyle style = TextInputStyle.Short, + string placeholder = null, + int? minLength = null, + int? maxLength = null, + bool? required = null, + string value = null, + int? id = null + ) : this(customId, style, placeholder, minLength, maxLength, required, value, id) { + Label = label; + } + /// + /// Creates a new instance of a . + /// + public TextInputBuilder() + { } /// @@ -174,7 +219,9 @@ public TextInputBuilder() /// public TextInputBuilder(TextInputComponent textInput) { +#pragma warning disable CS0618 // Type or member is obsolete Label = textInput.Label; +#pragma warning restore CS0618 // Type or member is obsolete Style = textInput.Style; CustomId = textInput.CustomId; Placeholder = textInput.Placeholder; @@ -190,6 +237,7 @@ public TextInputBuilder(TextInputComponent textInput) /// /// The value to set. /// The current builder. + [Obsolete("Label is no longer supported", error: false)] public TextInputBuilder WithLabel(string label) { Label = label; @@ -273,16 +321,19 @@ public TextInputBuilder WithRequired(bool required) return this; } + /// public TextInputComponent Build() { if (string.IsNullOrEmpty(CustomId)) throw new ArgumentException("TextInputComponents must have a custom id.", nameof(CustomId)); - if (string.IsNullOrWhiteSpace(Label)) - throw new ArgumentException("TextInputComponents must have a label.", nameof(Label)); + if (Style is TextInputStyle.Short && Value?.Any(x => x == '\n') is true) - throw new ArgumentException($"Value must not contain new line characters when style is {TextInputStyle.Short}.", nameof(Value)); + throw new ArgumentException( + $"Value must not contain new line characters when style is {TextInputStyle.Short}.", nameof(Value)); +#pragma warning disable CS0618 // Type or member is obsolete return new TextInputComponent(CustomId, Label, Placeholder, MinLength, MaxLength, Style, Required, Value, Id); +#pragma warning restore CS0618 // Type or member is obsolete } IMessageComponent IMessageComponentBuilder.Build() => Build(); diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs index 5d5cce5a77..4595fafa46 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/ComponentType.cs @@ -45,18 +45,49 @@ public enum ComponentType /// ChannelSelect = 8, + /// + /// A container to display text alongside an accessory component. + /// Section = 9, + /// + /// A component displaying Markdown text. + /// TextDisplay = 10, + /// + /// A small image that can be used as an accessory. + /// Thumbnail = 11, + /// + /// A component displaying images and other media. + /// MediaGallery = 12, + /// + /// A component displaying an attached file. + /// File = 13, + /// + /// A component to add vertical padding between other components. + /// Separator = 14, + /// + /// A container that visually groups a set of components. + /// Container = 17, + + /// + /// A layout component that wraps modal components (text input, select menu or file upload) with a label and description. + /// + Label = 18, + + /// + /// A component that allows users to upload files in modals. + /// + FileUpload = 19 } } diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/FileUploadComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/FileUploadComponent.cs new file mode 100644 index 0000000000..26a23e85ab --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/FileUploadComponent.cs @@ -0,0 +1,51 @@ +namespace Discord; + +/// +/// Represents a component that allows users to upload files in modals. +/// +public class FileUploadComponent : IInteractableComponent +{ + /// + public ComponentType Type => ComponentType.FileUpload; + + /// + /// Gets the ID of this component. + /// + public int? Id { get; } + + /// + /// Gets the custom ID of this component. + /// + public string CustomId { get; } + + /// + /// Gets the minimum number of files a user must upload. + /// + public int? MinValues { get; } + + /// + /// Gets the maximum number of files a user can upload. + /// + public int? MaxValues { get; } + + /// + /// Gets whether this component requires a file upload to be submitted. + /// + public bool IsRequired { get; } + + internal FileUploadComponent(int? id, string customId, int? minValues, int? maxValues, bool isRequired) + { + Id = id; + CustomId = customId; + MinValues = minValues; + MaxValues = maxValues; + IsRequired = isRequired; + } + + /// + public FileUploadComponentBuilder ToBuilder() + => new(this); + + /// + IMessageComponentBuilder IMessageComponent.ToBuilder() => ToBuilder(); +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IMessageComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IMessageComponent.cs index 1d043d10ad..deeb2f850b 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IMessageComponent.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/IMessageComponent.cs @@ -11,7 +11,7 @@ public interface IMessageComponent ComponentType Type { get; } /// - /// + /// Gets the id for the component. /// int? Id { get; } diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/LabelComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/LabelComponent.cs new file mode 100644 index 0000000000..e9c1cfb91c --- /dev/null +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/LabelComponent.cs @@ -0,0 +1,40 @@ +namespace Discord; + +/// +/// Represents a layout component that wraps modal components (text input, select menu or file upload) with a label and description. +/// +public class LabelComponent : IMessageComponent +{ + /// + public ComponentType Type => ComponentType.Label; + + /// + public int? Id { get; } + + /// + /// Gets the label text. + /// + public string Label { get; } + + /// + /// Gets the description text for the label. + /// + public string Description { get; } + + /// + /// Gets the component within the label. + /// + public IMessageComponent Component { get; } + + internal LabelComponent(int? id, string label, string description, IMessageComponent component) + { + Id = id; + Label = label; + Description = description; + Component = component; + } + + /// + public IMessageComponentBuilder ToBuilder() + => new LabelBuilder(this); +} diff --git a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs index 01eae3dcc2..9cd612bdb5 100644 --- a/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs +++ b/src/Discord.Net.Core/Entities/Interactions/MessageComponents/TextInputComponent.cs @@ -49,15 +49,23 @@ public class TextInputComponent : IInteractableComponent /// public string Value { get; } - /// /// Converts a to a . /// public TextInputBuilder ToBuilder() - => new TextInputBuilder(this); + => new(this); - internal TextInputComponent(string customId, string label, string placeholder, int? minLength, int? maxLength, - TextInputStyle style, bool? required, string value, int? id) + internal TextInputComponent( + string customId, + string label, + string placeholder, + int? minLength, + int? maxLength, + TextInputStyle style, + bool? required, + string value, + int? id + ) { CustomId = customId; Label = label; diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs index 767dd5df75..264c53190f 100644 --- a/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/IModalInteractionData.cs @@ -16,5 +16,30 @@ public interface IModalInteractionData : IDiscordInteractionData /// Gets the components submitted by the user. /// IReadOnlyCollection Components { get; } + + /// + /// Gets the channels(s) of a component within the modal. + /// + IReadOnlyCollection Channels { get; } + + /// + /// Gets the user(s) of a or component within the modal. + /// + IReadOnlyCollection Users { get; } + + /// + /// Gets the roles(s) of a or component within the modal. + /// + IReadOnlyCollection Roles { get; } + + /// + /// Gets the guild member(s) of a or component within the modal. + /// + IReadOnlyCollection Members { get; } + + /// + /// Gets the attachment(s) of a component within the modal. + /// + IReadOnlyCollection Attachments { get; } } } diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs index 6062db0cc0..1d61030381 100644 --- a/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/Modal.cs @@ -10,7 +10,9 @@ public class Modal /// public string Title { get; set; } - /// + /// + /// Gets the custom id of the modal. + /// public string CustomId { get; set; } /// diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs index 016071ced6..7afe85c315 100644 --- a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalBuilder.cs @@ -1,4 +1,5 @@ using System; +using System.Collections; using System.Collections.Generic; using System.Linq; @@ -11,7 +12,13 @@ public class ModalBuilder { private string _customId; - public ModalBuilder() { } + /// + /// Creates a new and empty . + /// + public ModalBuilder() + { + Components = new(); + } /// /// Creates a new instance of the . @@ -19,7 +26,6 @@ public ModalBuilder() { } /// The modal's title. /// The modal's customId. /// The modal's components. - /// Only TextInputComponents are allowed. public ModalBuilder(string title, string customId, ModalComponentBuilder components = null) { Title = title; @@ -27,6 +33,28 @@ public ModalBuilder(string title, string customId, ModalComponentBuilder compone Components = components ?? new(); } + /// + /// Creates a new instance of the . + /// + /// The modal's title. + /// The modal's customId. + /// The modal's components. + public ModalBuilder(string title, string customId, params IEnumerable components) + : this(title, customId, new ModalComponentBuilder(components)) + { + } + + /// + /// Creates a new instance of the . + /// + /// The modal's title. + /// The modal's customId. + /// The modal's components. + public ModalBuilder(string title, string customId, params IEnumerable components) + : this(title, customId, new ModalComponentBuilder(components)) + { + } + /// /// Gets or sets the title of the current modal. /// @@ -43,7 +71,7 @@ public string CustomId if (value is not null) { Preconditions.AtLeast(value.Length, 1, nameof(CustomId)); - Preconditions.AtMost(value.Length, ComponentBuilder.MaxCustomIdLength, nameof(CustomId)); + Preconditions.AtMost(value.Length, ModalComponentBuilder.MaxCustomIdLength, nameof(CustomId)); } _customId = value; @@ -53,7 +81,7 @@ public string CustomId /// /// Gets or sets the components of the current modal. /// - public ModalComponentBuilder Components { get; set; } = new(); + public ModalComponentBuilder Components { get; set; } /// /// Sets the title of the current modal. @@ -83,52 +111,227 @@ public ModalBuilder WithCustomId(string customId) /// The component to add. /// The row to add the text input. /// The current builder. - public ModalBuilder AddTextInput(TextInputBuilder component, int row = 0) + [Obsolete("Modal components no longer have rows", error: false)] + public ModalBuilder AddTextInput(TextInputBuilder component, int row) { Components.WithTextInput(component, row); return this; } - /// - /// Adds a to the current builder. - /// - /// The input's custom id. - /// The input's label. - /// The input's placeholder text. - /// The input's minimum length. - /// The input's maximum length. - /// The input's style. - /// The current builder. - public ModalBuilder AddTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, - string placeholder = "", int? minLength = null, int? maxLength = null, bool? required = null, string value = null) - => AddTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value)); + /// + /// The current . + public ModalBuilder AddTextInput( + string label, + string customId, + TextInputStyle style = TextInputStyle.Short, + string placeholder = null, + int? minLength = null, + int? maxLength = null, + bool? required = null, + string value = null, + int? id = null, + string description = null, + int? labelId = null + ) + { + Components.WithTextInput( + label, customId, style, placeholder, minLength, maxLength, 0, required, value, id, description, + labelId + ); + + return this; + } + + /// + /// The current . + public ModalBuilder AddLabel(LabelBuilder label) + { + Components.WithLabel(label); + return this; + } + + /// + /// The current . + public ModalBuilder AddLabel( + string label, + IMessageComponentBuilder component, + string description = null, + int? id = null + ) + { + Components.WithLabel(label, component, description, id); + return this; + } + + /// + /// The current . + public ModalBuilder AddSelectMenu( + string label, + string customId, + List options = null, + string placeholder = null, + int minValues = 1, + int maxValues = 1, + bool disabled = false, + ComponentType type = ComponentType.SelectMenu, + ChannelType[] channelTypes = null, + int? id = null, + string description = null, + int? labelId = null + ) + { + Components.WithSelectMenu( + label, + customId, + options, + placeholder, + minValues, + maxValues, + disabled, + type, + channelTypes, + id, + description, + labelId + ); + + return this; + } + + /// + /// The current . + public ModalBuilder AddSelectMenu( + string label, + SelectMenuBuilder menu, + string description = null, + int? labelId = null + ) + { + Components.WithSelectMenu(label, menu, description, labelId); + return this; + } + + /// + /// The current . + public ModalBuilder AddFileUpload( + string label, + FileUploadComponentBuilder fileUpload, + string description = null, + int? labelId = null + ) + { + Components.WithFileUpload(label, fileUpload, description, labelId); + return this; + } + + /// + /// The current . + public ModalBuilder AddFileUpload( + string label, + string customId, + int? minValues = null, + int? maxValues = null, + bool isRequired = true, + int? id = null, + string description = null, + int? labelId = null + ) + { + Components.WithFileUpload(label, customId, minValues, maxValues, isRequired, id, description, labelId); + return this; + } + + /// + /// The current . + public ModalBuilder AddTextDisplay(TextDisplayBuilder textDisplay) + { + Components.WithTextDisplay(textDisplay); + return this; + } + + /// + /// The current . + public ModalBuilder AddTextDisplay(string content, int? id = null) + { + Components.WithTextDisplay(content, id); + return this; + } /// /// Adds multiple components to the current builder. /// /// The components to add. /// The current builder + [Obsolete("Modal components no longer have rows", error: false)] public ModalBuilder AddComponents(List components, int row) { components.ForEach(x => Components.AddComponent(x, row)); return this; } + /// + /// Adds multiple components to the current builder. + /// + /// The components to add. + /// The current builder + public ModalBuilder AddComponents(params IEnumerable components) + { + Components.With(components); + return this; + } + + /// + /// Gets a by the specified . + /// + /// + /// The of the component to get. + /// + /// + /// The component that was found, otherwise. + /// + public IInteractableComponentBuilder GetComponent(string customId) => + GetComponent(customId); + /// /// Gets a by the specified . /// /// The type of the component to get. - /// The of the component to get. + /// + /// The of the component to get. + /// /// - /// The component of type that was found, otherwise. + /// The component of type that was found, + /// otherwise. /// public TMessageComponentBuilder GetComponent(string customId) where TMessageComponentBuilder : class, IInteractableComponentBuilder { Preconditions.NotNull(customId, nameof(customId)); - return Components.ActionRows?.SelectMany(r => r.Components.OfType()) - .FirstOrDefault(c => c.CustomId == customId); + var components = Components.SelectMany(ExtractComponent); + + // optimization: no need for the of type call if we're checking the root type. + if (typeof(TMessageComponentBuilder) != typeof(IInteractableComponentBuilder)) + components = components.OfType(); + + return (TMessageComponentBuilder)components.FirstOrDefault(x => x.CustomId == customId); + + /* + * Used to extract depth=1 components from the modal. Allows for the same behaviour of the previous + * iteration of the builder, whilst adding support for label components. + * + * This is not a long-term solution, and can break if more component types are added or nesting is changed. + */ + static IEnumerable ExtractComponent(IMessageComponentBuilder builder) + => builder switch + { + LabelBuilder { Component: IInteractableComponentBuilder target } => [target], + ActionRowBuilder { Components: { } components } + => components.OfType(), + _ => [] + }; } /// @@ -144,29 +347,25 @@ public ModalBuilder UpdateTextInput(string customId, Action up { Preconditions.NotNull(customId, nameof(customId)); - var component = GetComponent(customId) ?? throw new ArgumentException($"There is no component of type {nameof(TextInputComponent)} with the specified custom ID in this modal builder.", nameof(customId)); - var row = Components.ActionRows.First(r => r.Components.Contains(component)); + var component = GetComponent(customId) ?? throw new ArgumentException( + $"There is no component of type {nameof(TextInputComponent)} with the specified custom ID in this modal builder.", + nameof(customId)); - var builder = new TextInputBuilder - { - Label = component.Label, - CustomId = component.CustomId, - Style = component.Style, - Placeholder = component.Placeholder, - MinLength = component.MinLength, - MaxLength = component.MaxLength, - Required = component.Required, - Value = component.Value - }; - - updateTextInput(builder); + /* + * We can just update the instance in-place, we don't need to update the parent here. + * + * NOTE: + * this does change the behaviour of this function, since in the previous iteration, we would've removed + * and re-added the component to/from the row, which has the inverse effect of sliding it to the end of the + * row. With this change, we no longer update the position within the row, but I think the position + * shifting was an unintended side effect- and therefor a bug. + */ - row.Components.Remove(component); - row.AddComponent(builder); + updateTextInput(component); return this; } - + /// /// Updates the value of a by the specified . /// @@ -188,7 +387,35 @@ public ModalBuilder RemoveComponent(string customId) { Preconditions.NotNull(customId, nameof(customId)); - Components.ActionRows?.ForEach(r => r.Components.RemoveAll(c => c is IInteractableComponentBuilder ic && ic.CustomId == customId)); + /* + * This function actually removed any component with the provided custom id, and could remove + * more than one. To keep this behaviour, the below code attempts to do the same. + * + * For reference, this was the old implementation + * Components.ActionRows?.ForEach(r => r + * .Components + * .RemoveAll(c => c is IInteractableComponentBuilder ic && ic.CustomId == customId) + * ); + */ + + foreach (var parent in Components.ToArray()) + { + switch (parent) + { + case LabelBuilder { Component: IInteractableComponentBuilder target } label + when target.CustomId == customId: + // you cannot have a label without a component, so we actually remove the label here + Components.Remove(label); + break; + case ActionRowBuilder row: + row.Components.RemoveAll(x => + x is IInteractableComponentBuilder ic && + ic.CustomId == customId + ); + break; + } + } + return this; } @@ -199,7 +426,11 @@ public ModalBuilder RemoveComponent(string customId) /// The current builder. public ModalBuilder RemoveComponentsOfType(ComponentType type) { - Components.ActionRows?.ForEach(r => r.Components.RemoveAll(c => c.Type == type)); + foreach (var component in Components.ToArray()) + { + if (component.Type == type) Components.Remove(component); + } + return this; } @@ -216,8 +447,6 @@ public Modal Build() throw new ArgumentException("Modals must have a custom ID.", nameof(CustomId)); if (string.IsNullOrWhiteSpace(Title)) throw new ArgumentException("Modals must have a title.", nameof(Title)); - if (Components.ActionRows?.SelectMany(r => r.Components).Any(c => c.Type != ComponentType.TextInput) ?? false) - throw new ArgumentException($"Only components of type {nameof(TextInputComponent)} are allowed.", nameof(Components)); return new(Title, CustomId, Components.Build()); } @@ -226,7 +455,7 @@ public Modal Build() /// /// Represents a builder for creating a . /// - public class ModalComponentBuilder + public class ModalComponentBuilder : IList { /// /// The max length of a . @@ -236,126 +465,448 @@ public class ModalComponentBuilder /// /// The max amount of rows a can have. /// + [Obsolete("Modal components no longer support action rows", error: true)] public const int MaxActionRowCount = 5; /// - /// Gets or sets the Action Rows for this Component Builder. + /// Gets the number of components in the builder. + /// + public int Count => _components.Count; + + /// + /// Gets or sets the component at the specified index. /// - /// cannot be null. - /// count exceeds . - public List ActionRows + /// The index of the component to get or set + public IMessageComponentBuilder this[int index] { - get => _actionRows; + get => _components[index]; set { - if (value == null) - throw new ArgumentNullException(nameof(value), $"{nameof(ActionRows)} cannot be null."); - if (value.Count > MaxActionRowCount) - throw new ArgumentOutOfRangeException(nameof(value), $"Action row count must be less than or equal to {MaxActionRowCount}."); - _actionRows = value; + ValidateComponentBuilder(value); + _components[index] = value; } } - private List _actionRows; + private readonly List _components; /// - /// Creates a new builder from the provided list of components. + /// Constructs an empty . /// - /// The components to create the builder from. - /// The newly created builder. - public static ComponentBuilder FromComponents(IReadOnlyCollection components) + public ModalComponentBuilder() + { + _components = []; + } + + /// + /// Constructs a with the provided + /// s. + /// + /// The components to add to this + public ModalComponentBuilder(params IEnumerable components) : this() { - var builder = new ComponentBuilder(); - for (int i = 0; i != components.Count; i++) + foreach (var component in components) { - var component = components.ElementAt(i); - builder.AddComponent(component, i); + Add(component); } - return builder; } - internal void AddComponent(IMessageComponent component, int row) + /// + /// Constructs a with the provided + /// s. + /// + /// The components to add to this + public ModalComponentBuilder(params IEnumerable components) : this() { - switch (component) + foreach (var component in components) { - case TextInputComponent text: - WithTextInput(text.Label, text.CustomId, text.Style, text.Placeholder, text.MinLength, text.MaxLength, row); - break; - case ActionRowComponent actionRow: - foreach (var cmp in actionRow.Components) - AddComponent(cmp, row); - break; + Add(component); } } + private static void ValidateComponentBuilder(IMessageComponentBuilder builder) + { + if (builder is not LabelBuilder and not ActionRowBuilder and not TextDisplayBuilder) + throw new InvalidOperationException( + $"Only top-level modal components (labels, action rows or text displays) are allowed, not {builder.GetType().Name}." + ); + } + /// - /// Adds a to the at the specific row. - /// If the row cannot accept the component then it will add it to a row that can. + /// Creates a new builder from the provided list of components. /// - /// The input's custom id. - /// The input's label. - /// The input's placeholder text. - /// The input's minimum length. - /// The input's maximum length. - /// The input's style. - /// The current builder. - public ModalComponentBuilder WithTextInput(string label, string customId, TextInputStyle style = TextInputStyle.Short, - string placeholder = null, int? minLength = null, int? maxLength = null, int row = 0, bool? required = null, - string value = null) - => WithTextInput(new(label, customId, style, placeholder, minLength, maxLength, required, value), row); + /// The components to create the builder from. + /// The newly created builder. + public static ModalComponentBuilder FromComponents(params IEnumerable components) + { + var builder = new ModalComponentBuilder(); + + foreach (var component in components) + builder.Add(component); + + return builder; + } + + [Obsolete("Modal components no longer have rows", error: true)] + internal ModalComponentBuilder AddComponent(IMessageComponent component, int row) + => Add(component); /// - /// Adds a to the at the specific row. - /// If the row cannot accept the component then it will add it to a row that can. + /// Adds a component to this . /// - /// The to add. - /// The row to add the text input. - /// There are no more rows to add a text input to. - /// must be less than . - /// The current builder. - public ModalComponentBuilder WithTextInput(TextInputBuilder text, int row = 0) + /// The component to add. + /// The current . + public ModalComponentBuilder Add(IMessageComponent component) + => Add(component.ToBuilder()); + + /// + /// Adds a component to this . + /// + /// The component to add. + /// The current . + public ModalComponentBuilder Add(IMessageComponentBuilder component) { - Preconditions.LessThan(row, MaxActionRowCount, nameof(row)); + ValidateComponentBuilder(component); - if (_actionRows == null) - { - _actionRows = new List - { - new ActionRowBuilder().AddComponent(text) - }; - } - else + _components.Add(component); + return this; + } + + /// + /// Sets the components in this builder to the provided + /// + /// The components to set this builder to. + /// The current . + public ModalComponentBuilder With(params IEnumerable components) + { + _components.Clear(); + + foreach (var component in components) + Add(component); + + return this; + } + + /// + /// Adds a to the current . + /// + /// The to add. + /// The current . + public ModalComponentBuilder WithLabel(LabelBuilder label) + => Add(label); + + /// + /// Constructs and adds a to the current . + /// + /// The label of the . + /// The component of the . + /// The description of the . + /// The id of the . + /// The current . + public ModalComponentBuilder WithLabel( + string label, + IMessageComponentBuilder component, + string description = null, + int? id = null + ) => WithLabel(new( + label, + component, + description, + id + )); + + /// + /// Constructs and adds a containing a to the + /// current . + /// + /// The label around the . + /// The custom id of the . + /// The options of the . + /// The placeholder of the . + /// The min values of the . + /// The max values of the . + /// Whether the is disabled. + /// The type of the . + /// The channel types of the . + /// The id of the . + /// The description around the . + /// + /// The id of the wrapping the . + /// + /// The current . + public ModalComponentBuilder WithSelectMenu( + string label, + string customId, + List options = null, + string placeholder = null, + int minValues = 1, + int maxValues = 1, + bool disabled = false, + ComponentType type = ComponentType.SelectMenu, + ChannelType[] channelTypes = null, + int? id = null, + string description = null, + int? labelId = null + ) => WithSelectMenu( + label, + new SelectMenuBuilder() + .WithId(id) + .WithCustomId(customId) + .WithOptions(options) + .WithPlaceholder(placeholder) + .WithMaxValues(maxValues) + .WithMinValues(minValues) + .WithDisabled(disabled) + .WithType(type) + .WithChannelTypes(channelTypes), + description, + labelId + ); + + /// + /// Constructs and adds a with the provided to + /// the current . + /// + /// The label around the . + /// The menu to add. + /// The description around the . + /// + /// The id of the wrapping the . + /// + /// The current . + public ModalComponentBuilder WithSelectMenu( + string label, + SelectMenuBuilder menu, + string description = null, + int? labelId = null + ) + { + if (menu.Options is not null && menu.Options.Distinct().Count() != menu.Options.Count) + throw new InvalidOperationException("Please make sure that there is no duplicates values."); + + return WithLabel( + label, + menu, + description, + labelId + ); + } + + /// + /// Constructs and adds a with the provided + /// to the current . + /// + /// The label around the . + /// The file upload to add. + /// The description around the . + /// + /// The id of the wrapping the . + /// + /// The current . + public ModalComponentBuilder WithFileUpload( + string label, + FileUploadComponentBuilder fileUpload, + string description = null, + int? labelId = null + ) => WithLabel(label, fileUpload, description, labelId); + + /// + /// Constructs and adds a with a + /// to the current . + /// + /// The label around the . + /// The custom id of the . + /// The min values of the . + /// The max values of the . + /// Whether the is required. + /// The id of the . + /// The description around the . + /// + /// The id of the wrapping the . + /// + /// The current . + public ModalComponentBuilder WithFileUpload( + string label, + string customId, + int? minValues = null, + int? maxValues = null, + bool isRequired = true, + int? id = null, + string description = null, + int? labelId = null + ) => WithLabel( + label, + new FileUploadComponentBuilder( + customId, + minValues, + maxValues, + isRequired, + id + ), + description, + labelId + ); + + /// + /// Adds a to the current . + /// + /// The to add. + /// The current . + public ModalComponentBuilder WithTextDisplay(TextDisplayBuilder textDisplay) + => Add(textDisplay); + + /// + /// Constructs and adds a to the current . + /// + /// The content of the . + /// The id of the . + /// The current . + public ModalComponentBuilder WithTextDisplay(string content, int? id = null) + => WithTextDisplay(new TextDisplayBuilder(content, id)); + + /// + /// Constructs and adds a with the provided to + /// the current . + /// + /// The label around the . + /// The text input to add. + /// The description around the . + /// + /// The id of the wrapping the . + /// + /// The current . + public ModalComponentBuilder WithTextInput( + string label, + TextInputBuilder textInput, + string description = null, + int? labelId = null + ) => WithLabel(label, textInput, description, labelId); + + /// + /// Constructs and adds a with the provided to + /// the current . + /// + /// The text input to add. + /// The current . + [Obsolete("text components must be wrapped in a label", error: false)] + public ModalComponentBuilder WithTextInput(TextInputBuilder text) + { +#pragma warning disable CS0618 // Type or member is obsolete + if (text.Label is null) { - if (_actionRows.Count == row) - _actionRows.Add(new ActionRowBuilder().AddComponent(text)); - else - { - ActionRowBuilder actionRow; - if (_actionRows.Count > row) - actionRow = _actionRows.ElementAt(row); - else - { - actionRow = new ActionRowBuilder(); - _actionRows.Add(actionRow); - } - - if (actionRow.CanTakeComponent(text)) - actionRow.AddComponent(text); - else if (row < MaxActionRowCount) - WithTextInput(text, row + 1); - else - throw new InvalidOperationException($"There are no more rows to add {nameof(text)} to."); - } + // TODO: better explain + throw new ArgumentNullException( + nameof(text), + "Label cannot be null" + ); } - return this; + return WithLabel( + text.Label, + text + ); + +#pragma warning restore CS0618 // Type or member is obsolete } + /// + /// Constructs and adds a with the provided to + /// the current . + /// + /// The text input to add. + /// The row to add the text input to. + /// The current . + [Obsolete("Modal components no longer have rows", error: false)] + public ModalComponentBuilder WithTextInput(TextInputBuilder text, int row) + => WithTextInput(text); + + /// + /// Constructs and adds a with a + /// to the current . + /// + /// The label around the . + /// The custom id of the . + /// The style of the . + /// The placeholder of the . + /// The min length of the . + /// The max length of the . + /// DEPRECATED: The row to place the on. + /// Whether the is required. + /// The value of the . + /// The id of the . + /// The description around the . + /// + /// The id of the wrapping the . + /// + /// The current . + public ModalComponentBuilder WithTextInput( + string label, + string customId, + TextInputStyle style = TextInputStyle.Short, + string placeholder = null, + int? minLength = null, + int? maxLength = null, + int row = 0, + bool? required = null, + string value = null, + int? id = null, + string description = null, + int? labelId = null + ) => WithLabel( + label, + new TextInputBuilder( + customId, + style, + placeholder, + minLength, + maxLength, + required, + value, + id + ), + description, + labelId + ); + + /// + void ICollection.Add(IMessageComponentBuilder item) => Add(item); + + /// + public void Clear() => _components.Clear(); + + /// + public bool Contains(IMessageComponentBuilder item) => _components.Contains(item); + + /// + public void CopyTo(IMessageComponentBuilder[] array, int arrayIndex) => _components.CopyTo(array, arrayIndex); + + /// + public bool Remove(IMessageComponentBuilder item) => _components.Remove(item); + + /// + public int IndexOf(IMessageComponentBuilder item) => _components.IndexOf(item); + + /// + public void Insert(int index, IMessageComponentBuilder item) + { + ValidateComponentBuilder(item); + + _components.Insert(index, item); + } + + /// + public void RemoveAt(int index) => _components.RemoveAt(index); + + /// + public IEnumerator GetEnumerator() => _components.GetEnumerator(); + /// /// Get a representing the builder. /// /// A representing the builder. public ModalComponent Build() - => new(ActionRows?.Select(x => x.Build()).ToList()); + => new(_components.Select(x => x.Build()).ToList()); + + IEnumerator IEnumerable.GetEnumerator() => ((IEnumerable)_components).GetEnumerator(); + bool ICollection.IsReadOnly => false; } } diff --git a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs index ecc90720fa..0857649247 100644 --- a/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs +++ b/src/Discord.Net.Core/Entities/Interactions/Modals/ModalComponent.cs @@ -10,9 +10,9 @@ public class ModalComponent /// /// Gets the components to be used in a modal. /// - public IReadOnlyCollection Components { get; } + public IReadOnlyCollection Components { get; } - internal ModalComponent(List components) + internal ModalComponent(List components) { Components = components; } diff --git a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs index 85f53af3f5..c244640b45 100644 --- a/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs +++ b/src/Discord.Net.Interactions/Extensions/IDiscordInteractionExtensions.cs @@ -10,6 +10,7 @@ public static class IDiscordInteractionExtentions /// /// Type of the implementation. /// The interaction to respond to. + /// The custom id of the modal. /// Delegate that can be used to modify the modal. /// The request options for this request. /// A task that represents the asynchronous operation of responding to the interaction. @@ -31,10 +32,11 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction /// /// Type of the implementation. /// The interaction to respond to. + /// The custom id of the modal. /// Interaction service instance that should be used to build s. /// The request options for this request. /// Delegate that can be used to modify the modal. - /// + /// A task that represents the asynchronous operation of responding to the interaction. public static Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, InteractionService interactionService, RequestOptions options = null, Action modifyModal = null) where T : class, IModal @@ -50,10 +52,11 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction /// /// Type of the implementation. /// The interaction to respond to. + /// The custom id of the modal. /// The instance to get field values from. /// The request options for this request. /// Delegate that can be used to modify the modal. - /// + /// A task that represents the asynchronous operation of responding to the interaction. public static Task RespondWithModalAsync(this IDiscordInteraction interaction, string customId, T modal, RequestOptions options = null, Action modifyModal = null) where T : class, IModal @@ -81,8 +84,7 @@ public static Task RespondWithModalAsync(this IDiscordInteraction interaction throw new InvalidOperationException($"{input.GetType().FullName} isn't a valid component info class"); } - if (modifyModal is not null) - modifyModal(builder); + modifyModal?.Invoke(builder); return interaction.RespondWithModalAsync(builder.Build(), options); } diff --git a/src/Discord.Net.Rest/API/Common/FileUploadComponent.cs b/src/Discord.Net.Rest/API/Common/FileUploadComponent.cs new file mode 100644 index 0000000000..ed2f71fcc9 --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/FileUploadComponent.cs @@ -0,0 +1,43 @@ +using Newtonsoft.Json; + +namespace Discord.API; + +internal class FileUploadComponent : IInteractableComponent +{ + [JsonProperty("type")] + public ComponentType Type { get; set; } + + [JsonProperty("id")] + public Optional Id { get; set; } + + [JsonProperty("custom_id")] + public string CustomId { get; set; } + + [JsonProperty("min_values")] + public Optional MinValues { get; set; } + + [JsonProperty("max_values")] + public Optional MaxValues { get; set; } + + [JsonProperty("required")] + public Optional IsRequired { get; set; } + + [JsonProperty("values")] + public Optional Values { get; set; } + + public FileUploadComponent() {} + + public FileUploadComponent(Discord.FileUploadComponent component) + { + Type = component.Type; + Id = component.Id ?? Optional.Unspecified; + CustomId = component.CustomId; + MinValues = component.MinValues ?? Optional.Unspecified; + MaxValues = component.MaxValues ?? Optional.Unspecified; + IsRequired = component.IsRequired; + } + + [JsonIgnore] + int? IMessageComponent.Id => Id.ToNullable(); + IMessageComponentBuilder IMessageComponent.ToBuilder() => null; +} diff --git a/src/Discord.Net.Rest/API/Common/LabelComponent.cs b/src/Discord.Net.Rest/API/Common/LabelComponent.cs new file mode 100644 index 0000000000..f27875e23a --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/LabelComponent.cs @@ -0,0 +1,38 @@ +using Discord.Rest; +using Newtonsoft.Json; + +namespace Discord.API; + +internal class LabelComponent : IMessageComponent +{ + [JsonProperty("type")] + public ComponentType Type { get; set; } + + [JsonProperty("id")] + public Optional Id { get; } + + [JsonProperty("label")] + public string Label { get; set; } + + [JsonProperty("description")] + public string Description { get; set; } + + [JsonProperty("component")] + public IMessageComponent Component { get; set; } + + public LabelComponent() {} + + public LabelComponent(Discord.LabelComponent label) + { + Type = label.Type; + Id = label.Id ?? Optional.Unspecified; + Label = label.Label; + Description = label.Description; + Component = label.Component.ToModel(); + } + + public IMessageComponentBuilder ToBuilder() => null; + + [JsonIgnore] + int? IMessageComponent.Id => Id.ToNullable(); +} diff --git a/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs b/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs index 182fa53b22..2f816ee1b2 100644 --- a/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs +++ b/src/Discord.Net.Rest/API/Common/ModalInteractionData.cs @@ -8,6 +8,9 @@ internal class ModalInteractionData : IDiscordInteractionData public string CustomId { get; set; } [JsonProperty("components")] - public API.ActionRowComponent[] Components { get; set; } + public IMessageComponent[] Components { get; set; } + + [JsonProperty("resolved")] + public Optional Resolved { get; set; } } } diff --git a/src/Discord.Net.Rest/API/Common/ModalInteractionDataResolved.cs b/src/Discord.Net.Rest/API/Common/ModalInteractionDataResolved.cs new file mode 100644 index 0000000000..67194e700f --- /dev/null +++ b/src/Discord.Net.Rest/API/Common/ModalInteractionDataResolved.cs @@ -0,0 +1,22 @@ +using Newtonsoft.Json; +using System.Collections.Generic; + +namespace Discord.API; + +internal class ModalInteractionDataResolved +{ + [JsonProperty("users")] + public Optional> Users { get; set; } + + [JsonProperty("members")] + public Optional> Members { get; set; } + + [JsonProperty("roles")] + public Optional> Roles { get; set; } + + [JsonProperty("channels")] + public Optional> Channels { get; set; } + + [JsonProperty("attachments")] + public Optional> Attachments { get; set; } +} diff --git a/src/Discord.Net.Rest/API/Common/TextInputComponent.cs b/src/Discord.Net.Rest/API/Common/TextInputComponent.cs index d76dcd50f6..3e96f26d05 100644 --- a/src/Discord.Net.Rest/API/Common/TextInputComponent.cs +++ b/src/Discord.Net.Rest/API/Common/TextInputComponent.cs @@ -16,8 +16,9 @@ internal class TextInputComponent : IInteractableComponent [JsonProperty("custom_id")] public string CustomId { get; set; } + // deprecated [JsonProperty("label")] - public string Label { get; set; } + public Optional Label { get; set; } [JsonProperty("placeholder")] public Optional Placeholder { get; set; } diff --git a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs index ef16c0f55c..b481a4eb21 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/CommandBase/RestCommandBase.cs @@ -372,7 +372,7 @@ public override string RespondWithModal(Modal modal, RequestOptions options = nu { CustomId = modal.CustomId, Title = modal.Title, - Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + Components = modal.Component.Components.Select(x => x.ToModel()).ToArray() } }; diff --git a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs index 8748ef4a37..52dfad62a4 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponent.cs @@ -508,7 +508,7 @@ public override string RespondWithModal(Modal modal, RequestOptions options = nu { CustomId = modal.CustomId, Title = modal.Title, - Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + Components = modal.Component.Components.Select(x => x.ToModel()).ToArray() } }; diff --git a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs index f72eceecb4..17d1d388b8 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/MessageComponents/RestMessageComponentData.cs @@ -99,7 +99,7 @@ internal RestMessageComponentData(IInteractableComponent component, BaseDiscordC Type = component.Type; if (component is API.TextInputComponent textInput) - Value = textInput.Value.Value; + Value = textInput.Value.GetValueOrDefault(); if (component is API.SelectMenuComponent select) { @@ -129,6 +129,11 @@ internal RestMessageComponentData(IInteractableComponent component, BaseDiscordC : null; } } + + if (component is API.FileUploadComponent fileUpload) + { + Values = fileUpload.Values.GetValueOrDefault(null); + } } } } diff --git a/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs index 1831d4b51a..69fc6ac255 100644 --- a/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs +++ b/src/Discord.Net.Rest/Entities/Interactions/Modals/RestModalData.cs @@ -1,4 +1,5 @@ using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using Model = Discord.API.ModalInteractionData; @@ -17,15 +18,84 @@ public class RestModalData : IModalInteractionData /// public IReadOnlyCollection Components { get; } + /// + public IReadOnlyCollection Channels { get; } + + /// + public IReadOnlyCollection Users { get; } + + /// + public IReadOnlyCollection Roles { get; } + + /// + public IReadOnlyCollection Members { get; } + + /// + public IReadOnlyCollection Attachments { get; } + IReadOnlyCollection IModalInteractionData.Components => Components; + /// + IReadOnlyCollection IModalInteractionData.Channels => Channels; + + /// + IReadOnlyCollection IModalInteractionData.Users => Users; + + /// + IReadOnlyCollection IModalInteractionData.Roles => Roles; + + /// + IReadOnlyCollection IModalInteractionData.Members => Members; + + /// + IReadOnlyCollection IModalInteractionData.Attachments => Attachments; + internal RestModalData(Model model, BaseDiscordClient discord, IGuild guild) { CustomId = model.CustomId; Components = model.Components - .SelectMany(x => x.Components.OfType()) + .SelectMany(c => c switch + { + Discord.API.ActionRowComponent row => row.Components, // Preserve the previous behavior + Discord.API.LabelComponent label => [label.Component], + _ => [c] + }) + .OfType() .Select(x => new RestMessageComponentData(x, discord, guild)) .ToArray(); + + if (model.Resolved.IsSpecified) + { + Users = model.Resolved.Value.Users.IsSpecified + ? model.Resolved.Value.Users.Value.Select(user => RestUser.Create(discord, user.Value)).ToImmutableArray() + : []; + + Members = model.Resolved.Value.Members.IsSpecified + ? model.Resolved.Value.Members.Value.Select(member => + { + member.Value.User = model.Resolved.Value.Users.Value.First(u => u.Key == member.Key).Value; + + return RestGuildUser.Create(discord, guild, member.Value); + }).ToImmutableArray() + : []; + + Channels = model.Resolved.Value.Channels.IsSpecified + ? model.Resolved.Value.Channels.Value.Select(channel => + { + if (channel.Value.Type is ChannelType.DM) + return RestDMChannel.Create(discord, channel.Value); + return RestChannel.Create(discord, channel.Value); + }).ToImmutableArray() + : []; + + Roles = model.Resolved.Value.Roles.IsSpecified + ? model.Resolved.Value.Roles.Value.Select(role => RestRole.Create(discord, guild, role.Value)).ToImmutableArray() + : []; + + Attachments = model.Resolved.Value.Attachments.IsSpecified + ? model.Resolved.Value.Attachments.Value.Select(attachment => Attachment.Create(attachment.Value, discord)).ToImmutableArray() + : []; + } } } } diff --git a/src/Discord.Net.Rest/Extensions/MessageComponentExtension.cs b/src/Discord.Net.Rest/Extensions/MessageComponentExtension.cs index 96893f7e4b..a65533b0ed 100644 --- a/src/Discord.Net.Rest/Extensions/MessageComponentExtension.cs +++ b/src/Discord.Net.Rest/Extensions/MessageComponentExtension.cs @@ -41,6 +41,12 @@ internal static IMessageComponent ToModel(this IMessageComponent component) case ContainerComponent container: return new API.ContainerComponent(container); + + case LabelComponent label: + return new API.LabelComponent(label); + + case FileUploadComponent fileUpload: + return new API.FileUploadComponent(fileUpload); } return null; @@ -110,7 +116,7 @@ internal static IMessageComponent ToEntity(this IMessageComponent component) { var parsed = (API.TextInputComponent)component; return new TextInputComponent(parsed.CustomId, - parsed.Label, + parsed.Label.GetValueOrDefault(), parsed.Placeholder.GetValueOrDefault(null), parsed.MinLength.ToNullable(), parsed.MaxLength.ToNullable(), @@ -173,6 +179,22 @@ internal static IMessageComponent ToEntity(this IMessageComponent component) parsed.Id.ToNullable()); } + case ComponentType.Label: + { + var parsed = (API.LabelComponent)component; + return new LabelComponent(parsed.Id.ToNullable(), parsed.Label, parsed.Description, parsed.Component.ToEntity()); + } + + case ComponentType.FileUpload: + { + var parsed = (API.FileUploadComponent)component; + return new FileUploadComponent(parsed.Id.ToNullable(), + parsed.CustomId, + parsed.MaxValues.ToNullable(), + parsed.MaxValues.ToNullable(), + parsed.IsRequired.GetValueOrDefault(false)); + } + default: return null; } diff --git a/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs b/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs index f72ce4d11a..22d34d41fb 100644 --- a/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs +++ b/src/Discord.Net.Rest/Net/Converters/MessageComponentConverter.cs @@ -62,6 +62,12 @@ public override object ReadJson(JsonReader reader, Type objectType, object exist case ComponentType.Container: messageComponent = new API.ContainerComponent(); break; + case ComponentType.Label: + messageComponent = new API.LabelComponent(); + break; + case ComponentType.FileUpload: + messageComponent = new API.FileUploadComponent(); + break; default: throw new JsonSerializationException($"Unknown component type value '{typeProperty}' while deserializing message component"); } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs index 984c898454..957accca24 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponent.cs @@ -504,7 +504,7 @@ public override async Task RespondWithModalAsync(Modal modal, RequestOptions opt { CustomId = modal.CustomId, Title = modal.Title, - Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + Components = modal.Component.Components.Select(x => x.ToModel()).ToArray() } }; diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs index 718cc3b130..5dff8dd70a 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/MessageComponents/SocketMessageComponentData.cs @@ -1,6 +1,4 @@ using Discord.Rest; -using Discord.Utils; -using System; using System.Collections.Generic; using System.Collections.Immutable; using System.Linq; @@ -95,9 +93,8 @@ internal SocketMessageComponentData(IInteractableComponent component, DiscordSoc CustomId = component.CustomId; Type = component.Type; - Value = component.Type == ComponentType.TextInput - ? ((TextInputComponent)component).Value - : null; + if (component is API.TextInputComponent textInput) + Value = textInput.Value.GetValueOrDefault(); if (component is API.SelectMenuComponent select) { @@ -132,6 +129,11 @@ internal SocketMessageComponentData(IInteractableComponent component, DiscordSoc : null; } } + + if (component is API.FileUploadComponent fileUpload) + { + Values = fileUpload.Values.GetValueOrDefault(null); + } } } } diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs index a778766677..d0f7444a81 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/Modals/SocketModalData.cs @@ -1,5 +1,6 @@ using Discord.Rest; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using Model = Discord.API.ModalInteractionData; @@ -20,13 +21,83 @@ public class SocketModalData : IModalInteractionData /// public IReadOnlyCollection Components { get; } + /// + public IReadOnlyCollection Channels { get; } + + /// + /// Returns if user is cached, otherwise. + public IReadOnlyCollection Users { get; } + + /// + public IReadOnlyCollection Roles { get; } + + /// + public IReadOnlyCollection Members { get; } + + /// + public IReadOnlyCollection Attachments { get; } + + /// + IReadOnlyCollection IModalInteractionData.Channels => Channels; + + /// + IReadOnlyCollection IModalInteractionData.Users => Users; + + /// + IReadOnlyCollection IModalInteractionData.Roles => Roles; + + /// + IReadOnlyCollection IModalInteractionData.Members => Members; + + /// + IReadOnlyCollection IModalInteractionData.Attachments => Attachments; + internal SocketModalData(Model model, DiscordSocketClient discord, ClientState state, SocketGuild guild, API.User dmUser) { CustomId = model.CustomId; Components = model.Components - .SelectMany(x => x.Components.Select(y => y.ToEntity()).OfType()) + .SelectMany(c => c switch + { + Discord.API.ActionRowComponent row => row.Components, // Preserve the previous behavior + Discord.API.LabelComponent label => [label.Component], + _ => [c] + }) + .OfType() .Select(x => new SocketMessageComponentData(x, discord, state, guild, dmUser)) .ToArray(); + + if (model.Resolved.IsSpecified) + { + Users = model.Resolved.Value.Users.IsSpecified + ? model.Resolved.Value.Users.Value.Select(user => (IUser)state.GetUser(user.Value.Id) ?? RestUser.Create(discord, user.Value)).ToImmutableArray() + : []; + + Members = model.Resolved.Value.Members.IsSpecified + ? model.Resolved.Value.Members.Value.Select(member => + { + member.Value.User = model.Resolved.Value.Users.Value.First(u => u.Key == member.Key).Value; + return SocketGuildUser.Create(guild, state, member.Value); + }).ToImmutableArray() + : []; + + Channels = model.Resolved.Value.Channels.IsSpecified + ? model.Resolved.Value.Channels.Value.Select( + channel => + { + if (channel.Value.Type is ChannelType.DM) + return SocketDMChannel.Create(discord, state, channel.Value.Id, dmUser); + return (SocketChannel)SocketGuildChannel.Create(guild, state, channel.Value); + }).ToImmutableArray() + : []; + + Roles = model.Resolved.Value.Roles.IsSpecified + ? model.Resolved.Value.Roles.Value.Select(role => SocketRole.Create(guild, state, role.Value)).ToImmutableArray() + : []; + + Attachments = model.Resolved.Value.Attachments.IsSpecified + ? model.Resolved.Value.Attachments.Value.Select(attachment => Attachment.Create(attachment.Value, discord)).ToImmutableArray() + : []; + } } IReadOnlyCollection IModalInteractionData.Components => Components; diff --git a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs index 075e4e31fa..914df8ac33 100644 --- a/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs +++ b/src/Discord.Net.WebSocket/Entities/Interaction/SocketBaseCommand/SocketCommandBase.cs @@ -161,7 +161,7 @@ public override async Task RespondWithModalAsync(Modal modal, RequestOptions opt { CustomId = modal.CustomId, Title = modal.Title, - Components = modal.Component.Components.Select(x => new Discord.API.ActionRowComponent(x)).ToArray() + Components = modal.Component.Components.Select(x => x.ToModel()).ToArray() } };