diff --git a/Cargo.lock b/Cargo.lock index 4359845d2..071342235 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1410,6 +1410,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "emojis" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" +dependencies = [ + "phf", +] + [[package]] name = "encode_unicode" version = "0.3.6" @@ -3505,6 +3514,7 @@ dependencies = [ "async-stream", "async_cell", "chrono", + "emojis", "eyeball", "eyeball-im", "eyeball-im-util", @@ -3531,6 +3541,7 @@ dependencies = [ "tokio-stream", "tracing", "unicode-normalization", + "unicode-segmentation", "uniffi", "wiremock", ] @@ -6149,9 +6160,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4c87d22b6e3f4a18d4d40ef354e97c90fcb14dd91d7dc0aa9d8a1172ebf7202" +checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493" [[package]] name = "unicode-truncate" diff --git a/bindings/matrix-sdk-ffi/src/timeline/mod.rs b/bindings/matrix-sdk-ffi/src/timeline/mod.rs index 3635e81ce..ab49adfdd 100644 --- a/bindings/matrix-sdk-ffi/src/timeline/mod.rs +++ b/bindings/matrix-sdk-ffi/src/timeline/mod.rs @@ -1322,4 +1322,8 @@ impl LazyTimelineItemProvider { fn get_send_handle(&self) -> Option> { self.0.local_echo_send_handle().map(|handle| Arc::new(SendHandle::new(handle))) } + + fn contains_only_emojis(&self) -> bool { + self.0.contains_only_emojis() + } } diff --git a/crates/matrix-sdk-ui/Cargo.toml b/crates/matrix-sdk-ui/Cargo.toml index ea518a8f7..62769b597 100644 --- a/crates/matrix-sdk-ui/Cargo.toml +++ b/crates/matrix-sdk-ui/Cargo.toml @@ -51,6 +51,9 @@ tracing = { workspace = true, features = ["attributes"] } unicode-normalization = { workspace = true } uniffi = { workspace = true, optional = true } +emojis = "0.6.4" +unicode-segmentation = "1.12.0" + [dev-dependencies] anyhow = { workspace = true } assert-json-diff = { workspace = true } diff --git a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs index 1284fdf2b..3365d5298 100644 --- a/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs +++ b/crates/matrix-sdk-ui/src/timeline/event_item/mod.rs @@ -36,6 +36,7 @@ use ruma::{ OwnedUserId, RoomId, RoomVersionId, TransactionId, UserId, }; use tracing::warn; +use unicode_segmentation::UnicodeSegmentation; mod content; mod local; @@ -604,6 +605,69 @@ impl EventTimelineItem { pub fn local_echo_send_handle(&self) -> Option { as_variant!(self.handle(), TimelineItemHandle::Local(handle) => handle.clone()) } + + /// Some clients may want to know if a particular text message or media + /// caption contains only emojis so that they can render them bigger for + /// added effect. + /// + /// This function provides that feature with the following + /// behavior/limitations: + /// - ignores leading and trailing white spaces + /// - fails texts bigger than 5 graphemes for performance reasons + /// - checks the body only for [`MessageType::Text`] + /// - only checks the caption for [`MessageType::Audio`], + /// [`MessageType::File`], [`MessageType::Image`], and + /// [`MessageType::Video`] if present + /// - all other message types will not match + /// + /// # Examples + /// # fn render_timeline_item(timeline_item: TimelineItem) { + /// if timeline_item.contains_only_emojis() { + /// // e.g. increase the font size + /// } + /// # } + /// + /// See `test_emoji_detection` for more examples. + pub fn contains_only_emojis(&self) -> bool { + let body = match self.content() { + TimelineItemContent::Message(msg) => match msg.msgtype() { + MessageType::Text(text) => Some(text.body.as_str()), + MessageType::Audio(audio) => audio.caption(), + MessageType::File(file) => file.caption(), + MessageType::Image(image) => image.caption(), + MessageType::Video(video) => video.caption(), + _ => None, + }, + TimelineItemContent::RedactedMessage + | TimelineItemContent::Sticker(_) + | TimelineItemContent::UnableToDecrypt(_) + | TimelineItemContent::MembershipChange(_) + | TimelineItemContent::ProfileChange(_) + | TimelineItemContent::OtherState(_) + | TimelineItemContent::FailedToParseMessageLike { .. } + | TimelineItemContent::FailedToParseState { .. } + | TimelineItemContent::Poll(_) + | TimelineItemContent::CallInvite + | TimelineItemContent::CallNotify => None, + }; + + if let Some(body) = body { + // Collect the graphemes after trimming white spaces. + let graphemes = body.trim().graphemes(true).collect::>(); + + // Limit the check to 5 graphemes for performance and security + // reasons. This will probably be used for every new message so we + // want it to be fast and we don't want to allow a DoS attack by + // sending a huge message. + if graphemes.len() > 5 { + return false; + } + + graphemes.iter().all(|g| emojis::get(g).is_some()) + } else { + false + } + } } impl From for EventTimelineItemKind { @@ -1060,6 +1124,48 @@ mod tests { ); } + #[async_test] + async fn test_emoji_detection() { + let room_id = room_id!("!q:x.uk"); + let user_id = user_id!("@t:o.uk"); + let client = logged_in_client(None).await; + + let mut event = message_event(room_id, user_id, "πŸ€·β€β™‚οΈ No boost πŸ€·β€β™‚οΈ", "", 0); + let mut timeline_item = + EventTimelineItem::from_latest_event(client.clone(), room_id, LatestEvent::new(event)) + .await + .unwrap(); + + assert!(!timeline_item.contains_only_emojis()); + + // Ignores leading and trailing white spaces + event = message_event(room_id, user_id, " πŸš€ ", "", 0); + timeline_item = + EventTimelineItem::from_latest_event(client.clone(), room_id, LatestEvent::new(event)) + .await + .unwrap(); + + assert!(timeline_item.contains_only_emojis()); + + // Too many + event = message_event(room_id, user_id, "πŸ‘¨β€πŸ‘©β€πŸ‘¦1οΈβƒ£πŸš€πŸ‘³πŸΎβ€β™‚οΈπŸͺ©πŸ‘πŸ‘πŸ»πŸ«±πŸΌβ€πŸ«²πŸΎπŸ™‚πŸ‘‹", "", 0); + timeline_item = + EventTimelineItem::from_latest_event(client.clone(), room_id, LatestEvent::new(event)) + .await + .unwrap(); + + assert!(!timeline_item.contains_only_emojis()); + + // Works with combined emojis + event = message_event(room_id, user_id, "πŸ‘¨β€πŸ‘©β€πŸ‘¦1οΈβƒ£πŸ‘³πŸΎβ€β™‚οΈπŸ‘πŸ»πŸ«±πŸΌβ€πŸ«²πŸΎ", "", 0); + timeline_item = + EventTimelineItem::from_latest_event(client.clone(), room_id, LatestEvent::new(event)) + .await + .unwrap(); + + assert!(timeline_item.contains_only_emojis()); + } + fn member_event( room_id: &RoomId, user_id: &UserId,