From 1c6ec8f2db5e1b8fa3bfda4451d43883eb67381c Mon Sep 17 00:00:00 2001 From: Archer Date: Mon, 12 Jun 2023 18:00:00 +0200 Subject: [PATCH 1/7] feat(twilight-model)!: Implement additional select menu types This patch implements the additional select menu types `user`, `role`, `mentionable`, and `channel`. Moreover, it moves the type-specific data for the existing text select menu implementation into a separate enum variant. The new types are implemented by "semi-flattening" the `SelectMenu` struct: fields common to all select menu types are implemented in the struct itself, and type-specific fields like the available options or channel types are moved into a `SelectMenuData` enum. This enum is also used to select a select menu's type. This approach de-duplicates the common fields while preventing users from accessing fields irrelevant to the current select menu type. Finally, this commit updates the documentation and existing tests to reflect these changes. The tests, however, might require additional attention in case new behaviour introduced by this commit should be tested as well. --- .../interaction/message_component.rs | 4 +- .../src/channel/message/component/kind.rs | 50 ++++- .../src/channel/message/component/mod.rs | 206 ++++++++++++------ .../channel/message/component/select_menu.rs | 94 +++++++- twilight-validate/src/component.rs | 45 ++-- 5 files changed, 302 insertions(+), 97 deletions(-) diff --git a/twilight-model/src/application/interaction/message_component.rs b/twilight-model/src/application/interaction/message_component.rs index 24427251e18..eb539454e33 100644 --- a/twilight-model/src/application/interaction/message_component.rs +++ b/twilight-model/src/application/interaction/message_component.rs @@ -5,7 +5,7 @@ use crate::channel::message::component::ComponentType; use serde::{Deserialize, Serialize}; -/// Data received when an [`MessageComponent`] interaction is executed. +/// Data received when a [`MessageComponent`] interaction is executed. /// /// See [Discord Docs/Message Component Data Structure]. /// @@ -25,7 +25,7 @@ pub struct MessageComponentInteractionData { /// /// Only used for [`SelectMenu`] components. /// - /// [`SelectMenu`]: ComponentType::SelectMenu + /// [`SelectMenu`]: crate::channel::message::component::SelectMenu #[serde(default)] pub values: Vec, } diff --git a/twilight-model/src/channel/message/component/kind.rs b/twilight-model/src/channel/message/component/kind.rs index 4d1b2852aac..4411c606bc6 100644 --- a/twilight-model/src/channel/message/component/kind.rs +++ b/twilight-model/src/channel/message/component/kind.rs @@ -16,14 +16,30 @@ pub enum ComponentType { /// /// [`Button`]: super::Button Button, - /// Component is an [`SelectMenu`]. + /// Component is a [`SelectMenu`] with custom string options. /// /// [`SelectMenu`]: super::SelectMenu - SelectMenu, + TextSelectMenu, /// Component is an [`TextInput`]. /// /// [`TextInput`]: super::TextInput TextInput, + /// Component is a [`SelectMenu`] for users. + /// + /// [`SelectMenu`]: super::SelectMenu + UserSelectMenu, + /// Component is a [`SelectMenu`] for roles. + /// + /// [`SelectMenu`]: super::SelectMenu + RoleSelectMenu, + /// Component is a [`SelectMenu`] for mentionables. + /// + /// [`SelectMenu`]: super::SelectMenu + MentionableSelectMenu, + /// Component is a [`SelectMenu`] for channels. + /// + /// [`SelectMenu`]: super::SelectMenu + ChannelSelectMenu, /// Variant value is unknown to the library. Unknown(u8), } @@ -33,8 +49,12 @@ impl From for ComponentType { match value { 1 => ComponentType::ActionRow, 2 => ComponentType::Button, - 3 => ComponentType::SelectMenu, + 3 => ComponentType::TextSelectMenu, 4 => ComponentType::TextInput, + 5 => ComponentType::UserSelectMenu, + 6 => ComponentType::RoleSelectMenu, + 7 => ComponentType::MentionableSelectMenu, + 8 => ComponentType::ChannelSelectMenu, unknown => ComponentType::Unknown(unknown), } } @@ -45,8 +65,12 @@ impl From for u8 { match value { ComponentType::ActionRow => 1, ComponentType::Button => 2, - ComponentType::SelectMenu => 3, + ComponentType::TextSelectMenu => 3, ComponentType::TextInput => 4, + ComponentType::UserSelectMenu => 5, + ComponentType::RoleSelectMenu => 6, + ComponentType::MentionableSelectMenu => 7, + ComponentType::ChannelSelectMenu => 8, ComponentType::Unknown(unknown) => unknown, } } @@ -72,7 +96,11 @@ impl ComponentType { match self { Self::ActionRow => "ActionRow", Self::Button => "Button", - Self::SelectMenu => "SelectMenu", + Self::TextSelectMenu + | Self::UserSelectMenu + | Self::RoleSelectMenu + | Self::MentionableSelectMenu + | Self::ChannelSelectMenu => "SelectMenu", Self::TextInput => "TextInput", Self::Unknown(_) => "Unknown", } @@ -110,8 +138,12 @@ mod tests { fn variants() { serde_test::assert_tokens(&ComponentType::ActionRow, &[Token::U8(1)]); serde_test::assert_tokens(&ComponentType::Button, &[Token::U8(2)]); - serde_test::assert_tokens(&ComponentType::SelectMenu, &[Token::U8(3)]); + serde_test::assert_tokens(&ComponentType::TextSelectMenu, &[Token::U8(3)]); serde_test::assert_tokens(&ComponentType::TextInput, &[Token::U8(4)]); + serde_test::assert_tokens(&ComponentType::UserSelectMenu, &[Token::U8(5)]); + serde_test::assert_tokens(&ComponentType::RoleSelectMenu, &[Token::U8(6)]); + serde_test::assert_tokens(&ComponentType::MentionableSelectMenu, &[Token::U8(7)]); + serde_test::assert_tokens(&ComponentType::ChannelSelectMenu, &[Token::U8(8)]); serde_test::assert_tokens(&ComponentType::Unknown(99), &[Token::U8(99)]); } @@ -119,7 +151,11 @@ mod tests { fn names() { assert_eq!("ActionRow", ComponentType::ActionRow.name()); assert_eq!("Button", ComponentType::Button.name()); - assert_eq!("SelectMenu", ComponentType::SelectMenu.name()); + assert_eq!("SelectMenu", ComponentType::TextSelectMenu.name()); + assert_eq!("SelectMenu", ComponentType::UserSelectMenu.name()); + assert_eq!("SelectMenu", ComponentType::RoleSelectMenu.name()); + assert_eq!("SelectMenu", ComponentType::MentionableSelectMenu.name()); + assert_eq!("SelectMenu", ComponentType::ChannelSelectMenu.name()); assert_eq!("TextInput", ComponentType::TextInput.name()); assert_eq!("Unknown", ComponentType::Unknown(99).name()); } diff --git a/twilight-model/src/channel/message/component/mod.rs b/twilight-model/src/channel/message/component/mod.rs index c97b6455347..b53477912fa 100644 --- a/twilight-model/src/channel/message/component/mod.rs +++ b/twilight-model/src/channel/message/component/mod.rs @@ -15,11 +15,14 @@ pub use self::{ action_row::ActionRow, button::{Button, ButtonStyle}, kind::ComponentType, - select_menu::{SelectMenu, SelectMenuOption}, + select_menu::{ + ChannelSelectMenuData, SelectMenu, SelectMenuData, SelectMenuOption, TextSelectMenuData, + }, text_input::{TextInput, TextInputStyle}, }; use super::ReactionType; +use crate::channel::ChannelType; use serde::{ de::{Deserializer, Error as DeError, IgnoredAny, MapAccess, Visitor}, ser::SerializeStruct, @@ -56,7 +59,10 @@ use std::fmt::{Formatter, Result as FmtResult}; /// ``` /// use twilight_model::{ /// channel::message::{ -/// component::{ActionRow, Component, SelectMenu, SelectMenuOption}, +/// component::{ +/// ActionRow, Component, SelectMenu, SelectMenuData, SelectMenuOption, +/// TextSelectMenuData, +/// }, /// ReactionType, /// }, /// id::Id, @@ -68,41 +74,45 @@ use std::fmt::{Formatter, Result as FmtResult}; /// disabled: false, /// max_values: Some(3), /// min_values: Some(1), -/// options: Vec::from([ -/// SelectMenuOption { -/// default: false, -/// emoji: Some(ReactionType::Custom { -/// animated: false, -/// id: Id::new(625891304148303894), -/// name: Some("rogue".to_owned()), -/// }), -/// description: Some("Sneak n stab".to_owned()), -/// label: "Rogue".to_owned(), -/// value: "rogue".to_owned(), -/// }, -/// SelectMenuOption { -/// default: false, -/// emoji: Some(ReactionType::Custom { -/// animated: false, -/// id: Id::new(625891304081063986), -/// name: Some("mage".to_owned()), -/// }), -/// description: Some("Turn 'em into a sheep".to_owned()), -/// label: "Mage".to_owned(), -/// value: "mage".to_owned(), -/// }, -/// SelectMenuOption { -/// default: false, -/// emoji: Some(ReactionType::Custom { -/// animated: false, -/// id: Id::new(625891303795982337), -/// name: Some("priest".to_owned()), -/// }), -/// description: Some("You get heals when I'm done doing damage".to_owned()), -/// label: "Priest".to_owned(), -/// value: "priest".to_owned(), -/// }, -/// ]), +/// data: SelectMenuData::Text(Box::new(TextSelectMenuData { +/// options: Vec::from([ +/// SelectMenuOption { +/// default: false, +/// emoji: Some(ReactionType::Custom { +/// animated: false, +/// id: Id::new(625891304148303894), +/// name: Some("rogue".to_owned()), +/// }), +/// description: Some("Sneak n stab".to_owned()), +/// label: "Rogue".to_owned(), +/// value: "rogue".to_owned(), +/// }, +/// SelectMenuOption { +/// default: false, +/// emoji: Some(ReactionType::Custom { +/// animated: false, +/// id: Id::new(625891304081063986), +/// name: Some("mage".to_owned()), +/// }), +/// description: Some("Turn 'em into a sheep".to_owned()), +/// label: "Mage".to_owned(), +/// value: "mage".to_owned(), +/// }, +/// SelectMenuOption { +/// default: false, +/// emoji: Some(ReactionType::Custom { +/// animated: false, +/// id: Id::new(625891303795982337), +/// name: Some("priest".to_owned()), +/// }), +/// description: Some( +/// "You get heals when I'm done doing damage".to_owned(), +/// ), +/// label: "Priest".to_owned(), +/// value: "priest".to_owned(), +/// }, +/// ]), +/// })), /// placeholder: Some("Choose a class".to_owned()), /// })], /// }); @@ -144,7 +154,26 @@ impl Component { match self { Self::ActionRow(_) => ComponentType::ActionRow, Self::Button(_) => ComponentType::Button, - Self::SelectMenu(_) => ComponentType::SelectMenu, + Self::SelectMenu(SelectMenu { + data: SelectMenuData::Text(_), + .. + }) => ComponentType::TextSelectMenu, + Self::SelectMenu(SelectMenu { + data: SelectMenuData::User, + .. + }) => ComponentType::UserSelectMenu, + Self::SelectMenu(SelectMenu { + data: SelectMenuData::Role, + .. + }) => ComponentType::RoleSelectMenu, + Self::SelectMenu(SelectMenu { + data: SelectMenuData::Mentionable, + .. + }) => ComponentType::MentionableSelectMenu, + Self::SelectMenu(SelectMenu { + data: SelectMenuData::Channel(_), + .. + }) => ComponentType::ChannelSelectMenu, Self::TextInput(_) => ComponentType::TextInput, Component::Unknown(unknown) => ComponentType::Unknown(*unknown), } @@ -184,6 +213,7 @@ impl<'de> Deserialize<'de> for Component { #[derive(Debug, Deserialize)] #[serde(field_identifier, rename_all = "snake_case")] enum Field { + ChannelTypes, Components, CustomId, Disabled, @@ -217,6 +247,7 @@ impl<'de> Visitor<'de> for ComponentVisitor { let mut components: Option> = None; let mut kind: Option = None; let mut options: Option> = None; + let mut channel_types: Option> = None; let mut style: Option = None; // Liminal fields. @@ -260,6 +291,13 @@ impl<'de> Visitor<'de> for ComponentVisitor { }; match key { + Field::ChannelTypes => { + if channel_types.is_some() { + return Err(DeError::duplicate_field("channel_types")); + } + + channel_types = Some(map.next_value()?); + } Field::Components => { if components.is_some() { return Err(DeError::duplicate_field("components")); @@ -436,28 +474,47 @@ impl<'de> Visitor<'de> for ComponentVisitor { } // Required fields: // - custom_id - // - options + // - options (if this is a text select menu) // // Optional fields: // - disabled // - max_values // - min_values // - placeholder - ComponentType::SelectMenu => { + // - channel_types (if this is a channel select menu) + kind @ (ComponentType::TextSelectMenu + | ComponentType::UserSelectMenu + | ComponentType::RoleSelectMenu + | ComponentType::MentionableSelectMenu + | ComponentType::ChannelSelectMenu) => { let custom_id = custom_id .flatten() .ok_or_else(|| DeError::missing_field("custom_id"))? .deserialize_into() .map_err(DeserializerError::into_error)?; - let options = options.ok_or_else(|| DeError::missing_field("options"))?; + let data = match kind { + ComponentType::TextSelectMenu => { + let options = options.ok_or_else(|| DeError::missing_field("options"))?; + SelectMenuData::Text(Box::new(TextSelectMenuData { options })) + } + ComponentType::UserSelectMenu => SelectMenuData::User, + ComponentType::RoleSelectMenu => SelectMenuData::Role, + ComponentType::MentionableSelectMenu => SelectMenuData::Mentionable, + ComponentType::ChannelSelectMenu => { + SelectMenuData::Channel(Box::new(ChannelSelectMenuData { channel_types })) + } + // We'll only take the branch below if we added a type above and forgot to implement it here. I.e., + // we should never end up here. + _ => panic!("missing select menu implementation"), + }; Self::Value::SelectMenu(SelectMenu { custom_id, + data, disabled: disabled.unwrap_or_default(), max_values: max_values.unwrap_or_default(), min_values: min_values.unwrap_or_default(), - options, placeholder: placeholder.unwrap_or_default(), }) } @@ -505,6 +562,7 @@ impl<'de> Visitor<'de> for ComponentVisitor { } impl Serialize for Component { + #[allow(clippy::too_many_lines)] fn serialize(&self, serializer: S) -> Result { let len = match self { // Required fields: @@ -602,7 +660,27 @@ impl Serialize for Component { } } Component::SelectMenu(select_menu) => { - state.serialize_field("type", &ComponentType::SelectMenu)?; + match &select_menu.data { + SelectMenuData::Text(data) => { + state.serialize_field("type", &ComponentType::TextSelectMenu)?; + state.serialize_field("options", &data.options)?; + } + SelectMenuData::User => { + state.serialize_field("type", &ComponentType::UserSelectMenu)?; + } + SelectMenuData::Role => { + state.serialize_field("type", &ComponentType::RoleSelectMenu)?; + } + SelectMenuData::Mentionable => { + state.serialize_field("type", &ComponentType::MentionableSelectMenu)?; + } + SelectMenuData::Channel(data) => { + state.serialize_field("type", &ComponentType::ChannelSelectMenu)?; + if data.channel_types.is_some() { + state.serialize_field("channel_types", &data.channel_types)?; + } + } + } // Due to `custom_id` being required in some variants and // optional in others, serialize as an Option. @@ -618,8 +696,6 @@ impl Serialize for Component { state.serialize_field("min_values", &select_menu.min_values)?; } - state.serialize_field("options", &select_menu.options)?; - if select_menu.placeholder.is_some() { state.serialize_field("placeholder", &select_menu.placeholder)?; } @@ -700,13 +776,15 @@ mod tests { disabled: false, max_values: Some(25), min_values: Some(5), - options: Vec::from([SelectMenuOption { - label: "test option label".into(), - value: "test option value".into(), - description: Some("test description".into()), - emoji: None, - default: false, - }]), + data: SelectMenuData::Text(Box::new(TextSelectMenuData { + options: Vec::from([SelectMenuOption { + label: "test option label".into(), + value: "test option value".into(), + description: Some("test description".into()), + emoji: None, + default: false, + }]), + })), placeholder: Some("test placeholder".into()), }), ]), @@ -745,18 +823,7 @@ mod tests { len: 6, }, Token::Str("type"), - Token::U8(ComponentType::SelectMenu.into()), - Token::Str("custom_id"), - Token::Some, - Token::Str("test custom id 2"), - Token::Str("disabled"), - Token::Bool(false), - Token::Str("max_values"), - Token::Some, - Token::U8(25), - Token::Str("min_values"), - Token::Some, - Token::U8(5), + Token::U8(ComponentType::TextSelectMenu.into()), Token::Str("options"), Token::Seq { len: Some(1) }, Token::Struct { @@ -774,6 +841,17 @@ mod tests { Token::Str("test option value"), Token::StructEnd, Token::SeqEnd, + Token::Str("custom_id"), + Token::Some, + Token::Str("test custom id 2"), + Token::Str("disabled"), + Token::Bool(false), + Token::Str("max_values"), + Token::Some, + Token::U8(25), + Token::Str("min_values"), + Token::Some, + Token::U8(5), Token::Str("placeholder"), Token::Some, Token::Str("test placeholder"), diff --git a/twilight-model/src/channel/message/component/select_menu.rs b/twilight-model/src/channel/message/component/select_menu.rs index 41d64a26cbd..a2342707ad5 100644 --- a/twilight-model/src/channel/message/component/select_menu.rs +++ b/twilight-model/src/channel/message/component/select_menu.rs @@ -1,13 +1,18 @@ -use crate::channel::message::ReactionType; +use crate::channel::{message::ReactionType, ChannelType}; use serde::{Deserialize, Serialize}; -/// Dropdown-style [`Component`] that renders belew messages. +/// Dropdown-style [`Component`] that renders below messages. +/// +/// Use the `data` field to determine which kind of select menu you want. The kinds available at the moment are listed +/// as [`SelectMenuData`]'s variants. /// /// [`Component`]: super::Component #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct SelectMenu { /// Developer defined identifier. pub custom_id: String, + /// Data specific to this select menu's kind. + pub data: SelectMenuData, /// Whether the select menu is disabled. /// /// Defaults to `false`. @@ -16,12 +21,63 @@ pub struct SelectMenu { pub max_values: Option, /// Minimum number of options that must be chosen. pub min_values: Option, - /// List of available choices. - pub options: Vec, /// Custom placeholder text if no option is selected. pub placeholder: Option, } +/// Data specific to a kind of [`SelectMenu`]. +/// +/// Choosing a variant of this enum implicitly sets the select menu's kind. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub enum SelectMenuData { + /// Data specific to text select menus. + /// + /// Choosing this variant for your select menu makes the menu a [`ComponentType::TextSelectMenu`]. + /// + /// [`ComponentType::TextSelectMenu`]: super::ComponentType::TextSelectMenu + Text(Box), + /// Data specific to user select menus. + /// + /// Choosing this variant for your select menu makes the menu a [`ComponentType::UserSelectMenu`]. + /// + /// [`ComponentType::UserSelectMenu`]: super::ComponentType::UserSelectMenu + User, + /// Data specific to role select menus. + /// + /// Choosing this variant for your select menu makes the menu a [`ComponentType::RoleSelectMenu`]. + /// + /// [`ComponentType::RoleSelectMenu`]: super::ComponentType::RoleSelectMenu + Role, + /// Data specific to mentionable select menus. + /// + /// Choosing this variant for your select menu makes the menu a [`ComponentType::MentionableSelectMenu`]. + /// + /// [`ComponentType::MentionableSelectMenu`]: super::ComponentType::MentionableSelectMenu + Mentionable, + /// Data specific to channel select menus. + /// + /// Choosing this variant for your select menu makes the menu a [`ComponentType::ChannelSelectMenu`]. + /// + /// [`ComponentType::ChannelSelectMenu`]: super::ComponentType::ChannelSelectMenu + Channel(Box), +} + +/// Data specific to text select menus. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct TextSelectMenuData { + /// A list of available choices for this select menu. + pub options: Vec, +} + +/// Data specific to channel select menus. +#[derive(Clone, Debug, Eq, Hash, PartialEq)] +pub struct ChannelSelectMenuData { + /// An optional list of channel types to include in this select menu. + /// + /// If `None`, the select menu will display all channel types. + pub channel_types: Option>, +} + /// Dropdown options that are part of [`SelectMenu`]. #[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] pub struct SelectMenuOption { @@ -49,14 +105,42 @@ mod tests { assert_fields!( SelectMenu: custom_id, + data, disabled, max_values, min_values, - options, placeholder ); assert_impl_all!(SelectMenu: Clone, Debug, Eq, Hash, PartialEq, Send, Sync); + assert_impl_all!( + SelectMenuData: Clone, + Debug, + Eq, + Hash, + PartialEq, + Send, + Sync + ); + assert_impl_all!( + TextSelectMenuData: Clone, + Debug, + Eq, + Hash, + PartialEq, + Send, + Sync + ); + assert_impl_all!( + ChannelSelectMenuData: Clone, + Debug, + Eq, + Hash, + PartialEq, + Send, + Sync + ); + assert_impl_all!( SelectMenuOption: Clone, Debug, diff --git a/twilight-validate/src/component.rs b/twilight-validate/src/component.rs index 5370e9f99fc..e6799282d65 100644 --- a/twilight-validate/src/component.rs +++ b/twilight-validate/src/component.rs @@ -5,8 +5,8 @@ use std::{ fmt::{Debug, Display, Formatter, Result as FmtResult}, }; use twilight_model::channel::message::component::{ - ActionRow, Button, ButtonStyle, Component, ComponentType, SelectMenu, SelectMenuOption, - TextInput, + ActionRow, Button, ButtonStyle, Component, ComponentType, SelectMenu, SelectMenuData, + SelectMenuOption, TextInput, }; /// Maximum number of [`Component`]s allowed inside an [`ActionRow`]. @@ -635,7 +635,20 @@ pub fn button(button: &Button) -> Result<(), ComponentValidationError> { /// [`SelectPlaceholderLength`]: ComponentValidationErrorType::SelectPlaceholderLength pub fn select_menu(select_menu: &SelectMenu) -> Result<(), ComponentValidationError> { self::component_custom_id(&select_menu.custom_id)?; - self::component_select_options(&select_menu.options)?; + + // There aren't any requirements for channel_types that we could validate here + if let SelectMenuData::Text(data) = &select_menu.data { + let options = &data.options; + for option in options { + component_select_option_label(&option.label)?; + component_select_option_value(&option.value)?; + + if let Some(description) = option.description.as_ref() { + component_option_description(description)?; + } + } + component_select_options(options)?; + } if let Some(placeholder) = select_menu.placeholder.as_ref() { self::component_select_placeholder(placeholder)?; @@ -649,15 +662,6 @@ pub fn select_menu(select_menu: &SelectMenu) -> Result<(), ComponentValidationEr self::component_select_min_values(usize::from(min_values))?; } - for option in &select_menu.options { - self::component_select_option_label(&option.label)?; - self::component_select_option_value(&option.value)?; - - if let Some(description) = option.description.as_ref() { - self::component_option_description(description)?; - } - } - Ok(()) } @@ -1052,6 +1056,7 @@ mod tests { use super::*; use static_assertions::{assert_fields, assert_impl_all}; use std::fmt::Debug; + use twilight_model::channel::message::component::TextSelectMenuData; use twilight_model::channel::message::ReactionType; assert_fields!(ComponentValidationErrorType::ActionRowComponentCount: count); @@ -1096,13 +1101,15 @@ mod tests { disabled: false, max_values: Some(2), min_values: Some(1), - options: Vec::from([SelectMenuOption { - default: true, - description: Some("Book 1 of the Expanse".into()), - emoji: None, - label: "Leviathan Wakes".into(), - value: "9780316129084".into(), - }]), + data: SelectMenuData::Text(Box::new(TextSelectMenuData { + options: Vec::from([SelectMenuOption { + default: true, + description: Some("Book 1 of the Expanse".into()), + emoji: None, + label: "Leviathan Wakes".into(), + value: "9780316129084".into(), + }]), + })), placeholder: Some("Choose a book".into()), }; From b545e9e5ad37ea647a28fa23254c12321da2933f Mon Sep 17 00:00:00 2001 From: Archer Date: Tue, 13 Jun 2023 14:55:22 +0200 Subject: [PATCH 2/7] refactor(twilight-model): Use unreachable! in ComponentVisitor This patch makes `ComponentVisitor::visit_map` use `unreachable!` instead of `panic!` in case a select menu type was not implemented while being listed in a previous match statement. Moreover, this commit sneaks in a minor formatting fix inside `Components` doc-comment example. --- twilight-model/src/channel/message/component/mod.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/twilight-model/src/channel/message/component/mod.rs b/twilight-model/src/channel/message/component/mod.rs index b53477912fa..3dad5988dd1 100644 --- a/twilight-model/src/channel/message/component/mod.rs +++ b/twilight-model/src/channel/message/component/mod.rs @@ -105,9 +105,7 @@ use std::fmt::{Formatter, Result as FmtResult}; /// id: Id::new(625891303795982337), /// name: Some("priest".to_owned()), /// }), -/// description: Some( -/// "You get heals when I'm done doing damage".to_owned(), -/// ), +/// description: Some("You get heals when I'm done doing damage".to_owned()), /// label: "Priest".to_owned(), /// value: "priest".to_owned(), /// }, @@ -506,7 +504,7 @@ impl<'de> Visitor<'de> for ComponentVisitor { } // We'll only take the branch below if we added a type above and forgot to implement it here. I.e., // we should never end up here. - _ => panic!("missing select menu implementation"), + _ => unreachable!("missing select menu implementation"), }; Self::Value::SelectMenu(SelectMenu { From e8fffb2b86bff8050104afce2ea71353d526e15b Mon Sep 17 00:00:00 2001 From: Archer Date: Tue, 13 Jun 2023 19:26:06 +0200 Subject: [PATCH 3/7] feat(twilight-model)!: Implement `resolved` for select menu interactions This commit implements the `resolved` field for `MessageComponentInteraction`. This field holds resolved users, roles, channels, or attachments for select menu interactions. Unfortunately, the new field makes it impossible to implement `Eq` and `Hash` for `MessageComponentInteractionData`. --- .../src/event/interaction.rs | 8 +++----- twilight-cache-inmemory/src/event/member.rs | 2 +- twilight-cache-inmemory/src/model/member.rs | 2 +- .../interaction/application_command/mod.rs | 10 +++------- .../interaction/message_component.rs | 14 ++++++++++---- .../src/application/interaction/mod.rs | 17 +++++++++-------- .../{application_command => }/resolved.rs | 6 +++--- twilight-standby/src/lib.rs | 5 +++-- 8 files changed, 33 insertions(+), 31 deletions(-) rename twilight-model/src/application/interaction/{application_command => }/resolved.rs (99%) diff --git a/twilight-cache-inmemory/src/event/interaction.rs b/twilight-cache-inmemory/src/event/interaction.rs index 5c9de09df25..a26a6cc98c1 100644 --- a/twilight-cache-inmemory/src/event/interaction.rs +++ b/twilight-cache-inmemory/src/event/interaction.rs @@ -65,10 +65,8 @@ mod tests { application::{ command::CommandType, interaction::{ - application_command::{ - CommandData, CommandInteractionDataResolved, InteractionMember, - }, - Interaction, InteractionData, InteractionType, + application_command::CommandData, Interaction, InteractionData, + InteractionDataResolved, InteractionMember, InteractionType, }, }, channel::{ @@ -143,7 +141,7 @@ mod tests { name: "command name".into(), kind: CommandType::ChatInput, // This isn't actually a valid command, so just mark it as a slash command. options: Vec::new(), - resolved: Some(CommandInteractionDataResolved { + resolved: Some(InteractionDataResolved { attachments: HashMap::new(), channels: HashMap::new(), members: HashMap::from([( diff --git a/twilight-cache-inmemory/src/event/member.rs b/twilight-cache-inmemory/src/event/member.rs index 52d2bc589a1..cc9c48b2cf3 100644 --- a/twilight-cache-inmemory/src/event/member.rs +++ b/twilight-cache-inmemory/src/event/member.rs @@ -5,7 +5,7 @@ use crate::{ }; use std::borrow::Cow; use twilight_model::{ - application::interaction::application_command::InteractionMember, + application::interaction::InteractionMember, gateway::payload::incoming::{MemberAdd, MemberChunk, MemberRemove, MemberUpdate}, guild::{Member, PartialMember}, id::{ diff --git a/twilight-cache-inmemory/src/model/member.rs b/twilight-cache-inmemory/src/model/member.rs index 56cf6bd4d49..f5827753145 100644 --- a/twilight-cache-inmemory/src/model/member.rs +++ b/twilight-cache-inmemory/src/model/member.rs @@ -1,6 +1,6 @@ use serde::Serialize; use twilight_model::{ - application::interaction::application_command::InteractionMember, + application::interaction::InteractionMember, guild::{Member, MemberFlags, PartialMember}, id::{ marker::{RoleMarker, UserMarker}, diff --git a/twilight-model/src/application/interaction/application_command/mod.rs b/twilight-model/src/application/interaction/application_command/mod.rs index 620610522f7..ca1f80465a9 100644 --- a/twilight-model/src/application/interaction/application_command/mod.rs +++ b/twilight-model/src/application/interaction/application_command/mod.rs @@ -3,15 +3,11 @@ //! [`ApplicationCommand`]: crate::application::interaction::InteractionType::ApplicationCommand mod option; -mod resolved; -pub use self::{ - option::{CommandDataOption, CommandOptionValue}, - resolved::{CommandInteractionDataResolved, InteractionChannel, InteractionMember}, -}; +pub use self::option::{CommandDataOption, CommandOptionValue}; use crate::{ - application::command::CommandType, + application::{command::CommandType, interaction::InteractionDataResolved}, id::{ marker::{CommandMarker, GenericMarker, GuildMarker}, Id, @@ -44,7 +40,7 @@ pub struct CommandData { pub options: Vec, /// Resolved data from the interaction's options. #[serde(skip_serializing_if = "Option::is_none")] - pub resolved: Option, + pub resolved: Option, /// If this is a user or message command, the ID of the targeted user/message. #[serde(skip_serializing_if = "Option::is_none")] pub target_id: Option>, diff --git a/twilight-model/src/application/interaction/message_component.rs b/twilight-model/src/application/interaction/message_component.rs index eb539454e33..ac0d29b30ee 100644 --- a/twilight-model/src/application/interaction/message_component.rs +++ b/twilight-model/src/application/interaction/message_component.rs @@ -2,6 +2,7 @@ //! //! [`MessageComponent`]: crate::application::interaction::InteractionType::MessageComponent +use crate::application::interaction::InteractionDataResolved; use crate::channel::message::component::ComponentType; use serde::{Deserialize, Serialize}; @@ -11,7 +12,7 @@ use serde::{Deserialize, Serialize}; /// /// [`MessageComponent`]: crate::application::interaction::InteractionType::MessageComponent /// [Discord Docs/Message Component Data Structure]: https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-message-component-data-structure -#[derive(Clone, Debug, Deserialize, Eq, Hash, PartialEq, Serialize)] +#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] pub struct MessageComponentInteractionData { /// User defined identifier for the component. /// @@ -21,6 +22,12 @@ pub struct MessageComponentInteractionData { pub custom_id: String, /// Type of the component. pub component_type: ComponentType, + /// Converted users, roles, channels, or attachments. + /// + /// Only used for [`SelectMenu`] components. + /// + /// [`SelectMenu`]: crate::channel::message::component::SelectMenu + pub resolved: Option, /// Values selected by the user. /// /// Only used for [`SelectMenu`] components. @@ -37,7 +44,7 @@ mod tests { use serde::{Deserialize, Serialize}; use serde_test::Token; use static_assertions::{assert_fields, assert_impl_all}; - use std::{fmt::Debug, hash::Hash}; + use std::fmt::Debug; assert_fields!( MessageComponentInteractionData: custom_id, @@ -48,8 +55,6 @@ mod tests { MessageComponentInteractionData: Clone, Debug, Deserialize<'static>, - Eq, - Hash, PartialEq, Send, Serialize, @@ -61,6 +66,7 @@ mod tests { let value = MessageComponentInteractionData { custom_id: "test".to_owned(), component_type: ComponentType::Button, + resolved: None, values: Vec::from(["1".to_owned(), "2".to_owned()]), }; diff --git a/twilight-model/src/application/interaction/mod.rs b/twilight-model/src/application/interaction/mod.rs index b7d60c44672..79ee6999d19 100644 --- a/twilight-model/src/application/interaction/mod.rs +++ b/twilight-model/src/application/interaction/mod.rs @@ -9,8 +9,12 @@ pub mod message_component; pub mod modal; mod interaction_type; +mod resolved; -pub use self::interaction_type::InteractionType; +pub use self::{ + interaction_type::InteractionType, + resolved::{InteractionChannel, InteractionDataResolved, InteractionMember}, +}; use self::{ application_command::CommandData, message_component::MessageComponentInteractionData, @@ -424,7 +428,7 @@ pub enum InteractionData { /// Data received for the [`MessageComponent`] interaction type. /// /// [`MessageComponent`]: InteractionType::MessageComponent - MessageComponent(MessageComponentInteractionData), + MessageComponent(Box), /// Data received for the [`ModalSubmit`] interaction type. /// /// [`ModalSubmit`]: InteractionType::ModalSubmit @@ -434,11 +438,8 @@ pub enum InteractionData { #[cfg(test)] mod tests { use super::{ - application_command::{ - CommandData, CommandDataOption, CommandInteractionDataResolved, CommandOptionValue, - InteractionMember, - }, - Interaction, InteractionData, InteractionType, + application_command::{CommandData, CommandDataOption, CommandOptionValue}, + Interaction, InteractionData, InteractionDataResolved, InteractionMember, InteractionType, }; use crate::{ application::command::{CommandOptionType, CommandType}, @@ -508,7 +509,7 @@ mod tests { name: "member".into(), value: CommandOptionValue::User(Id::new(600)), }]), - resolved: Some(CommandInteractionDataResolved { + resolved: Some(InteractionDataResolved { attachments: HashMap::new(), channels: HashMap::new(), members: IntoIterator::into_iter([( diff --git a/twilight-model/src/application/interaction/application_command/resolved.rs b/twilight-model/src/application/interaction/resolved.rs similarity index 99% rename from twilight-model/src/application/interaction/application_command/resolved.rs rename to twilight-model/src/application/interaction/resolved.rs index a1691c4c7aa..0b2c0f79e51 100644 --- a/twilight-model/src/application/interaction/application_command/resolved.rs +++ b/twilight-model/src/application/interaction/resolved.rs @@ -18,7 +18,7 @@ use std::collections::hash_map::HashMap; /// [`ApplicationCommand`]: crate::application::interaction::InteractionType::ApplicationCommand /// [Discord Docs/Resolved Data Structure]: https://discord.com/developers/docs/interactions/receiving-and-responding#interaction-object-resolved-data-structure #[derive(Clone, Debug, Deserialize, PartialEq, Serialize)] -pub struct CommandInteractionDataResolved { +pub struct InteractionDataResolved { /// Map of resolved attachments. #[serde(default, skip_serializing_if = "HashMap::is_empty")] pub attachments: HashMap, Attachment>, @@ -97,7 +97,7 @@ pub struct InteractionMember { #[cfg(test)] mod tests { - use super::{CommandInteractionDataResolved, InteractionChannel, InteractionMember}; + use super::{InteractionChannel, InteractionDataResolved, InteractionMember}; use crate::{ channel::{ message::{ @@ -122,7 +122,7 @@ mod tests { let timestamp = Timestamp::from_str("2020-02-02T02:02:02.020000+00:00")?; let flags = MemberFlags::BYPASSES_VERIFICATION | MemberFlags::DID_REJOIN; - let value = CommandInteractionDataResolved { + let value = InteractionDataResolved { attachments: IntoIterator::into_iter([( Id::new(400), Attachment { diff --git a/twilight-standby/src/lib.rs b/twilight-standby/src/lib.rs index c61dbcd2b95..7b25a47a940 100644 --- a/twilight-standby/src/lib.rs +++ b/twilight-standby/src/lib.rs @@ -1193,13 +1193,14 @@ mod tests { video_quality_mode: None, }), channel_id: None, - data: Some(InteractionData::MessageComponent( + data: Some(InteractionData::MessageComponent(Box::new( MessageComponentInteractionData { custom_id: String::from("Click"), component_type: ComponentType::Button, + resolved: None, values: Vec::new(), }, - )), + ))), guild_id: Some(Id::new(3)), guild_locale: None, id: Id::new(4), From 52f6e476441329cb702f3a1f5356e0f73b01c30e Mon Sep 17 00:00:00 2001 From: Archer Date: Tue, 13 Jun 2023 19:57:42 +0200 Subject: [PATCH 4/7] test(twilight-model): Fix tests for `InteractionDataResolved` This commit fixes the tests testing the (de-)serialization of `InteractionDataResolved`. Previously, the tests were failing, as the type was renamed from `CommandInteractionDataResolved`. Moreover, this commit fixes the message component interaction tests by adding the missing `resolved` field to the expected fields in `message_component_interaction_data`. --- .../src/application/interaction/message_component.rs | 4 +++- twilight-model/src/application/interaction/mod.rs | 2 +- twilight-model/src/application/interaction/resolved.rs | 2 +- 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/twilight-model/src/application/interaction/message_component.rs b/twilight-model/src/application/interaction/message_component.rs index ac0d29b30ee..2962fb0ebde 100644 --- a/twilight-model/src/application/interaction/message_component.rs +++ b/twilight-model/src/application/interaction/message_component.rs @@ -75,12 +75,14 @@ mod tests { &[ Token::Struct { name: "MessageComponentInteractionData", - len: 3, + len: 4, }, Token::String("custom_id"), Token::String("test"), Token::String("component_type"), Token::U8(ComponentType::Button.into()), + Token::String("resolved"), + Token::None, Token::String("values"), Token::Seq { len: Some(2) }, Token::String("1"), diff --git a/twilight-model/src/application/interaction/mod.rs b/twilight-model/src/application/interaction/mod.rs index 79ee6999d19..4c6151bd8be 100644 --- a/twilight-model/src/application/interaction/mod.rs +++ b/twilight-model/src/application/interaction/mod.rs @@ -653,7 +653,7 @@ mod tests { Token::Str("resolved"), Token::Some, Token::Struct { - name: "CommandInteractionDataResolved", + name: "InteractionDataResolved", len: 2, }, Token::Str("members"), diff --git a/twilight-model/src/application/interaction/resolved.rs b/twilight-model/src/application/interaction/resolved.rs index 0b2c0f79e51..d62d4ecd272 100644 --- a/twilight-model/src/application/interaction/resolved.rs +++ b/twilight-model/src/application/interaction/resolved.rs @@ -282,7 +282,7 @@ mod tests { &value, &[ Token::Struct { - name: "CommandInteractionDataResolved", + name: "InteractionDataResolved", len: 6, }, Token::Str("attachments"), From c9a1a7ff4eda4149d49e80724171484a252c102a Mon Sep 17 00:00:00 2001 From: Archer Date: Wed, 14 Jun 2023 16:04:18 +0200 Subject: [PATCH 5/7] refactor(twilight-model)!: Un-`Box` `SelectMenuData` variants This commit unboxes `SelectMenuData`'s variants. They were boxed in a previous patch to prevent future fields from unnecessarily increasing the enum's memory footprint, but considering that, with the current structures, new fields are a breaking change already, it's OK to leave the variants unboxed for now and re-box as necessary. For the current code, this saves a heap allocation while only marginally increasing the enum's memory footprint. This change is only breaking in the context of PR #2219. When added to the commits in this PR, it doesn't add any breaking changes to the PR's list of breaking changes. --- twilight-model/src/channel/message/component/mod.rs | 12 ++++++------ .../src/channel/message/component/select_menu.rs | 4 ++-- twilight-validate/src/component.rs | 4 ++-- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/twilight-model/src/channel/message/component/mod.rs b/twilight-model/src/channel/message/component/mod.rs index 3dad5988dd1..e877da2f8dd 100644 --- a/twilight-model/src/channel/message/component/mod.rs +++ b/twilight-model/src/channel/message/component/mod.rs @@ -74,7 +74,7 @@ use std::fmt::{Formatter, Result as FmtResult}; /// disabled: false, /// max_values: Some(3), /// min_values: Some(1), -/// data: SelectMenuData::Text(Box::new(TextSelectMenuData { +/// data: SelectMenuData::Text(TextSelectMenuData { /// options: Vec::from([ /// SelectMenuOption { /// default: false, @@ -110,7 +110,7 @@ use std::fmt::{Formatter, Result as FmtResult}; /// value: "priest".to_owned(), /// }, /// ]), -/// })), +/// }), /// placeholder: Some("Choose a class".to_owned()), /// })], /// }); @@ -494,13 +494,13 @@ impl<'de> Visitor<'de> for ComponentVisitor { let data = match kind { ComponentType::TextSelectMenu => { let options = options.ok_or_else(|| DeError::missing_field("options"))?; - SelectMenuData::Text(Box::new(TextSelectMenuData { options })) + SelectMenuData::Text(TextSelectMenuData { options }) } ComponentType::UserSelectMenu => SelectMenuData::User, ComponentType::RoleSelectMenu => SelectMenuData::Role, ComponentType::MentionableSelectMenu => SelectMenuData::Mentionable, ComponentType::ChannelSelectMenu => { - SelectMenuData::Channel(Box::new(ChannelSelectMenuData { channel_types })) + SelectMenuData::Channel(ChannelSelectMenuData { channel_types }) } // We'll only take the branch below if we added a type above and forgot to implement it here. I.e., // we should never end up here. @@ -774,7 +774,7 @@ mod tests { disabled: false, max_values: Some(25), min_values: Some(5), - data: SelectMenuData::Text(Box::new(TextSelectMenuData { + data: SelectMenuData::Text(TextSelectMenuData { options: Vec::from([SelectMenuOption { label: "test option label".into(), value: "test option value".into(), @@ -782,7 +782,7 @@ mod tests { emoji: None, default: false, }]), - })), + }), placeholder: Some("test placeholder".into()), }), ]), diff --git a/twilight-model/src/channel/message/component/select_menu.rs b/twilight-model/src/channel/message/component/select_menu.rs index a2342707ad5..f270bdb5674 100644 --- a/twilight-model/src/channel/message/component/select_menu.rs +++ b/twilight-model/src/channel/message/component/select_menu.rs @@ -35,7 +35,7 @@ pub enum SelectMenuData { /// Choosing this variant for your select menu makes the menu a [`ComponentType::TextSelectMenu`]. /// /// [`ComponentType::TextSelectMenu`]: super::ComponentType::TextSelectMenu - Text(Box), + Text(TextSelectMenuData), /// Data specific to user select menus. /// /// Choosing this variant for your select menu makes the menu a [`ComponentType::UserSelectMenu`]. @@ -59,7 +59,7 @@ pub enum SelectMenuData { /// Choosing this variant for your select menu makes the menu a [`ComponentType::ChannelSelectMenu`]. /// /// [`ComponentType::ChannelSelectMenu`]: super::ComponentType::ChannelSelectMenu - Channel(Box), + Channel(ChannelSelectMenuData), } /// Data specific to text select menus. diff --git a/twilight-validate/src/component.rs b/twilight-validate/src/component.rs index e6799282d65..323119d571c 100644 --- a/twilight-validate/src/component.rs +++ b/twilight-validate/src/component.rs @@ -1101,7 +1101,7 @@ mod tests { disabled: false, max_values: Some(2), min_values: Some(1), - data: SelectMenuData::Text(Box::new(TextSelectMenuData { + data: SelectMenuData::Text(TextSelectMenuData { options: Vec::from([SelectMenuOption { default: true, description: Some("Book 1 of the Expanse".into()), @@ -1109,7 +1109,7 @@ mod tests { label: "Leviathan Wakes".into(), value: "9780316129084".into(), }]), - })), + }), placeholder: Some("Choose a book".into()), }; From c774adc068a69d59032b0b29dbd20e0863da870d Mon Sep 17 00:00:00 2001 From: Archer Date: Sat, 24 Jun 2023 19:32:13 +0200 Subject: [PATCH 6/7] refactor(twilight-model)!: Fully flatten SelectMenu This commit removes the SelectMenuData type and embeds all variant-specific fields in the base struct. Moreover, it adds documentation indicating which fields are required variant-specific fields. This commit also updates the component validation module in `twilight-validate` by introducing a new error type for text select menus that don't have the necessary `options` field. --- .../src/channel/message/component/mod.rs | 202 +++++++++--------- .../channel/message/component/select_menu.rs | 102 +++------ twilight-validate/src/component.rs | 41 ++-- 3 files changed, 156 insertions(+), 189 deletions(-) diff --git a/twilight-model/src/channel/message/component/mod.rs b/twilight-model/src/channel/message/component/mod.rs index e877da2f8dd..ae70efbc441 100644 --- a/twilight-model/src/channel/message/component/mod.rs +++ b/twilight-model/src/channel/message/component/mod.rs @@ -15,9 +15,7 @@ pub use self::{ action_row::ActionRow, button::{Button, ButtonStyle}, kind::ComponentType, - select_menu::{ - ChannelSelectMenuData, SelectMenu, SelectMenuData, SelectMenuOption, TextSelectMenuData, - }, + select_menu::{SelectMenu, SelectMenuOption, SelectMenuType}, text_input::{TextInput, TextInputStyle}, }; @@ -25,7 +23,7 @@ use super::ReactionType; use crate::channel::ChannelType; use serde::{ de::{Deserializer, Error as DeError, IgnoredAny, MapAccess, Visitor}, - ser::SerializeStruct, + ser::{Error as SerError, SerializeStruct}, Deserialize, Serialize, Serializer, }; use serde_value::{DeserializerError, Value}; @@ -59,10 +57,7 @@ use std::fmt::{Formatter, Result as FmtResult}; /// ``` /// use twilight_model::{ /// channel::message::{ -/// component::{ -/// ActionRow, Component, SelectMenu, SelectMenuData, SelectMenuOption, -/// TextSelectMenuData, -/// }, +/// component::{ActionRow, Component, SelectMenu, SelectMenuOption, SelectMenuType}, /// ReactionType, /// }, /// id::Id, @@ -70,47 +65,47 @@ use std::fmt::{Formatter, Result as FmtResult}; /// /// Component::ActionRow(ActionRow { /// components: vec![Component::SelectMenu(SelectMenu { +/// channel_types: None, /// custom_id: "class_select_1".to_owned(), /// disabled: false, +/// kind: SelectMenuType::Text, /// max_values: Some(3), /// min_values: Some(1), -/// data: SelectMenuData::Text(TextSelectMenuData { -/// options: Vec::from([ -/// SelectMenuOption { -/// default: false, -/// emoji: Some(ReactionType::Custom { -/// animated: false, -/// id: Id::new(625891304148303894), -/// name: Some("rogue".to_owned()), -/// }), -/// description: Some("Sneak n stab".to_owned()), -/// label: "Rogue".to_owned(), -/// value: "rogue".to_owned(), -/// }, -/// SelectMenuOption { -/// default: false, -/// emoji: Some(ReactionType::Custom { -/// animated: false, -/// id: Id::new(625891304081063986), -/// name: Some("mage".to_owned()), -/// }), -/// description: Some("Turn 'em into a sheep".to_owned()), -/// label: "Mage".to_owned(), -/// value: "mage".to_owned(), -/// }, -/// SelectMenuOption { -/// default: false, -/// emoji: Some(ReactionType::Custom { -/// animated: false, -/// id: Id::new(625891303795982337), -/// name: Some("priest".to_owned()), -/// }), -/// description: Some("You get heals when I'm done doing damage".to_owned()), -/// label: "Priest".to_owned(), -/// value: "priest".to_owned(), -/// }, -/// ]), -/// }), +/// options: Some(Vec::from([ +/// SelectMenuOption { +/// default: false, +/// emoji: Some(ReactionType::Custom { +/// animated: false, +/// id: Id::new(625891304148303894), +/// name: Some("rogue".to_owned()), +/// }), +/// description: Some("Sneak n stab".to_owned()), +/// label: "Rogue".to_owned(), +/// value: "rogue".to_owned(), +/// }, +/// SelectMenuOption { +/// default: false, +/// emoji: Some(ReactionType::Custom { +/// animated: false, +/// id: Id::new(625891304081063986), +/// name: Some("mage".to_owned()), +/// }), +/// description: Some("Turn 'em into a sheep".to_owned()), +/// label: "Mage".to_owned(), +/// value: "mage".to_owned(), +/// }, +/// SelectMenuOption { +/// default: false, +/// emoji: Some(ReactionType::Custom { +/// animated: false, +/// id: Id::new(625891303795982337), +/// name: Some("priest".to_owned()), +/// }), +/// description: Some("You get heals when I'm done doing damage".to_owned()), +/// label: "Priest".to_owned(), +/// value: "priest".to_owned(), +/// }, +/// ])), /// placeholder: Some("Choose a class".to_owned()), /// })], /// }); @@ -152,26 +147,13 @@ impl Component { match self { Self::ActionRow(_) => ComponentType::ActionRow, Self::Button(_) => ComponentType::Button, - Self::SelectMenu(SelectMenu { - data: SelectMenuData::Text(_), - .. - }) => ComponentType::TextSelectMenu, - Self::SelectMenu(SelectMenu { - data: SelectMenuData::User, - .. - }) => ComponentType::UserSelectMenu, - Self::SelectMenu(SelectMenu { - data: SelectMenuData::Role, - .. - }) => ComponentType::RoleSelectMenu, - Self::SelectMenu(SelectMenu { - data: SelectMenuData::Mentionable, - .. - }) => ComponentType::MentionableSelectMenu, - Self::SelectMenu(SelectMenu { - data: SelectMenuData::Channel(_), - .. - }) => ComponentType::ChannelSelectMenu, + Self::SelectMenu(SelectMenu { kind, .. }) => match kind { + SelectMenuType::Text => ComponentType::TextSelectMenu, + SelectMenuType::User => ComponentType::UserSelectMenu, + SelectMenuType::Role => ComponentType::RoleSelectMenu, + SelectMenuType::Mentionable => ComponentType::MentionableSelectMenu, + SelectMenuType::Channel => ComponentType::ChannelSelectMenu, + }, Self::TextInput(_) => ComponentType::TextInput, Component::Unknown(unknown) => ComponentType::Unknown(*unknown), } @@ -245,7 +227,6 @@ impl<'de> Visitor<'de> for ComponentVisitor { let mut components: Option> = None; let mut kind: Option = None; let mut options: Option> = None; - let mut channel_types: Option> = None; let mut style: Option = None; // Liminal fields. @@ -253,6 +234,7 @@ impl<'de> Visitor<'de> for ComponentVisitor { let mut label: Option> = None; // Optional fields. + let mut channel_types: Option> = None; let mut disabled: Option = None; let mut emoji: Option> = None; let mut max_length: Option> = None; @@ -485,34 +467,38 @@ impl<'de> Visitor<'de> for ComponentVisitor { | ComponentType::RoleSelectMenu | ComponentType::MentionableSelectMenu | ComponentType::ChannelSelectMenu) => { + // Verify the individual variants' required fields + if let ComponentType::TextSelectMenu = kind { + if options.is_none() { + return Err(DeError::missing_field("options")); + } + } + let custom_id = custom_id .flatten() .ok_or_else(|| DeError::missing_field("custom_id"))? .deserialize_into() .map_err(DeserializerError::into_error)?; - let data = match kind { - ComponentType::TextSelectMenu => { - let options = options.ok_or_else(|| DeError::missing_field("options"))?; - SelectMenuData::Text(TextSelectMenuData { options }) - } - ComponentType::UserSelectMenu => SelectMenuData::User, - ComponentType::RoleSelectMenu => SelectMenuData::Role, - ComponentType::MentionableSelectMenu => SelectMenuData::Mentionable, - ComponentType::ChannelSelectMenu => { - SelectMenuData::Channel(ChannelSelectMenuData { channel_types }) - } - // We'll only take the branch below if we added a type above and forgot to implement it here. I.e., - // we should never end up here. - _ => unreachable!("missing select menu implementation"), - }; - Self::Value::SelectMenu(SelectMenu { + channel_types, custom_id, - data, disabled: disabled.unwrap_or_default(), + kind: match kind { + ComponentType::TextSelectMenu => SelectMenuType::Text, + ComponentType::UserSelectMenu => SelectMenuType::User, + ComponentType::RoleSelectMenu => SelectMenuType::Role, + ComponentType::MentionableSelectMenu => SelectMenuType::Mentionable, + ComponentType::ChannelSelectMenu => SelectMenuType::Channel, + // This branch is unreachable unless we add a new type above and forget to + // also add it here + _ => { + unreachable!("select menu component type is only partially implemented") + } + }, max_values: max_values.unwrap_or_default(), min_values: min_values.unwrap_or_default(), + options, placeholder: placeholder.unwrap_or_default(), }) } @@ -586,18 +572,23 @@ impl Serialize for Component { } // Required fields: // - custom_id - // - options + // - options (for text select menus) // - type // // Optional fields: + // - channel_types (for channel select menus) // - disabled // - max_values // - min_values // - placeholder Component::SelectMenu(select_menu) => { - 3 + usize::from(select_menu.disabled) + // We ignore text menus that don't include the `options` field, as those are + // detected later in the serialization process + 2 + usize::from(select_menu.channel_types.is_some()) + + usize::from(select_menu.disabled) + usize::from(select_menu.max_values.is_some()) + usize::from(select_menu.min_values.is_some()) + + usize::from(select_menu.options.is_some()) + usize::from(select_menu.placeholder.is_some()) } // Required fields: @@ -658,24 +649,29 @@ impl Serialize for Component { } } Component::SelectMenu(select_menu) => { - match &select_menu.data { - SelectMenuData::Text(data) => { + match &select_menu.kind { + SelectMenuType::Text => { state.serialize_field("type", &ComponentType::TextSelectMenu)?; - state.serialize_field("options", &data.options)?; + state.serialize_field( + "options", + &select_menu.options.as_ref().ok_or(SerError::custom( + "required field \"option\" missing for text select menu", + ))?, + )?; } - SelectMenuData::User => { + SelectMenuType::User => { state.serialize_field("type", &ComponentType::UserSelectMenu)?; } - SelectMenuData::Role => { + SelectMenuType::Role => { state.serialize_field("type", &ComponentType::RoleSelectMenu)?; } - SelectMenuData::Mentionable => { + SelectMenuType::Mentionable => { state.serialize_field("type", &ComponentType::MentionableSelectMenu)?; } - SelectMenuData::Channel(data) => { + SelectMenuType::Channel => { state.serialize_field("type", &ComponentType::ChannelSelectMenu)?; - if data.channel_types.is_some() { - state.serialize_field("channel_types", &data.channel_types)?; + if let Some(channel_types) = &select_menu.channel_types { + state.serialize_field("channel_types", channel_types)?; } } } @@ -770,19 +766,19 @@ mod tests { url: None, }), Component::SelectMenu(SelectMenu { + channel_types: None, custom_id: "test custom id 2".into(), disabled: false, + kind: SelectMenuType::Text, max_values: Some(25), min_values: Some(5), - data: SelectMenuData::Text(TextSelectMenuData { - options: Vec::from([SelectMenuOption { - label: "test option label".into(), - value: "test option value".into(), - description: Some("test description".into()), - emoji: None, - default: false, - }]), - }), + options: Some(Vec::from([SelectMenuOption { + label: "test option label".into(), + value: "test option value".into(), + description: Some("test description".into()), + emoji: None, + default: false, + }])), placeholder: Some("test placeholder".into()), }), ]), diff --git a/twilight-model/src/channel/message/component/select_menu.rs b/twilight-model/src/channel/message/component/select_menu.rs index f270bdb5674..27945c6ea5c 100644 --- a/twilight-model/src/channel/message/component/select_menu.rs +++ b/twilight-model/src/channel/message/component/select_menu.rs @@ -3,79 +3,55 @@ use serde::{Deserialize, Serialize}; /// Dropdown-style [`Component`] that renders below messages. /// -/// Use the `data` field to determine which kind of select menu you want. The kinds available at the moment are listed -/// as [`SelectMenuData`]'s variants. -/// /// [`Component`]: super::Component #[derive(Clone, Debug, Eq, Hash, PartialEq)] pub struct SelectMenu { + /// An optional list of channel types. + /// + /// This option is only used for [channel select menus](SelectMenuType::Channel). + pub channel_types: Option>, /// Developer defined identifier. pub custom_id: String, - /// Data specific to this select menu's kind. - pub data: SelectMenuData, /// Whether the select menu is disabled. /// /// Defaults to `false`. pub disabled: bool, + /// This select menu's type. + /// + /// [Text select menus](SelectMenuType::Text) *must* also set the `options` field. + pub kind: SelectMenuType, /// Maximum number of options that may be chosen. pub max_values: Option, /// Minimum number of options that must be chosen. pub min_values: Option, + /// A list of available options. + /// + /// This value is only used and required by [text select menus](SelectMenuType::Text). + pub options: Option>, /// Custom placeholder text if no option is selected. pub placeholder: Option, } -/// Data specific to a kind of [`SelectMenu`]. -/// -/// Choosing a variant of this enum implicitly sets the select menu's kind. +/// A [`SelectMenu`]'s type. #[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub enum SelectMenuData { - /// Data specific to text select menus. - /// - /// Choosing this variant for your select menu makes the menu a [`ComponentType::TextSelectMenu`]. - /// - /// [`ComponentType::TextSelectMenu`]: super::ComponentType::TextSelectMenu - Text(TextSelectMenuData), - /// Data specific to user select menus. +#[non_exhaustive] +pub enum SelectMenuType { + /// Select menus with a text-based `options` list. /// - /// Choosing this variant for your select menu makes the menu a [`ComponentType::UserSelectMenu`]. - /// - /// [`ComponentType::UserSelectMenu`]: super::ComponentType::UserSelectMenu + /// Select menus of this `kind` *must* set the `options` field to specify the options users + /// can pick from. + Text, + /// User select menus. User, - /// Data specific to role select menus. - /// - /// Choosing this variant for your select menu makes the menu a [`ComponentType::RoleSelectMenu`]. - /// - /// [`ComponentType::RoleSelectMenu`]: super::ComponentType::RoleSelectMenu + /// Role select menus. Role, - /// Data specific to mentionable select menus. - /// - /// Choosing this variant for your select menu makes the menu a [`ComponentType::MentionableSelectMenu`]. - /// - /// [`ComponentType::MentionableSelectMenu`]: super::ComponentType::MentionableSelectMenu + /// Mentionable select menus. Mentionable, - /// Data specific to channel select menus. - /// - /// Choosing this variant for your select menu makes the menu a [`ComponentType::ChannelSelectMenu`]. - /// - /// [`ComponentType::ChannelSelectMenu`]: super::ComponentType::ChannelSelectMenu - Channel(ChannelSelectMenuData), -} - -/// Data specific to text select menus. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub struct TextSelectMenuData { - /// A list of available choices for this select menu. - pub options: Vec, -} - -/// Data specific to channel select menus. -#[derive(Clone, Debug, Eq, Hash, PartialEq)] -pub struct ChannelSelectMenuData { - /// An optional list of channel types to include in this select menu. + /// Channel select menus. /// - /// If `None`, the select menu will display all channel types. - pub channel_types: Option>, + /// Select menus of this `kind` *can* use the `channel_types` field to specify which types of + /// channels are selectable. + Channel, } /// Dropdown options that are part of [`SelectMenu`]. @@ -104,35 +80,19 @@ mod tests { use std::{fmt::Debug, hash::Hash}; assert_fields!( - SelectMenu: custom_id, - data, + SelectMenu: channel_types, + custom_id, disabled, + kind, max_values, min_values, + options, placeholder ); assert_impl_all!(SelectMenu: Clone, Debug, Eq, Hash, PartialEq, Send, Sync); assert_impl_all!( - SelectMenuData: Clone, - Debug, - Eq, - Hash, - PartialEq, - Send, - Sync - ); - assert_impl_all!( - TextSelectMenuData: Clone, - Debug, - Eq, - Hash, - PartialEq, - Send, - Sync - ); - assert_impl_all!( - ChannelSelectMenuData: Clone, + SelectMenuType: Clone, Debug, Eq, Hash, diff --git a/twilight-validate/src/component.rs b/twilight-validate/src/component.rs index 323119d571c..5eb435f5d5e 100644 --- a/twilight-validate/src/component.rs +++ b/twilight-validate/src/component.rs @@ -5,8 +5,8 @@ use std::{ fmt::{Debug, Display, Formatter, Result as FmtResult}, }; use twilight_model::channel::message::component::{ - ActionRow, Button, ButtonStyle, Component, ComponentType, SelectMenu, SelectMenuData, - SelectMenuOption, TextInput, + ActionRow, Button, ButtonStyle, Component, ComponentType, SelectMenu, SelectMenuOption, + SelectMenuType, TextInput, }; /// Maximum number of [`Component`]s allowed inside an [`ActionRow`]. @@ -259,6 +259,9 @@ impl Display for ComponentValidationError { Display::fmt(&SELECT_MAXIMUM_VALUES_LIMIT, f) } + ComponentValidationErrorType::SelectOptionsMissing => { + f.write_str("a text select menu doesn't specify the required options field") + } ComponentValidationErrorType::SelectOptionDescriptionLength { chars } => { f.write_str("a select menu option's description is ")?; Display::fmt(&chars, f)?; @@ -399,6 +402,10 @@ pub enum ComponentValidationErrorType { /// Number of options that were provided. count: usize, }, + /// The `options` field is `None` for a [text select menu][text-select]. + /// + /// [text-select]: SelectMenuType::Text + SelectOptionsMissing, /// Number of select menu options provided is larger than /// [the maximum][`SELECT_OPTION_COUNT`]. SelectOptionCount { @@ -637,8 +644,13 @@ pub fn select_menu(select_menu: &SelectMenu) -> Result<(), ComponentValidationEr self::component_custom_id(&select_menu.custom_id)?; // There aren't any requirements for channel_types that we could validate here - if let SelectMenuData::Text(data) = &select_menu.data { - let options = &data.options; + if let SelectMenuType::Text = &select_menu.kind { + let options = select_menu + .options + .as_ref() + .ok_or(ComponentValidationError { + kind: ComponentValidationErrorType::SelectOptionsMissing, + })?; for option in options { component_select_option_label(&option.label)?; component_select_option_value(&option.value)?; @@ -1056,8 +1068,7 @@ mod tests { use super::*; use static_assertions::{assert_fields, assert_impl_all}; use std::fmt::Debug; - use twilight_model::channel::message::component::TextSelectMenuData; - use twilight_model::channel::message::ReactionType; + use twilight_model::channel::message::{component::SelectMenuType, ReactionType}; assert_fields!(ComponentValidationErrorType::ActionRowComponentCount: count); assert_fields!(ComponentValidationErrorType::ComponentCount: count); @@ -1097,19 +1108,19 @@ mod tests { }; let select_menu = SelectMenu { + channel_types: None, custom_id: "custom id 2".into(), disabled: false, + kind: SelectMenuType::Text, max_values: Some(2), min_values: Some(1), - data: SelectMenuData::Text(TextSelectMenuData { - options: Vec::from([SelectMenuOption { - default: true, - description: Some("Book 1 of the Expanse".into()), - emoji: None, - label: "Leviathan Wakes".into(), - value: "9780316129084".into(), - }]), - }), + options: Some(Vec::from([SelectMenuOption { + default: true, + description: Some("Book 1 of the Expanse".into()), + emoji: None, + label: "Leviathan Wakes".into(), + value: "9780316129084".into(), + }])), placeholder: Some("Choose a book".into()), }; From ab68bfb60e643cf950c05257be41ebdbdaf4ef55 Mon Sep 17 00:00:00 2001 From: Archer Date: Sun, 25 Jun 2023 16:07:22 +0200 Subject: [PATCH 7/7] docs(twilight-model)!: Streamline select menu documentation This commit streamlines the language in the `SelectMenu` and `SelectMenuType` documentation. In particular, it removes duplicated sections outlining required and optional fields. Moreover, it makes language describing which menu types use a specific option more in line with the rest of the library. --- .../src/channel/message/component/select_menu.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/twilight-model/src/channel/message/component/select_menu.rs b/twilight-model/src/channel/message/component/select_menu.rs index 27945c6ea5c..1cdb338f039 100644 --- a/twilight-model/src/channel/message/component/select_menu.rs +++ b/twilight-model/src/channel/message/component/select_menu.rs @@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize}; pub struct SelectMenu { /// An optional list of channel types. /// - /// This option is only used for [channel select menus](SelectMenuType::Channel). + /// This is only applicable to [channel select menus](SelectMenuType::Channel). pub channel_types: Option>, /// Developer defined identifier. pub custom_id: String, @@ -17,8 +17,6 @@ pub struct SelectMenu { /// Defaults to `false`. pub disabled: bool, /// This select menu's type. - /// - /// [Text select menus](SelectMenuType::Text) *must* also set the `options` field. pub kind: SelectMenuType, /// Maximum number of options that may be chosen. pub max_values: Option, @@ -26,7 +24,7 @@ pub struct SelectMenu { pub min_values: Option, /// A list of available options. /// - /// This value is only used and required by [text select menus](SelectMenuType::Text). + /// This is required by [text select menus](SelectMenuType::Text). pub options: Option>, /// Custom placeholder text if no option is selected. pub placeholder: Option, @@ -48,9 +46,6 @@ pub enum SelectMenuType { /// Mentionable select menus. Mentionable, /// Channel select menus. - /// - /// Select menus of this `kind` *can* use the `channel_types` field to specify which types of - /// channels are selectable. Channel, }