feat(ui): expose a method for checking whether a message contains only emojis and should be boosted (use a bigger font size) (#4577)

- supports only text room message types
- enumerates through their body's grapheme clusters and check that every
single one of them is an emoji
- part of the `LazyTimelineItemProvider` so that it can be opt in
This commit is contained in:
Stefan Ceriu
2025-01-27 16:00:01 +02:00
committed by GitHub
parent aaecbf07f2
commit 2657eb7866
4 changed files with 126 additions and 2 deletions

15
Cargo.lock generated
View File

@@ -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"

View File

@@ -1322,4 +1322,8 @@ impl LazyTimelineItemProvider {
fn get_send_handle(&self) -> Option<Arc<SendHandle>> {
self.0.local_echo_send_handle().map(|handle| Arc::new(SendHandle::new(handle)))
}
fn contains_only_emojis(&self) -> bool {
self.0.contains_only_emojis()
}
}

View File

@@ -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 }

View File

@@ -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<SendHandle> {
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::<Vec<&str>>();
// 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<LocalEventTimelineItem> 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,