From 04480eda1b715070ba2f257a2fde7f68ba91fd66 Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Wed, 7 Aug 2024 22:49:54 -0700 Subject: [PATCH] Add message slash commands (#317) --- Cargo.lock | 2 + Cargo.toml | 2 + docs/iamb.1 | 37 +++++ src/message/compose.rs | 290 ++++++++++++++++++++++++++++++++++++++++ src/message/mod.rs | 110 +++------------ src/notifications.rs | 4 +- src/windows/room/mod.rs | 8 +- 7 files changed, 352 insertions(+), 101 deletions(-) create mode 100644 src/message/compose.rs diff --git a/Cargo.lock b/Cargo.lock index e3fa0d3..6d50162 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2093,6 +2093,7 @@ dependencies = [ name = "iamb" version = "0.0.10-alpha.1" dependencies = [ + "anyhow", "bitflags 2.5.0", "chrono", "clap", @@ -2114,6 +2115,7 @@ dependencies = [ "mime_guess", "modalkit", "modalkit-ratatui", + "nom", "notify-rust", "open", "pretty_assertions", diff --git a/Cargo.toml b/Cargo.toml index 82417db..97059e9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ default-features = false features = ["build", "git", "gitcl",] [dependencies] +anyhow = "1.0" bitflags = "^2.3" chrono = "0.4" clap = {version = "~4.3", features = ["derive"]} @@ -41,6 +42,7 @@ libc = "0.2" markup5ever_rcdom = "0.2.0" mime = "^0.3.16" mime_guess = "^2.0.4" +nom = "7.0.0" notify-rust = { version = "4.10.0", default-features = false, features = ["zbus", "serde"] } open = "3.2.0" rand = "0.8.5" diff --git a/docs/iamb.1 b/docs/iamb.1 index c1f3f45..bdf1744 100644 --- a/docs/iamb.1 +++ b/docs/iamb.1 @@ -188,6 +188,43 @@ Close all but one tab. Go to the preview tab. .El +.Sh "SLASH COMMANDS" +.Bl -tag -width Ds +.It Sy "/markdown" , Sy "/md" +Interpret the message body as Markdown markup. +This is the default behaviour. +.It Sy "/html" , Sy "/h" +Send the message body as literal HTML. +.It Sy "/plaintext" , Sy "/plain" , Sy "/p" +Do not interpret any markup in the message body and send it as it is. +.It Sy "/me" +Send an emote message. +.It Sy "/confetti" +Produces no effect in +.Nm , +but will display confetti in Matrix clients that support doing so. +.It Sy "/fireworks" +Produces no effect in +.Nm , +but will display fireworks in Matrix clients that support doing so. +.It Sy "/hearts" +Produces no effect in +.Nm , +but will display floating hearts in Matrix clients that support doing so. +.It Sy "/rainfall" +Produces no effect in +.Nm , +but will display rainfall in Matrix clients that support doing so. +.It Sy "/snowfall" +Produces no effect in +.Nm , +but will display snowfall in Matrix clients that support doing so. +.It Sy "/spaceinvaders" +Produces no effect in +.Nm , +but will display aliens from Space Invaders in Matrix clients that support doing so. +.El + .Sh EXAMPLES .Ss Example 1: Starting with a specific profile To start with a profile named diff --git a/src/message/compose.rs b/src/message/compose.rs new file mode 100644 index 0000000..2505853 --- /dev/null +++ b/src/message/compose.rs @@ -0,0 +1,290 @@ +//! Code for converting composed messages into content to send to the homeserver. +use comrak::{markdown_to_html, ComrakOptions}; +use nom::{ + branch::alt, + bytes::complete::tag, + character::complete::space0, + combinator::value, + IResult, +}; + +use matrix_sdk::ruma::events::room::message::{ + EmoteMessageEventContent, + MessageType, + RoomMessageEventContent, + TextMessageEventContent, +}; + +#[derive(Clone, Debug, Default)] +enum SlashCommand { + /// Send an emote message. + Emote, + + /// Send a message as literal HTML. + Html, + + /// Send a message without parsing any markup. + Plaintext, + + /// Send a Markdown message (the default message markup). + #[default] + Markdown, + + /// Send a message with confetti effects in clients that show them. + Confetti, + + /// Send a message with fireworks effects in clients that show them. + Fireworks, + + /// Send a message with heart effects in clients that show them. + Hearts, + + /// Send a message with rainfall effects in clients that show them. + Rainfall, + + /// Send a message with snowfall effects in clients that show them. + Snowfall, + + /// Send a message with heart effects in clients that show them. + SpaceInvaders, +} + +impl SlashCommand { + fn to_message(&self, input: &str) -> anyhow::Result { + let msgtype = match self { + SlashCommand::Emote => { + let html = text_to_html(input); + let msg = EmoteMessageEventContent::html(input, html); + MessageType::Emote(msg) + }, + SlashCommand::Html => { + let msg = TextMessageEventContent::html(input, input); + MessageType::Text(msg) + }, + SlashCommand::Plaintext => { + let msg = TextMessageEventContent::plain(input); + MessageType::Text(msg) + }, + SlashCommand::Markdown => { + let html = text_to_html(input); + let msg = TextMessageEventContent::html(input, html); + MessageType::Text(msg) + }, + SlashCommand::Confetti => { + MessageType::new("nic.custom.confetti", input.into(), Default::default())? + }, + SlashCommand::Fireworks => { + MessageType::new("nic.custom.fireworks", input.into(), Default::default())? + }, + SlashCommand::Hearts => { + MessageType::new("io.element.effect.hearts", input.into(), Default::default())? + }, + SlashCommand::Rainfall => { + MessageType::new("io.element.effect.rainfall", input.into(), Default::default())? + }, + SlashCommand::Snowfall => { + MessageType::new("io.element.effect.snowfall", input.into(), Default::default())? + }, + SlashCommand::SpaceInvaders => { + MessageType::new( + "io.element.effects.space_invaders", + input.into(), + Default::default(), + )? + }, + }; + + Ok(msgtype) + } +} + +fn parse_slash_command_inner(input: &str) -> IResult<&str, SlashCommand> { + let (input, _) = space0(input)?; + let (input, slash) = alt(( + value(SlashCommand::Emote, tag("/me ")), + value(SlashCommand::Html, tag("/h ")), + value(SlashCommand::Html, tag("/html ")), + value(SlashCommand::Plaintext, tag("/p ")), + value(SlashCommand::Plaintext, tag("/plain ")), + value(SlashCommand::Plaintext, tag("/plaintext ")), + value(SlashCommand::Markdown, tag("/md ")), + value(SlashCommand::Markdown, tag("/markdown ")), + value(SlashCommand::Confetti, tag("/confetti ")), + value(SlashCommand::Fireworks, tag("/fireworks ")), + value(SlashCommand::Hearts, tag("/hearts ")), + value(SlashCommand::Rainfall, tag("/rainfall ")), + value(SlashCommand::Snowfall, tag("/snowfall ")), + value(SlashCommand::SpaceInvaders, tag("/spaceinvaders ")), + ))(input)?; + let (input, _) = space0(input)?; + + Ok((input, slash)) +} + +fn parse_slash_command(input: &str) -> anyhow::Result<(&str, SlashCommand)> { + match parse_slash_command_inner(input) { + Ok(input) => Ok(input), + Err(e) => Err(anyhow::anyhow!("Failed to parse slash command: {e}")), + } +} + +fn text_to_html(input: &str) -> String { + let mut options = ComrakOptions::default(); + options.extension.autolink = true; + options.extension.shortcodes = true; + options.extension.strikethrough = true; + options.render.hardbreaks = true; + markdown_to_html(input, &options) +} + +fn text_to_message_content(input: String) -> TextMessageEventContent { + let html = text_to_html(input.as_str()); + TextMessageEventContent::html(input, html) +} + +pub fn text_to_message(input: String) -> RoomMessageEventContent { + let msg = parse_slash_command(input.as_str()) + .and_then(|(input, slash)| slash.to_message(input)) + .unwrap_or_else(|_| MessageType::Text(text_to_message_content(input))); + + RoomMessageEventContent::new(msg) +} + +#[cfg(test)] +pub mod tests { + use super::*; + + #[test] + fn test_markdown_autolink() { + let input = "http://example.com\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

http://example.com

\n" + ); + + let input = "www.example.com\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

www.example.com

\n" + ); + + let input = "See docs (they're at https://iamb.chat)\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

See docs (they're at https://iamb.chat)

\n" + ); + } + + #[test] + fn test_markdown_message() { + let input = "**bold**\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

bold

\n"); + + let input = "*emphasis*\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

emphasis

\n"); + + let input = "`code`\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

code

\n"); + + let input = "```rust\nconst A: usize = 1;\n```\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "
const A: usize = 1;\n
\n" + ); + + let input = ":heart:\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

\u{2764}\u{FE0F}

\n"); + + let input = "para 1\n\npara 2\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

para 1

\n

para 2

\n"); + + let input = "line 1\nline 2\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!(content.formatted.unwrap().body, "

line 1
\nline 2

\n"); + + let input = "# Heading\n## Subheading\n\ntext\n"; + let content = text_to_message_content(input.into()); + assert_eq!(content.body, input); + assert_eq!( + content.formatted.unwrap().body, + "

Heading

\n

Subheading

\n

text

\n" + ); + } + + #[test] + fn text_to_message_slash_commands() { + let MessageType::Text(content) = text_to_message("/html bold".into()).msgtype else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert_eq!(content.formatted.unwrap().body, "bold"); + + let MessageType::Text(content) = text_to_message("/h bold".into()).msgtype else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert_eq!(content.formatted.unwrap().body, "bold"); + + let MessageType::Text(content) = text_to_message("/plain bold".into()).msgtype + else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert!(content.formatted.is_none(), "{:?}", content.formatted); + + let MessageType::Text(content) = text_to_message("/p bold".into()).msgtype else { + panic!("Expected MessageType::Text"); + }; + assert_eq!(content.body, "bold"); + assert!(content.formatted.is_none(), "{:?}", content.formatted); + + let MessageType::Emote(content) = text_to_message("/me *bold*".into()).msgtype else { + panic!("Expected MessageType::Emote"); + }; + assert_eq!(content.body, "*bold*"); + assert_eq!(content.formatted.unwrap().body, "

bold

\n"); + + let content = text_to_message("/confetti hello".into()).msgtype; + assert_eq!(content.msgtype(), "nic.custom.confetti"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/fireworks hello".into()).msgtype; + assert_eq!(content.msgtype(), "nic.custom.fireworks"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/hearts hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effect.hearts"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/rainfall hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effect.rainfall"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/snowfall hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effect.snowfall"); + assert_eq!(content.body(), "hello"); + + let content = text_to_message("/spaceinvaders hello".into()).msgtype; + assert_eq!(content.msgtype(), "io.element.effects.space_invaders"); + assert_eq!(content.body(), "hello"); + } +} diff --git a/src/message/mod.rs b/src/message/mod.rs index d94cffb..39e220b 100644 --- a/src/message/mod.rs +++ b/src/message/mod.rs @@ -10,7 +10,6 @@ use std::hash::{Hash, Hasher}; use std::ops::{Deref, DerefMut}; use chrono::{DateTime, Local as LocalTz, NaiveDateTime, TimeZone}; -use comrak::{markdown_to_html, ComrakOptions}; use humansize::{format_size, DECIMAL}; use serde_json::json; use unicode_width::UnicodeWidthStr; @@ -33,7 +32,6 @@ use matrix_sdk::ruma::{ Relation, RoomMessageEvent, RoomMessageEventContent, - TextMessageEventContent, }, redaction::SyncRoomRedactionEvent, }, @@ -66,9 +64,12 @@ use crate::{ util::{replace_emojis_in_str, space, space_span, take_width, wrapped_text}, }; +mod compose; mod html; mod printer; +pub use self::compose::text_to_message; + pub type MessageKey = (MessageTimeStamp, OwnedEventId); #[derive(Default)] @@ -129,22 +130,6 @@ const MIN_MSG_LEN: usize = 30; const TIME_GUTTER_EMPTY: &str = " "; const TIME_GUTTER_EMPTY_SPAN: Span<'static> = span_static(TIME_GUTTER_EMPTY); -fn text_to_message_content(input: String) -> TextMessageEventContent { - let mut options = ComrakOptions::default(); - options.extension.autolink = true; - options.extension.shortcodes = true; - options.extension.strikethrough = true; - options.render.hardbreaks = true; - let html = markdown_to_html(input.as_str(), &options); - - TextMessageEventContent::html(input, html) -} - -pub fn text_to_message(input: String) -> RoomMessageEventContent { - let msg = MessageType::Text(text_to_message_content(input)); - RoomMessageEventContent::new(msg) -} - /// Before the image is loaded, already display a placeholder frame of the image size. fn placeholder_frame( text: Option<&str>, @@ -553,7 +538,18 @@ fn body_cow_content(content: &RoomMessageEventContent) -> Cow<'_, str> { display_file_to_text!(Video, content); }, _ => { - return Cow::Owned(format!("[Unknown message type: {:?}]", content.msgtype())); + match content.msgtype() { + // Just show the body text for the special Element messages. + "nic.custom.confetti" | + "nic.custom.fireworks" | + "io.element.effect.hearts" | + "io.element.effect.rainfall" | + "io.element.effect.snowfall" | + "io.element.effects.space_invaders" => content.body(), + other => { + return Cow::Owned(format!("[Unknown message type: {other:?}]")); + }, + } }, }; @@ -1274,82 +1270,6 @@ pub mod tests { assert_eq!(identity(&mc6), mc1); } - #[test] - fn test_markdown_autolink() { - let input = "http://example.com\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!( - content.formatted.unwrap().body, - "

http://example.com

\n" - ); - - let input = "www.example.com\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!( - content.formatted.unwrap().body, - "

www.example.com

\n" - ); - - let input = "See docs (they're at https://iamb.chat)\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!( - content.formatted.unwrap().body, - "

See docs (they're at https://iamb.chat)

\n" - ); - } - - #[test] - fn test_markdown_message() { - let input = "**bold**\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!(content.formatted.unwrap().body, "

bold

\n"); - - let input = "*emphasis*\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!(content.formatted.unwrap().body, "

emphasis

\n"); - - let input = "`code`\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!(content.formatted.unwrap().body, "

code

\n"); - - let input = "```rust\nconst A: usize = 1;\n```\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!( - content.formatted.unwrap().body, - "
const A: usize = 1;\n
\n" - ); - - let input = ":heart:\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!(content.formatted.unwrap().body, "

\u{2764}\u{FE0F}

\n"); - - let input = "para 1\n\npara 2\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!(content.formatted.unwrap().body, "

para 1

\n

para 2

\n"); - - let input = "line 1\nline 2\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!(content.formatted.unwrap().body, "

line 1
\nline 2

\n"); - - let input = "# Heading\n## Subheading\n\ntext\n"; - let content = text_to_message_content(input.into()); - assert_eq!(content.body, input); - assert_eq!( - content.formatted.unwrap().body, - "

Heading

\n

Subheading

\n

text

\n" - ); - } - #[test] fn test_placeholder_frame() { fn pretty_frame_test(str: &str) -> Option { diff --git a/src/notifications.rs b/src/notifications.rs index 5ca905e..48878d7 100644 --- a/src/notifications.rs +++ b/src/notifications.rs @@ -228,7 +228,9 @@ pub fn event_notification_body( MessageType::VerificationRequest(_) => { format!("{sender_name} sent a verification request.") }, - _ => unimplemented!(), + _ => { + format!("[Unknown message type: {:?}]", &message.msgtype) + }, }; Some(body) }, diff --git a/src/windows/room/mod.rs b/src/windows/room/mod.rs index a251971..7288678 100644 --- a/src/windows/room/mod.rs +++ b/src/windows/room/mod.rs @@ -401,7 +401,7 @@ impl RoomState { }, RoomField::CanonicalAlias => { let Some(alias_to_destroy) = room.canonical_alias() else { - let msg = format!("This room has no canonical alias to unset"); + let msg = "This room has no canonical alias to unset"; return Ok(vec![(Action::ShowInfoMessage(msg.into()), ctx)]); }; @@ -500,11 +500,9 @@ impl RoomState { Some(can) => format!("Canonical alias: {can}"), } }, - RoomField::Tag(_) => { - format!("Cannot currently show value for a tag") - }, + RoomField::Tag(_) => "Cannot currently show value for a tag".into(), RoomField::Alias(_) => { - format!("Cannot show a single alias; use `:room aliases show` instead.") + "Cannot show a single alias; use `:room aliases show` instead.".into() }, };