use composure::{
Choice, app,
command::{Ephemeral, HandlerError, IntoResponse, ModalView, Update, View, dispatch},
component, rsx,
types::{
AllowedMentions, ButtonStyle, ChannelType, Component, File, Interaction,
InteractionResponse, SelectDefaultValue, SeparatorSpacing, Snowflake, TextInputStyle,
},
view,
};
use futures::executor::block_on;
use serde_json::{Value, json};
#[component("count")]
fn count(n: u32) -> Update<String> {
Update(format!("{}", n + 1))
}
#[derive(Choice, Debug, PartialEq)]
enum Color {
Red,
Green,
#[choice(name = "Deep blue")]
Blue,
}
#[view]
pub fn Counter(count: u32, #[prop(default = 1)] step: u32, note: Option<String>) -> View {
rsx! {
<Text>"Clicked " {count} " times" {note.as_deref().map(|n| format!(" ({n})"))}</Text>
<Row>
<Button primary on:click:add=move |_| Counter { count: count + step, step, note }>
"+" {step}
</Button>
<Button danger on:click:reset=move |_| Ephemeral("reset")>"Reset"</Button>
</Row>
}
}
#[view(prefix = "c")]
pub fn Card(title: String, items: Vec<u32>) -> View {
rsx! {
<Container accent_color=0x00ff00 spoiler>
"## " {&title}
<Separator large divider=false/>
{items.iter().map(|&id| rsx! { <ItemRow id=id/> })}
<Section>
<Text>"Docs"</Text>
<Button url="https://example.com">"Open"</Button>
</Section>
<Gallery>
<Item url="https://example.com/a.png" description="a"/>
</Gallery>
</Container>
}
}
#[view]
pub fn ItemRow(id: u32) -> View {
rsx! {
<Section>
"Item " {id}
<Button on:click:remove=move |_| Ephemeral(format!("removed {id}"))>"Remove"</Button>
</Section>
}
}
#[view]
pub fn Palette(picked: Option<String>) -> View {
rsx! {
<Text>"Picked: " {picked.as_deref().unwrap_or("nothing")}</Text>
<Row>
<StringSelect placeholder="Colors" options=Color
on:select:pick={move |_ctx, colors: Vec<Color>| Palette {
picked: Some(format!("{colors:?}")),
}}
/>
</Row>
}
}
#[view]
pub fn Feedback(topic: String) -> ModalView {
rsx! {
<Modal title="Feedback" on:submit:send={move |_ctx, details: String, color: Option<Color>, contact: bool| {
Ephemeral(format!("{topic}: {details} {color:?} {contact}"))
}}>
<Text>"About " {&topic}</Text>
<Label text="Details" description="Tell us">
<TextInput name="details" paragraph max_len=100/>
</Label>
<Label text="Color">
<RadioGroup name="color" options=Color/>
</Label>
<Label text="Contact me">
<Checkbox name="contact"/>
</Label>
</Modal>
}
}
#[view]
pub fn Opener() -> View {
rsx! {
<Row>
<Button on:click:open=move |_| Feedback { topic: "bugs".to_string() }>"Open"</Button>
<Button on:click=count(41)>"Count"</Button>
</Row>
}
}
#[view]
pub fn Panel(title: String, children: View) -> View {
rsx! {
<Container>
"### " {title}
{children}
</Container>
}
}
#[composure::command]
fn counter() -> Counter {
Counter::builder().count(0).build()
}
#[app(handlers(counter, count, Counter, Card, ItemRow, Palette, Feedback, Opener))]
pub struct Bot;
fn json(response: InteractionResponse) -> Value {
serde_json::to_value(response).unwrap()
}
fn interaction(t: u8, data: Value) -> Interaction {
serde_json::from_value(json!({
"id": "1", "application_id": "2", "type": t, "token": "t", "version": 1,
"user": { "id": "3", "username": "ana", "discriminator": "0", "global_name": null, "avatar": null },
"data": data,
}))
.unwrap()
}
fn click(custom_id: &str, values: Option<Vec<&str>>) -> Value {
let mut data = json!({ "custom_id": custom_id, "component_type": 2 });
if let Some(values) = values {
data["component_type"] = json!(3);
data["values"] = json!(values);
}
let interaction = interaction(3, data);
json(block_on(dispatch(interaction, &Bot, &())).unwrap())
}
fn custom_ids(value: &Value, out: &mut Vec<String>) {
match value {
Value::Object(map) => {
if let Some(Value::String(id)) = map.get("custom_id") {
out.push(id.clone());
}
map.values().for_each(|v| custom_ids(v, out));
}
Value::Array(items) => items.iter().for_each(|v| custom_ids(v, out)),
_ => {}
}
}
fn ids(value: &Value) -> Vec<String> {
let mut out = Vec::new();
custom_ids(value, &mut out);
out
}
#[test]
fn static_markup_serializes() {
let view = rsx! {
<Container accent_color=0x123456>
"## Hi " {3}
<Row>
<Button success custom_id="x" disabled emoji="👍">"Go"</Button>
</Row>
</Container>
};
assert_eq!(
json(view.into_response()),
json!({ "type": 4, "data": { "flags": 1 << 15, "components": [{
"type": 17,
"accent_color": 0x123456,
"components": [
{ "type": 10, "content": "## Hi 3" },
{ "type": 1, "components": [{
"type": 2, "style": 3, "label": "Go", "custom_id": "x", "disabled": true,
"emoji": { "id": null, "name": "👍", "animated": null },
}]},
],
}]}})
);
}
#[test]
fn command_sends_view_with_inline_handler_ids() {
let interaction = interaction(2, json!({ "id": "9", "name": "counter", "type": 1 }));
let response = json(block_on(dispatch(interaction, &Bot, &())).unwrap());
assert_eq!(response["type"], 4);
assert_eq!(
response["data"]["components"][0]["content"],
"Clicked 0 times"
);
assert_eq!(ids(&response), ["counter:add:0:1:", "counter:reset"]);
}
#[test]
fn inline_handler_rerenders_with_new_props() {
let response = click("counter:add:4:2:hi", None);
assert_eq!(response["type"], 7, "an update of the message");
assert_eq!(
response["data"]["components"][0]["content"],
"Clicked 6 times (hi)"
);
assert_eq!(ids(&response), ["counter:add:6:2:hi", "counter:reset"]);
let reset = click("counter:reset", None);
assert_eq!(reset["type"], 4);
assert_eq!(reset["data"]["content"], "reset");
}
#[test]
fn optional_props_and_text_joining() {
let view = Counter::builder()
.count(1)
.note("hey")
.step(5)
.build()
.render();
let value = json(view.into_response());
assert_eq!(
value["data"]["components"][0]["content"],
"Clicked 1 times (hey)"
);
assert_eq!(
value["data"]["components"][1]["components"][0]["label"],
"+5"
);
}
#[test]
fn sub_views_route_their_own_handlers() {
let card = Card::builder()
.title("Things")
.items(vec![7, 8])
.build()
.render();
let value = json(card.into_response());
let container = &value["data"]["components"][0];
assert_eq!(container["accent_color"], 0x00ff00);
assert_eq!(container["spoiler"], true);
assert_eq!(container["components"][0]["content"], "## Things");
assert_eq!(
container["components"][1],
json!({ "type": 14, "divider": false, "spacing": 2 })
);
assert_eq!(
container["components"][2]["components"][0]["content"],
"Item 7"
);
assert_eq!(
container["components"][4]["accessory"]["url"],
"https://example.com"
);
assert_eq!(container["components"][4]["accessory"]["style"], 5);
assert_eq!(
container["components"][5]["items"][0]["media"]["url"],
"https://example.com/a.png"
);
assert_eq!(ids(&value), ["item_row:remove:7", "item_row:remove:8"]);
assert_eq!(
click("item_row:remove:8", None)["data"]["content"],
"removed 8"
);
}
#[test]
fn select_values_are_typed() {
let value = json(Palette::builder().build().into_response());
let select = &value["data"]["components"][1]["components"][0];
assert_eq!(select["custom_id"], "palette:pick");
assert_eq!(
select["options"][2],
json!({ "label": "Deep blue", "value": "Blue" })
);
assert_eq!(select["max_values"], 3, "a Vec allows every option");
let picked = click("palette:pick", Some(vec!["Red", "Blue"]));
assert_eq!(picked["type"], 7);
assert_eq!(
picked["data"]["components"][0]["content"],
"Picked: [Red, Blue]"
);
}
#[test]
fn modal_view_opens_and_reads_typed_fields() {
let opened = click("opener:open", None);
assert_eq!(opened["type"], 9);
let data = &opened["data"];
assert_eq!(data["custom_id"], "feedback:send:bugs");
assert_eq!(data["title"], "Feedback");
assert_eq!(data["components"][0]["content"], "About bugs");
let details = &data["components"][1];
assert_eq!(details["label"], "Details");
assert_eq!(details["description"], "Tell us");
assert_eq!(details["component"]["custom_id"], "details");
assert_eq!(details["component"]["style"], 2);
assert_eq!(details["component"]["max_length"], 100);
assert!(
details["component"].get("required").is_none(),
"String is required"
);
assert_eq!(
data["components"][2]["component"]["required"], false,
"Option is not"
);
let submit = interaction(
5,
json!({ "custom_id": "feedback:send:bugs", "components": [
{ "type": 18, "component": { "type": 4, "custom_id": "details", "value": "broken" } },
{ "type": 18, "component": { "type": 21, "custom_id": "color", "value": "Green" } },
{ "type": 18, "component": { "type": 23, "custom_id": "contact", "value": true } },
]}),
);
let response = json(block_on(dispatch(submit, &Bot, &())).unwrap());
assert_eq!(response["data"]["content"], "bugs: broken Some(Green) true");
}
#[test]
fn reference_handlers_use_their_custom_id() {
let value = json(Opener::builder().build().into_response());
assert_eq!(ids(&value), ["opener:open", "count:41"]);
assert_eq!(click("count:41", None)["data"]["content"], "42");
}
#[test]
fn children_prop_takes_markup() {
let view = rsx! {
<Panel title="Box">
<Text>"inside"</Text>
</Panel>
};
let value = json(view.into_response());
let container = &value["data"]["components"][0];
assert_eq!(container["components"][0]["content"], "### Box");
assert_eq!(container["components"][1]["content"], "inside");
}
#[test]
fn unknown_handler_names_fall_through() {
let response = click("counter:gone", None);
assert_eq!(
response["data"]["flags"], 64,
"the ephemeral not-handled message"
);
}
#[test]
fn too_long_custom_id_is_an_error() {
let result = Feedback::builder()
.topic("y".repeat(120))
.build()
.try_into_response();
let error: HandlerError = result.unwrap_err();
assert!(error.to_string().contains("custom_id is"), "{error}");
}
#[view]
pub fn Report(name: String) -> View {
rsx! { <File src={File::new(name, b"a,b".to_vec())}/> }
}
#[test]
fn files_are_uploaded_with_the_view() {
let view = rsx! { <File src={File::new("a.txt", b"hi".to_vec())} spoiler/> };
let response = view.into_response();
assert_eq!(response.files().len(), 1);
assert_eq!(response.files()[0].name, "a.txt");
let value = json(response);
assert_eq!(
value["data"]["components"][0]["file"]["url"],
"attachment://a.txt"
);
assert_eq!(value["data"]["flags"], 1 << 15);
}
#[test]
fn sub_view_files_reach_the_response() {
let view = rsx! {
<Container>
<Report name="r.csv"/>
<Section>
<Text>"Avatar"</Text>
<Thumbnail src={File::new("me.png", vec![1])}/>
</Section>
</Container>
}
.file(File::new("extra.txt", vec![2]));
let names: Vec<_> = view.files().iter().map(|file| file.name.as_str()).collect();
assert_eq!(names, ["r.csv", "me.png", "extra.txt"]);
let response = view.into_response();
assert_eq!(response.files().len(), 3);
let value = json(response);
let container = &value["data"]["components"][0];
assert_eq!(
container["components"][0]["file"]["url"],
"attachment://r.csv"
);
assert_eq!(
container["components"][1]["accessory"]["media"]["url"],
"attachment://me.png"
);
}
#[test]
fn ephemeral_views_keep_components_v2() {
let view = rsx! { <Text>"Only you"</Text> }
.ephemeral()
.suppress_notifications();
let value = json(view.clone().into_response());
assert_eq!(value["data"]["flags"], (1 << 15) | (1 << 6) | (1 << 12));
let value = json(Update(view).into_response());
assert_eq!(value["type"], 7);
assert_eq!(value["data"]["flags"], (1 << 15) | (1 << 12));
}
#[test]
fn allowed_mentions_are_sent() {
let view = rsx! { <Text>"<@1>"</Text> }.allowed_mentions(AllowedMentions {
parse: vec![],
roles: vec![],
users: vec![],
replied_user: false,
});
let value = json(view.into_response());
assert_eq!(value["data"]["allowed_mentions"]["parse"], json!([]));
}
#[test]
fn views_past_discords_limits_are_errors() {
let buttons: Vec<_> = (0..6)
.map(|i| rsx! { <Button custom_id={i.to_string()}>"b"</Button> })
.collect();
let error = rsx! { <Row>{buttons}</Row> }
.try_into_response()
.unwrap_err();
assert_eq!(
error.to_string(),
"6 buttons in a row, Discord allows at most 5"
);
}
#[test]
fn every_attribute_compiles() {
let file = || File::new("a.png", vec![1]);
let _ = rsx! {
<Container id=1 accent_color=0x123456 spoiler>
<Section id=2 accessory={Component::text("x")}><Text id=3 content="c"/></Section>
<Section><Text>"t"</Text><Thumbnail id=4 src={file()} description="d" spoiler/></Section>
<Section><Text>"t"</Text><Thumbnail url="https://example.com/a.png"/></Section>
<Gallery id=5>
<Item src="https://example.com/a.png" description="d" spoiler/>
<Item url="https://example.com/b.png"/>
</Gallery>
<File id=6 src={file()} spoiler/>
<File url="attachment://a.png"/>
<Separator id=7 small divider=false/>
<Separator large spacing={SeparatorSpacing::Small}/>
<Row id=8>
<Button id=9 primary on:click={count(1)} emoji="👍" disabled=false>"a"</Button>
<Button secondary custom_id="b" label="b"/>
<Button success custom_id="c">"c"</Button>
<Button danger custom_id="d">"d"</Button>
<Button style={ButtonStyle::Primary} url="https://example.com">"e"</Button>
</Row>
<Row><Button sku_id={Snowflake::from_u64(1)}/></Row>
<Row>
<StringSelect id=10 on:select={count(2)} options=Color placeholder="p" min=1 max=2 disabled=false>
<Option value="x" label="X" description="d" emoji="🎨" default/>
</StringSelect>
</Row>
<Row><StringSelect custom_id="s" min_values=1 max_values=2/></Row>
<Row><UserSelect custom_id="u" default_values={vec![SelectDefaultValue::user(Snowflake::from_u64(1))]}/></Row>
<Row><RoleSelect custom_id="r"/></Row>
<Row><MentionableSelect custom_id="m"/></Row>
<Row><ChannelSelect custom_id="c" channel_types={vec![ChannelType::GuildText]}/></Row>
</Container>
};
let _ = rsx! {
<Modal title="t" on:submit={count(3)}>
<Label id=11 text="a" description="d">
<TextInput id=12 name="a" paragraph min_len=1 max_len=2 placeholder="p" value="v" required/>
</Label>
<Label label="b"><TextInput name="b" style={TextInputStyle::Short} min_length=1 max_length=2/></Label>
<Label text="c"><RadioGroup id=13 name="c" options=Color required/></Label>
<Label text="d">
<CheckboxGroup id=14 name="d" options=Color min=0 max=1 min_values=0 max_values=1 required/>
</Label>
<Label text="e">
<FileUpload id=15 name="e" min=0 max=1 min_values=0 max_values=1 file_types={vec!["image".to_string()]} required/>
</Label>
</Modal>
};
let _ = rsx! {
<Modal title="t" custom_id="m">
<Label text="a"><Checkbox id=16 name="a" default/></Label>
<Label text="b"><StringSelect name="b" required=false/></Label>
</Modal>
};
}
fn tags_and_attributes() -> Vec<(String, Vec<String>)> {
let source = include_str!("views.rs").replace("\r\n", "\n");
let markup = source
.split_once("// every attribute: begin")
.and_then(|(_, rest)| rest.split_once("// every attribute: end"))
.expect("the markup")
.0
.to_string();
let chars: Vec<char> = markup.chars().collect();
let mut tags: Vec<(String, Vec<String>)> = Vec::new();
let name = |i: &mut usize| {
let start = *i;
while *i < chars.len() && (chars[*i].is_alphanumeric() || chars[*i] == '_') {
*i += 1;
}
chars[start..*i].iter().collect::<String>()
};
let mut i = 0;
while i < chars.len() {
if chars[i] != '<' || !chars.get(i + 1).is_some_and(|c| c.is_uppercase()) {
i += 1;
continue;
}
i += 1;
let tag = name(&mut i);
let mut attrs = Vec::new();
loop {
while chars[i].is_whitespace() {
i += 1;
}
if chars[i] == '>' || chars[i] == '/' {
break;
}
let attr = name(&mut i);
while chars[i] == ':' {
i += 1;
name(&mut i);
}
attrs.push(attr);
if chars[i] == '=' {
i += 1;
match chars[i] {
'"' => {
i += 1;
while chars[i] != '"' {
i += 1;
}
i += 1;
}
'{' => {
let mut depth = 0;
loop {
match chars[i] {
'{' => depth += 1,
'}' => depth -= 1,
_ => {}
}
i += 1;
if depth == 0 {
break;
}
}
}
_ => {
while !chars[i].is_whitespace() && chars[i] != '>' && chars[i] != '/' {
i += 1;
}
}
}
}
}
match tags.iter_mut().find(|(t, _)| *t == tag) {
Some((_, known)) => {
for attr in attrs {
if !known.contains(&attr) {
known.push(attr);
}
}
}
None => tags.push((tag, attrs)),
}
}
tags
}
#[test]
fn the_guide_documents_every_tag_and_attribute() {
let tags = tags_and_attributes();
assert_eq!(tags.len(), 23, "every built-in tag: {tags:?}");
let guide = include_str!("../../../book/src/views.md").replace("\r\n", "\n");
let reference = guide
.split_once("## Tag reference")
.and_then(|(_, rest)| rest.split_once("\n## "))
.map(|(reference, _)| reference)
.expect("a tag reference");
let (intro, _) = reference.split_once("#### ").unwrap();
let sections: Vec<&str> = reference.split("#### ").skip(1).collect();
let section = |tag: &str| {
sections
.iter()
.find(|section| {
section
.lines()
.next()
.unwrap()
.contains(&format!("`<{tag}>`"))
})
.copied()
.unwrap_or_else(|| panic!("no section for `<{tag}>`"))
};
for (tag, attrs) in &tags {
let mut text = section(tag).to_string();
match tag.as_str() {
"UserSelect" | "RoleSelect" | "MentionableSelect" | "ChannelSelect" => {
text += section("StringSelect")
}
"TextInput" | "RadioGroup" | "CheckboxGroup" | "Checkbox" | "FileUpload" => {
text += section("Label")
}
_ => {}
}
for attr in attrs {
let documented = match attr.as_str() {
"id" => intro.contains("`id`"),
"on" => text.contains("`on:"),
attr => text.contains(&format!("`{attr}`")) || text.contains(&format!("`{attr}=")),
};
assert!(documented, "`{attr}` of `<{tag}>` is not in the guide");
}
}
}