From a39cb41ac607e88f276913c279d566763da19e75 Mon Sep 17 00:00:00 2001 From: Michael Krasnitski Date: Tue, 7 Oct 2025 19:18:48 -0400 Subject: [PATCH 1/4] Add support for label components --- src/builder/create_components.rs | 73 +++++++++++++----- src/builder/create_interaction_response.rs | 5 +- src/collector/quick_modal.rs | 72 +++++++++-------- src/model/application/component.rs | 90 ++++++++++++++++------ src/model/application/modal_interaction.rs | 2 +- 5 files changed, 162 insertions(+), 80 deletions(-) diff --git a/src/builder/create_components.rs b/src/builder/create_components.rs index 636068399f7..2780830d248 100644 --- a/src/builder/create_components.rs +++ b/src/builder/create_components.rs @@ -21,8 +21,6 @@ impl Serialize for StaticU8 { pub enum CreateActionRow<'a> { Buttons(Cow<'a, [CreateButton<'a>]>), SelectMenu(CreateSelectMenu<'a>), - /// Only valid in modals! - InputText(CreateInputText<'a>), } impl<'a> CreateActionRow<'a> { @@ -33,10 +31,6 @@ impl<'a> CreateActionRow<'a> { pub fn select_menu(select_menu: impl Into>) -> Self { Self::SelectMenu(select_menu.into()) } - - pub fn input_text(input_text: impl Into>) -> Self { - Self::InputText(input_text.into()) - } } impl serde::Serialize for CreateActionRow<'_> { @@ -49,7 +43,6 @@ impl serde::Serialize for CreateActionRow<'_> { match self { CreateActionRow::Buttons(buttons) => map.serialize_entry("components", &buttons)?, CreateActionRow::SelectMenu(select) => map.serialize_entry("components", &[select])?, - CreateActionRow::InputText(input) => map.serialize_entry("components", &[input])?, } map.end() @@ -105,6 +98,10 @@ pub enum CreateComponent<'a> { /// /// A container is a flexible component that can hold multiple nested components. Container(CreateContainer<'a>), + /// Represents a label component (V2). + /// + /// A label is used to hold other components in a modal. + Label(CreateLabel<'a>), } /// A builder to create a section component, supports up to a max of **3** components with an @@ -501,6 +498,54 @@ impl<'a> CreateContainer<'a> { } } +/// A builder for creating a label that can hold an [`InputText`] or [`SelectMenu`]. +#[derive(Clone, Debug, Serialize)] +#[must_use] +pub struct CreateLabel<'a> { + #[serde(rename = "type")] + kind: StaticU8<18>, + label: Cow<'a, str>, + description: Option>, + component: CreateLabelComponent<'a>, +} + +impl<'a> CreateLabel<'a> { + /// Create a select menu with a specific label. + pub fn select_menu(label: impl Into>, select_menu: CreateSelectMenu<'a>) -> Self { + Self { + kind: StaticU8::<18>, + label: label.into(), + description: None, + component: CreateLabelComponent::SelectMenu(select_menu), + } + } + + /// Create a text input with a specific label. + pub fn input_text(label: impl Into>, input_text: CreateInputText<'a>) -> Self { + Self { + kind: StaticU8::<18>, + label: label.into(), + description: None, + component: CreateLabelComponent::InputText(input_text), + } + } + + /// Sets the description of this component, which will display underneath the label text. + pub fn description(mut self, description: impl Into>) -> Self { + self.description = Some(description.into()); + self + } +} + +/// An enum of all valid label components. +#[derive(Clone, Debug, Serialize)] +#[must_use] +#[serde(untagged)] +enum CreateLabelComponent<'a> { + SelectMenu(CreateSelectMenu<'a>), + InputText(CreateInputText<'a>), +} + enum_number! { #[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd, Deserialize, Serialize)] #[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] @@ -893,7 +938,6 @@ pub struct CreateInputText<'a> { kind: ComponentType, custom_id: Cow<'a, str>, style: InputTextStyle, - label: Option>, min_length: Option, max_length: Option, required: bool, @@ -906,14 +950,9 @@ pub struct CreateInputText<'a> { impl<'a> CreateInputText<'a> { /// Creates a text input with the given style, label, and custom id (a developer-defined /// identifier), leaving all other fields empty. - pub fn new( - style: InputTextStyle, - label: impl Into>, - custom_id: impl Into>, - ) -> Self { + pub fn new(style: InputTextStyle, custom_id: impl Into>) -> Self { Self { style, - label: Some(label.into()), custom_id: custom_id.into(), placeholder: None, @@ -932,12 +971,6 @@ impl<'a> CreateInputText<'a> { self } - /// Sets the label of this input text. Replaces the current value as set in [`Self::new`]. - pub fn label(mut self, label: impl Into>) -> Self { - self.label = Some(label.into()); - self - } - /// Sets the custom id of the input text, a developer-defined identifier. Replaces the current /// value as set in [`Self::new`]. pub fn custom_id(mut self, id: impl Into>) -> Self { diff --git a/src/builder/create_interaction_response.rs b/src/builder/create_interaction_response.rs index 77a46af196e..f1c86a9182e 100644 --- a/src/builder/create_interaction_response.rs +++ b/src/builder/create_interaction_response.rs @@ -3,7 +3,6 @@ use std::collections::HashMap; use super::create_poll::Ready; use super::{ - CreateActionRow, CreateAllowedMentions, CreateAttachment, CreateComponent, @@ -424,7 +423,7 @@ impl<'a> CreateAutocompleteResponse<'a> { #[derive(Clone, Debug, Default, Serialize)] #[must_use] pub struct CreateModal<'a> { - components: Cow<'a, [CreateActionRow<'a>]>, + components: Cow<'a, [CreateComponent<'a>]>, custom_id: Cow<'a, str>, title: Cow<'a, str>, } @@ -442,7 +441,7 @@ impl<'a> CreateModal<'a> { /// Sets the components of this message. /// /// Overwrites existing components. - pub fn components(mut self, components: impl Into]>>) -> Self { + pub fn components(mut self, components: impl Into]>>) -> Self { self.components = components.into(); self } diff --git a/src/collector/quick_modal.rs b/src/collector/quick_modal.rs index d4d0c40d74f..395ec183bfb 100644 --- a/src/collector/quick_modal.rs +++ b/src/collector/quick_modal.rs @@ -1,6 +1,13 @@ use std::borrow::Cow; -use crate::builder::{CreateActionRow, CreateInputText, CreateInteractionResponse, CreateModal}; +use crate::builder::{ + CreateComponent, + CreateInputText, + CreateInteractionResponse, + CreateLabel, + CreateModal, + CreateTextDisplay, +}; use crate::collector::ModalInteractionCollector; use crate::gateway::client::Context; use crate::internal::prelude::*; @@ -31,7 +38,7 @@ pub struct QuickModalResponse { pub struct CreateQuickModal<'a> { title: Cow<'a, str>, timeout: Option, - input_texts: Vec>, + components: Vec>, } impl<'a> CreateQuickModal<'a> { @@ -39,7 +46,7 @@ impl<'a> CreateQuickModal<'a> { Self { title: title.into(), timeout: None, - input_texts: Vec::new(), + components: Vec::new(), } } @@ -52,12 +59,21 @@ impl<'a> CreateQuickModal<'a> { self } + /// Adds a text display field. + pub fn text(mut self, content: impl Into>) -> Self { + self.components.push(CreateComponent::TextDisplay(CreateTextDisplay::new(content))); + self + } + /// Adds an input text field. - /// - /// As the `custom_id` field of [`CreateInputText`], just supply an empty string. All custom - /// IDs are overwritten by [`CreateQuickModal`] when sending the modal. - pub fn field(mut self, input_text: CreateInputText<'a>) -> Self { - self.input_texts.push(input_text); + pub fn field( + mut self, + label: impl Into>, + input_text: CreateInputText<'a>, + ) -> Self { + self.components.push(CreateComponent::Label( + CreateLabel::input_text(label, input_text).description("test"), + )); self } @@ -65,14 +81,18 @@ impl<'a> CreateQuickModal<'a> { /// /// Wraps [`Self::field`]. pub fn short_field(self, label: impl Into>) -> Self { - self.field(CreateInputText::new(InputTextStyle::Short, label, "")) + let input_text = + CreateInputText::new(InputTextStyle::Short, self.components.len().to_string()); + self.field(label, input_text) } /// Convenience method to add a multi-line input text field. /// /// Wraps [`Self::field`]. pub fn paragraph_field(self, label: impl Into>) -> Self { - self.field(CreateInputText::new(InputTextStyle::Paragraph, label, "")) + let input_text = + CreateInputText::new(InputTextStyle::Paragraph, self.components.len().to_string()); + self.field(label, input_text) } /// # Errors @@ -84,22 +104,13 @@ impl<'a> CreateQuickModal<'a> { interaction_id: InteractionId, token: &str, ) -> Result, crate::Error> { - let modal_custom_id = interaction_id.to_arraystring(); let builder = CreateInteractionResponse::Modal( - CreateModal::new(modal_custom_id.as_str(), self.title).components( - self.input_texts - .into_iter() - .enumerate() - .map(|(i, input_text)| { - CreateActionRow::InputText(input_text.custom_id(i.to_string())) - }) - .collect::>(), - ), + CreateModal::new(interaction_id.to_string(), self.title).components(self.components), ); builder.execute(&ctx.http, interaction_id, token).await?; let collector = ModalInteractionCollector::new(ctx) - .custom_ids(vec![FixedString::from_str_trunc(&modal_custom_id)]); + .custom_ids(vec![FixedString::from_str_trunc(&interaction_id.to_string())]); let collector = match self.timeout { Some(timeout) => collector.timeout(timeout), @@ -114,23 +125,22 @@ impl<'a> CreateQuickModal<'a> { .data .components .iter() - .filter_map(|row| match row.components.first() { - Some(ActionRowComponent::InputText(text)) => { + .filter_map(|component| { + if let Component::Label(label) = component + && let LabelComponent::InputText(text) = &label.component + { if let Some(value) = &text.value { Some(value.clone()) } else { tracing::warn!("input text value was empty in modal response"); None } - }, - Some(other) => { - tracing::warn!("expected input text in modal response, got {:?}", other); - None - }, - None => { - tracing::warn!("empty action row"); + } else { + if !matches!(component, Component::TextDisplay(_)) { + tracing::warn!("expected input text in modal response, got {component:?}"); + } None - }, + } }) .collect(); diff --git a/src/model/application/component.rs b/src/model/application/component.rs index fbb1c9aeabf..b5303622b87 100644 --- a/src/model/application/component.rs +++ b/src/model/application/component.rs @@ -27,6 +27,7 @@ enum_number! { File = 13, Separator = 14, Container = 17, + Label = 18, _ => Unknown(u8), } } @@ -53,11 +54,12 @@ pub enum Component { Separator(Separator), File(FileComponent), Container(Container), + Label(Label), Unknown(u8), } impl<'de> Deserialize<'de> for Component { - fn deserialize(deserializer: D) -> std::result::Result + fn deserialize(deserializer: D) -> Result where D: Deserializer<'de>, { @@ -93,6 +95,7 @@ impl<'de> Deserialize<'de> for Component { ComponentType::File => Deserialize::deserialize(value).map(Component::File), ComponentType::Container => Deserialize::deserialize(value).map(Component::Container), ComponentType::Thumbnail => Deserialize::deserialize(value).map(Component::Thumbnail), + ComponentType::Label => Deserialize::deserialize(value).map(Component::Label), ComponentType(i) => Ok(Component::Unknown(i)), } .map_err(DeError::custom) @@ -272,6 +275,62 @@ pub struct Container { pub components: FixedArray, } +/// A layout component that wraps modal components with a label and optional description. +/// +/// **Note**: Labels can only appear within modals, and will not include the `label` or +/// `description` field when part of a modal response. +/// +/// [Discord docs](https://discord.com/developers/docs/components/reference#label-label-interaction-response-structure) +#[derive(Clone, Debug, Deserialize, Serialize)] +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[non_exhaustive] +pub struct Label { + /// Always [`ComponentType::Label`] + #[serde(rename = "type")] + pub kind: ComponentType, + /// The component within the label. + pub component: LabelComponent, +} + +#[derive(Clone, Debug, Serialize)] +#[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] +#[serde(untagged)] +#[non_exhaustive] +pub enum LabelComponent { + SelectMenu(SelectMenu), + InputText(InputText), +} + +impl<'de> Deserialize<'de> for LabelComponent { + fn deserialize>(deserializer: D) -> Result { + #[derive(Deserialize)] + struct LabelComponentRaw { + #[serde(rename = "type")] + kind: ComponentType, + } + + let raw_data = <&RawValue>::deserialize(deserializer)?; + let raw = LabelComponentRaw::deserialize(raw_data).map_err(DeError::custom)?; + + match raw.kind { + ComponentType::StringSelect + | ComponentType::UserSelect + | ComponentType::RoleSelect + | ComponentType::MentionableSelect + | ComponentType::ChannelSelect => { + Deserialize::deserialize(raw_data).map(LabelComponent::SelectMenu) + }, + ComponentType::InputText => { + Deserialize::deserialize(raw_data).map(LabelComponent::InputText) + }, + ComponentType(i) => { + return Err(DeError::custom(format_args!("Unknown component type {i}"))); + }, + } + .map_err(DeError::custom) + } +} + /// An action row. /// /// [Discord docs](https://discord.com/developers/docs/interactions/message-components#action-rows). @@ -291,16 +350,16 @@ pub struct ActionRow { /// /// [Discord docs](https://discord.com/developers/docs/interactions/message-components#component-object-component-types). #[cfg_attr(feature = "typesize", derive(typesize::derive::TypeSize))] -#[derive(Clone, Debug)] +#[derive(Clone, Debug, Serialize)] +#[serde(untagged)] #[non_exhaustive] pub enum ActionRowComponent { Button(Button), SelectMenu(SelectMenu), - InputText(InputText), } impl<'de> Deserialize<'de> for ActionRowComponent { - fn deserialize>(deserializer: D) -> std::result::Result { + fn deserialize>(deserializer: D) -> Result { #[derive(Deserialize)] struct ActionRowRaw { #[serde(rename = "type")] @@ -314,9 +373,6 @@ impl<'de> Deserialize<'de> for ActionRowComponent { ComponentType::Button => { Deserialize::deserialize(raw_data).map(ActionRowComponent::Button) }, - ComponentType::InputText => { - Deserialize::deserialize(raw_data).map(ActionRowComponent::InputText) - }, ComponentType::StringSelect | ComponentType::UserSelect | ComponentType::RoleSelect @@ -335,16 +391,6 @@ impl<'de> Deserialize<'de> for ActionRowComponent { } } -impl Serialize for ActionRowComponent { - fn serialize(&self, serializer: S) -> std::result::Result { - match self { - Self::Button(c) => c.serialize(serializer), - Self::InputText(c) => c.serialize(serializer), - Self::SelectMenu(c) => c.serialize(serializer), - } - } -} - impl From