Skip to main content

composure/command/
view.rs

1//! Views (`#[view]` and `rsx!`): message and modal layouts written as markup, rendered from
2//! Props, with inline handlers for their components (ADR 0011).
3//!
4//! ```ignore
5//! #[view]
6//! pub fn Card(title: String, likes: u32) -> View {
7//!     rsx! {
8//!         <Container>
9//!             "## " {title.as_str()}
10//!             <Row>
11//!                 <Button primary on:click:like=move |_| Card { likes: likes + 1, title }>
12//!                     "Like (" {likes} ")"
13//!                 </Button>
14//!             </Row>
15//!         </Container>
16//!     }
17//! }
18//! ```
19
20use std::{
21    cell::Cell,
22    fmt::{self, Display, Write},
23};
24
25use crate::types::{
26    AllowedMentions, Attachment, Component, File, InteractionResponse, MessageCallbackData,
27    MessageComponentData, MessageFlags, ModalCallbackData, PartialChannel, PartialEmoji, Role,
28    Snowflake, User,
29};
30
31use super::{
32    CommandOutput, ComponentCtx, Extras, HandlerError, IntoResponse, ModalChoice, ModalCtx, Update,
33    custom_id::CustomIdError, limits,
34};
35
36/// A rendered message: Components V2 components plus the rest of the message (ephemeral,
37/// allowed mentions, files to upload), answered as a new message (or, from an inline handler, as
38/// an update of the message the component is on). Built by [`rsx!`](crate::rsx); see
39/// [Views](https://bluefrog130.github.io/composure-rs/views.html).
40///
41/// A `custom_id` that could not be built (state past 100 characters) is kept inside, and
42/// Discord's [limits](crate::types::limits) are checked when the View is answered with or sent:
43/// either surfaces as a [`ViewError`] (a [`HandlerError`] in handlers).
44#[derive(Debug, Clone)]
45#[must_use]
46pub struct View {
47    pub(crate) components: Vec<Component>,
48    pub(crate) extras: Extras,
49}
50
51impl Default for View {
52    fn default() -> Self {
53        Self::new(Vec::new())
54    }
55}
56
57impl View {
58    /// A View of `components`, as built by hand
59    pub fn new(components: Vec<Component>) -> Self {
60        Self {
61            components,
62            extras: Extras::default(),
63        }
64    }
65
66    /// The top-level components
67    pub fn components(&self) -> &[Component] {
68        &self.components
69    }
70
71    /// The files uploaded with the message: added with [`file`](Self::file), or by `<File>`,
72    /// `<Thumbnail>` and `<Item>` given a [`File`]
73    pub fn files(&self) -> &[File] {
74        self.extras.files()
75    }
76
77    /// The message flags set with [`ephemeral`](Self::ephemeral) and
78    /// [`suppress_notifications`](Self::suppress_notifications); `IS_COMPONENTS_V2` is added
79    /// when the message is sent
80    pub fn flags(&self) -> MessageFlags {
81        self.extras.flags()
82    }
83
84    /// Only the invoking user sees the message. Ignored when it updates a message, whose
85    /// ephemerality cannot change.
86    pub fn ephemeral(mut self) -> Self {
87        self.extras.inner().flags |= MessageFlags::Ephemeral;
88        self
89    }
90
91    /// The message does not trigger push and desktop notifications
92    pub fn suppress_notifications(mut self) -> Self {
93        self.extras.inner().flags |= MessageFlags::SuppressNotifications;
94        self
95    }
96
97    /// Who the message may ping
98    pub fn allowed_mentions(mut self, allowed_mentions: AllowedMentions) -> Self {
99        self.extras.inner().allowed_mentions = Some(allowed_mentions);
100        self
101    }
102
103    /// Uploads `file` with the message; show it with `<File url="attachment://<name>">`, or give
104    /// the `File` to `<File>` directly
105    pub fn file(mut self, file: File) -> Self {
106        self.extras.inner().files.push(file);
107        self
108    }
109
110    #[cfg_attr(not(feature = "rest"), allow(dead_code))]
111    pub(crate) fn mentions(&self) -> Option<&AllowedMentions> {
112        self.extras.mentions()
113    }
114
115    /// The flags a REST body sends: `IS_COMPONENTS_V2`, plus ephemeral where the Endpoint takes
116    /// it
117    #[cfg_attr(not(feature = "rest"), allow(dead_code))]
118    pub(crate) fn message_flags(&self, ephemeral: bool) -> MessageFlags {
119        let flags = MessageFlags::IsComponentsV2 | self.extras.flags();
120        if ephemeral {
121            flags
122        } else {
123            flags - MessageFlags::Ephemeral
124        }
125    }
126
127    /// Whether the View can be sent: its `custom_id`s were built and it is within Discord's
128    /// [limits](crate::types::limits)
129    pub fn check(&self) -> Result<(), ViewError> {
130        if let Some(error) = self.extras.custom_id_error() {
131            return Err(ViewError::CustomId(error.clone()));
132        }
133        limits::check_message(&self.components)
134    }
135
136    /// The components, or the error building one of them. Drops the files and flags; send a
137    /// View through REST with the bodies' `TryFrom<&View>` (`CreateMessage::try_from(&view)`)
138    /// instead.
139    pub fn into_components(self) -> Result<Vec<Component>, CustomIdError> {
140        match self.extras.into_inner().error {
141            Some(error) => Err(error),
142            None => Ok(self.components),
143        }
144    }
145
146    /// The whole message, checked: components, `IS_COMPONENTS_V2` and the other flags, allowed
147    /// mentions, files
148    pub fn into_message_data(self) -> Result<MessageCallbackData, ViewError> {
149        self.check()?;
150        let mut data = MessageCallbackData::components_v2(self.components);
151        let extras = self.extras.into_inner();
152        data.flags = Some(MessageFlags::IsComponentsV2 | extras.flags);
153        data.allowed_mentions = extras.allowed_mentions;
154        data.files = extras.files;
155        Ok(data)
156    }
157}
158
159impl From<Vec<Component>> for View {
160    fn from(components: Vec<Component>) -> Self {
161        Self::new(components)
162    }
163}
164
165/// A Components V2 message, with the View's flags, mentions and files
166impl IntoResponse for View {
167    fn into_response(self) -> InteractionResponse {
168        self.try_into_response()
169            .unwrap_or_else(|error| error.response())
170    }
171
172    fn try_into_response(self) -> Result<InteractionResponse, HandlerError> {
173        self.into_message_data()
174            .map(InteractionResponse::ChannelMessageWithSource)
175            .map_err(HandlerError::new)
176    }
177}
178
179/// Why a [`View`] or [`ModalView`] cannot be sent
180#[derive(Debug, Clone, PartialEq, Eq)]
181#[non_exhaustive]
182pub enum ViewError {
183    /// A `custom_id` could not be built
184    CustomId(CustomIdError),
185    /// Past one of Discord's [limits](crate::types::limits)
186    Limit {
187        /// What was counted, e.g. `"components in a message"`
188        what: &'static str,
189        /// The fewest allowed
190        min: usize,
191        /// The most allowed
192        max: usize,
193        /// How many there were
194        found: usize,
195    },
196    /// Two of something Discord requires to be unique share a value
197    Duplicate {
198        /// What repeats, e.g. `"option value in a select"`
199        what: &'static str,
200        /// The repeated value
201        value: String,
202    },
203}
204
205impl Display for ViewError {
206    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
207        match self {
208            ViewError::CustomId(error) => error.fmt(f),
209            ViewError::Limit {
210                what,
211                min: 0,
212                max,
213                found,
214            } => write!(f, "{found} {what}, Discord allows at most {max}"),
215            ViewError::Limit {
216                what,
217                min,
218                max,
219                found,
220            } => write!(f, "{found} {what}, Discord allows {min} to {max}"),
221            ViewError::Duplicate { what, value } => {
222                write!(
223                    f,
224                    "{what} {value:?} repeats, Discord requires each to be unique"
225                )
226            }
227        }
228    }
229}
230
231impl std::error::Error for ViewError {}
232
233impl From<CustomIdError> for ViewError {
234    fn from(error: CustomIdError) -> Self {
235        ViewError::CustomId(error)
236    }
237}
238
239/// A rendered modal, built by `rsx!` from a `<Modal>` root
240#[derive(Debug, Clone)]
241#[must_use]
242pub struct ModalView {
243    pub(crate) data: ModalCallbackData,
244    pub(crate) error: Option<CustomIdError>,
245}
246
247impl ModalView {
248    /// The modal: title, `custom_id`, components
249    pub fn data(&self) -> &ModalCallbackData {
250        &self.data
251    }
252
253    /// Whether the modal can be opened: its `custom_id`s were built and it is within Discord's
254    /// [limits](crate::types::limits)
255    pub fn check(&self) -> Result<(), ViewError> {
256        if let Some(error) = &self.error {
257            return Err(ViewError::CustomId(error.clone()));
258        }
259        limits::check_modal(&self.data)
260    }
261
262    /// The modal, or the error building its `custom_id`
263    pub fn into_data(self) -> Result<ModalCallbackData, CustomIdError> {
264        match self.error {
265            Some(error) => Err(error),
266            None => Ok(self.data),
267        }
268    }
269}
270
271/// The modal response that opens it
272impl IntoResponse for ModalView {
273    fn into_response(self) -> InteractionResponse {
274        self.try_into_response()
275            .unwrap_or_else(|error| error.response())
276    }
277
278    fn try_into_response(self) -> Result<InteractionResponse, HandlerError> {
279        self.check().map_err(HandlerError::new)?;
280        Ok(InteractionResponse::Modal(self.data))
281    }
282}
283
284/// What renders to a [`View`]: a View, or a `#[view]`'s Props. Placed in markup, its
285/// components go in place and its files, flags and mentions join the enclosing View.
286#[diagnostic::on_unimplemented(
287    message = "`{Self}` is not a View",
288    label = "not a View",
289    note = "a View is `rsx!`'s output or a #[view]'s Props"
290)]
291pub trait IntoView {
292    /// The View
293    fn into_view(self) -> View;
294}
295
296impl IntoView for View {
297    fn into_view(self) -> View {
298        self
299    }
300}
301
302/// What `<File>`, `<Thumbnail>` and `<Item>` show: a URL (or `attachment://<filename>`), a
303/// [`File`] to upload with the message (its `attachment://` URL is set for you), or a received
304/// [`Attachment`]
305#[diagnostic::on_unimplemented(
306    message = "`{Self}` is not media",
307    note = "use a URL (&str or String), a File to upload with the message, or an &Attachment"
308)]
309pub trait IntoMedia {
310    /// The media's URL; a file is registered on the View being built
311    fn into_media(self, extras: &mut Extras) -> String;
312}
313
314impl IntoMedia for &str {
315    fn into_media(self, _extras: &mut Extras) -> String {
316        self.to_string()
317    }
318}
319
320impl IntoMedia for String {
321    fn into_media(self, _extras: &mut Extras) -> String {
322        self
323    }
324}
325
326impl IntoMedia for &String {
327    fn into_media(self, _extras: &mut Extras) -> String {
328        self.clone()
329    }
330}
331
332/// Uploaded with the message, shown as `attachment://<name>`
333impl IntoMedia for File {
334    fn into_media(self, extras: &mut Extras) -> String {
335        let url = format!("attachment://{}", self.name);
336        extras.inner().files.push(self);
337        url
338    }
339}
340
341/// An attachment Discord already has, by its URL
342impl IntoMedia for &Attachment {
343    fn into_media(self, _extras: &mut Extras) -> String {
344        self.url.clone()
345    }
346}
347
348/// What a `{expr}` in text-only markup (`<Text>`, a button label, an option) can be: strings,
349/// numbers, and `Option`s of them; call `.to_string()` on other `Display` types
350#[diagnostic::on_unimplemented(
351    message = "`{Self}` cannot be placed in text",
352    note = "use &str, String, a number, or an Option of one; call `.to_string()` on other Display types"
353)]
354pub trait IntoText {
355    /// Appends `self` to the text being built
356    fn push_text(self, text: &mut String);
357}
358
359impl IntoText for &str {
360    fn push_text(self, text: &mut String) {
361        text.push_str(self);
362    }
363}
364
365impl IntoText for String {
366    fn push_text(self, text: &mut String) {
367        text.push_str(&self);
368    }
369}
370
371impl IntoText for &String {
372    fn push_text(self, text: &mut String) {
373        text.push_str(self);
374    }
375}
376
377impl<T: IntoText> IntoText for Option<T> {
378    fn push_text(self, text: &mut String) {
379        if let Some(value) = self {
380            value.push_text(text);
381        }
382    }
383}
384
385macro_rules! text {
386    ($($ty:ty),*) => {$(
387        impl IntoText for $ty {
388            fn push_text(self, text: &mut String) {
389                let _ = write!(text, "{self}");
390            }
391        }
392    )*};
393}
394
395text!(
396    char,
397    u8,
398    u16,
399    u32,
400    u64,
401    u128,
402    usize,
403    i8,
404    i16,
405    i32,
406    i64,
407    i128,
408    isize,
409    f32,
410    f64,
411    Snowflake,
412    std::fmt::Arguments<'_>
413);
414
415/// An attribute value in `rsx!` markup, converted to the component field's type: `"text"` for
416/// a `String` or `Option<String>`, a `T` for an `Option<T>`, an emoji as `"👍"`.
417#[diagnostic::on_unimplemented(
418    message = "`{Self}` is not a valid value for this attribute",
419    note = "the attribute expects `{T}`"
420)]
421pub trait IntoAttr<T> {
422    /// The field value
423    fn into_attr(self) -> T;
424}
425
426impl<T> IntoAttr<T> for T {
427    fn into_attr(self) -> T {
428        self
429    }
430}
431
432impl<T> IntoAttr<Option<T>> for T {
433    fn into_attr(self) -> Option<T> {
434        Some(self)
435    }
436}
437
438impl IntoAttr<String> for &str {
439    fn into_attr(self) -> String {
440        self.to_string()
441    }
442}
443
444impl IntoAttr<Option<String>> for &str {
445    fn into_attr(self) -> Option<String> {
446        Some(self.to_string())
447    }
448}
449
450impl IntoAttr<String> for &String {
451    fn into_attr(self) -> String {
452        self.clone()
453    }
454}
455
456impl IntoAttr<Option<String>> for &String {
457    fn into_attr(self) -> Option<String> {
458        Some(self.clone())
459    }
460}
461
462/// A unicode emoji
463impl IntoAttr<Option<PartialEmoji>> for &str {
464    fn into_attr(self) -> Option<PartialEmoji> {
465        Some(PartialEmoji {
466            id: None,
467            name: Some(self.to_string()),
468            animated: None,
469        })
470    }
471}
472
473/// What an inline handler can return to re-render: a View or a message View's Props (answered
474/// as an update of the message the component is on), or a `Result` of one
475#[diagnostic::on_unimplemented(message = "`{Self}` is not a message View")]
476pub trait Rerender {
477    /// The response updating the component's message with this View
478    fn rerender(self) -> Result<InteractionResponse, HandlerError>;
479}
480
481impl Rerender for View {
482    fn rerender(self) -> Result<InteractionResponse, HandlerError> {
483        Update(self).try_into_response()
484    }
485}
486
487impl<T: Rerender, E: Display + 'static> Rerender for Result<T, E> {
488    fn rerender(self) -> Result<InteractionResponse, HandlerError> {
489        self.map_err(super::response::into_handler_error)?
490            .rerender()
491    }
492}
493
494/// An inline handler's output, answered by [`Rerender`] when it is one, else as a
495/// [`CommandOutput`]; picked by method resolution in the code `#[view]` generates:
496/// `(&&HandlerOutput::new(out)).answer()`.
497#[doc(hidden)]
498pub struct HandlerOutput<T>(Cell<Option<T>>);
499
500impl<T> HandlerOutput<T> {
501    pub fn new(output: T) -> Self {
502        Self(Cell::new(Some(output)))
503    }
504
505    fn take(&self) -> T {
506        self.0.take().expect("handler output answered once")
507    }
508}
509
510#[doc(hidden)]
511pub trait ViaRerender {
512    fn answer(&self) -> Result<InteractionResponse, HandlerError>;
513}
514
515impl<T: Rerender> ViaRerender for &HandlerOutput<T> {
516    fn answer(&self) -> Result<InteractionResponse, HandlerError> {
517        self.take().rerender()
518    }
519}
520
521#[doc(hidden)]
522pub trait ViaOutput {
523    fn answer(&self) -> Result<InteractionResponse, HandlerError>;
524}
525
526impl<T: CommandOutput> ViaOutput for HandlerOutput<T> {
527    fn answer(&self) -> Result<InteractionResponse, HandlerError> {
528        self.take().into_result()
529    }
530}
531
532/// A View as the `#[app]` listing it sees it: its `custom_id` prefix, whether it has inline handlers, and
533/// the Views its markup places, so a forgotten sub-View fails to compile
534#[derive(Debug, Clone, Copy)]
535pub struct ViewInfo {
536    /// The `custom_id` prefix of its inline handlers
537    pub prefix: &'static str,
538    /// Whether it has inline handlers, so must be listed in an App
539    pub handled: bool,
540    /// The Views its markup places
541    pub views: &'static [ViewInfo],
542}
543
544/// Implemented by `#[view]`
545pub trait ViewDefinition {
546    /// What the App listing it checks
547    const INFO: ViewInfo;
548}
549
550/// Runs a View's inline handlers; `None` when the handler named in the `custom_id` is not one of
551/// them. Implemented by `#[view]`; Views with handlers are listed in an `#[app]`.
552pub trait ViewHandler<S>: ViewDefinition {
553    /// Runs the `on:click` or `on:select` handler named in the `custom_id`. `fields` is the
554    /// `custom_id` after `prefix:`: the handler name, then its captured Props
555    fn component(
556        _ctx: ComponentCtx<'_, S>,
557        _fields: Option<&str>,
558    ) -> impl Future<Output = Option<Result<InteractionResponse, HandlerError>>> + Send {
559        async { None }
560    }
561
562    /// Runs the `on:submit` handler named in the `custom_id`; `fields` as for [`component`](Self::component)
563    fn modal(
564        _ctx: ModalCtx<'_, S>,
565        _fields: Option<&str>,
566    ) -> impl Future<Output = Option<Result<InteractionResponse, HandlerError>>> + Send {
567        async { None }
568    }
569}
570
571/// A typed select handler parameter (`on:select:name=|ctx, picked: Vec<Color>| ..`): the
572/// selected values as `String`, `Snowflake`, resolved `User`, `Role` or `PartialChannel`, or a
573/// `#[derive(Choice)]` enum; one (required), `Option` or `Vec`.
574#[diagnostic::on_unimplemented(
575    message = "`{Self}` cannot hold select values",
576    note = "use String, Snowflake, User, Role, PartialChannel, Attachment, or a #[derive(Choice)] enum, optionally in Option or Vec"
577)]
578pub trait SelectValue: Sized {
579    /// Several selections (a `Vec`)
580    const MANY: bool = false;
581
582    /// Reads the selected values; an error message when they do not fit the type
583    fn extract(data: &MessageComponentData) -> Result<Self, String>;
584}
585
586fn selected(data: &MessageComponentData) -> &[String] {
587    data.values.as_deref().unwrap_or_default()
588}
589
590macro_rules! select_value {
591    ($ty:ty, |$value:ident, $data:ident| $parse:expr) => {
592        impl SelectValue for $ty {
593            fn extract(data: &MessageComponentData) -> Result<Self, String> {
594                let parse =
595                    |$value: &str, $data: &MessageComponentData| -> Result<$ty, String> { $parse };
596                match selected(data).first() {
597                    Some(value) => parse(value, data),
598                    None => Err("nothing selected".to_string()),
599                }
600            }
601        }
602
603        impl SelectValue for Option<$ty> {
604            fn extract(data: &MessageComponentData) -> Result<Self, String> {
605                match selected(data).first() {
606                    Some(_) => <$ty as SelectValue>::extract(data).map(Some),
607                    None => Ok(None),
608                }
609            }
610        }
611
612        impl SelectValue for Vec<$ty> {
613            const MANY: bool = true;
614
615            fn extract(data: &MessageComponentData) -> Result<Self, String> {
616                let parse =
617                    |$value: &str, $data: &MessageComponentData| -> Result<$ty, String> { $parse };
618                selected(data)
619                    .iter()
620                    .map(|value| parse(value, data))
621                    .collect()
622            }
623        }
624    };
625}
626
627select_value!(String, |value, _data| Ok(value.to_string()));
628
629select_value!(Snowflake, |value, _data| value
630    .parse()
631    .map_err(|_| format!("`{value}` is not an id")));
632
633select_value!(User, |value, data| resolve(value, data, |r| r
634    .users
635    .as_ref()));
636
637select_value!(Role, |value, data| resolve(value, data, |r| r
638    .roles
639    .as_ref()));
640
641select_value!(PartialChannel, |value, data| resolve(value, data, |r| r
642    .channels
643    .as_ref()));
644
645select_value!(Attachment, |value, data| resolve(value, data, |r| r
646    .attachments
647    .as_ref()));
648
649fn resolve<T: Clone>(
650    value: &str,
651    data: &MessageComponentData,
652    get: impl Fn(&crate::types::ResolvedData) -> Option<&crate::types::SnowflakeMap<T>>,
653) -> Result<T, String> {
654    let id: Snowflake = value
655        .parse()
656        .map_err(|_| format!("`{value}` is not an id"))?;
657    data.resolved
658        .as_ref()
659        .and_then(get)
660        .and_then(|map| map.get(&id))
661        .cloned()
662        .ok_or_else(|| format!("{id} is not in the resolved data"))
663}
664
665fn choice<C: ModalChoice>(value: &str) -> Result<C, String> {
666    C::from_modal_value(value).ok_or_else(|| format!("`{value}` is not a choice"))
667}
668
669impl<C: ModalChoice> SelectValue for C {
670    fn extract(data: &MessageComponentData) -> Result<Self, String> {
671        match selected(data).first() {
672            Some(value) => choice(value),
673            None => Err("nothing selected".to_string()),
674        }
675    }
676}
677
678impl<C: ModalChoice> SelectValue for Option<C> {
679    fn extract(data: &MessageComponentData) -> Result<Self, String> {
680        selected(data)
681            .first()
682            .map(|value| choice(value))
683            .transpose()
684    }
685}
686
687impl<C: ModalChoice> SelectValue for Vec<C> {
688    const MANY: bool = true;
689
690    fn extract(data: &MessageComponentData) -> Result<Self, String> {
691        selected(data).iter().map(|value| choice(value)).collect()
692    }
693}
694
695/// Reads a typed select handler parameter; used by `#[view]`
696#[doc(hidden)]
697pub fn select_value<T: SelectValue>(data: &MessageComponentData) -> Result<T, HandlerError> {
698    T::extract(data).map_err(|e| HandlerError::invalid_option(&data.custom_id, e))
699}
700
701/// Reads a typed modal submit parameter; used by `#[view]`
702#[doc(hidden)]
703pub fn modal_value<T: super::ModalValue>(
704    data: &crate::types::ModalSubmitData,
705    name: &str,
706) -> Result<T, HandlerError> {
707    T::extract(data.find(name), data).map_err(|e| HandlerError::invalid_option(name, e))
708}
709
710/// The options of a select or group from a `#[derive(Choice)]` enum, as `(label, value)`
711#[doc(hidden)]
712pub fn choice_options<C: ModalChoice>() -> &'static [(&'static str, &'static str)] {
713    C::OPTIONS
714}
715
716/// `required: false` for an input whose submit parameter is optional
717#[doc(hidden)]
718pub const fn required<T: super::ModalValue>() -> Option<bool> {
719    if T::REQUIRED { None } else { Some(false) }
720}