diff --git a/Cargo.lock b/Cargo.lock index f522c5015..5f675b6a0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5537,6 +5537,7 @@ dependencies = [ "thiserror 2.0.18", "tokio", "tracing", + "unicode-segmentation", "url", ] diff --git a/Cargo.toml b/Cargo.toml index 7a6f9bb52..49b7842a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -227,6 +227,7 @@ opentelemetry-otlp = { version = "0.33", default-features = false, features = [" opentelemetry-appender-tracing = { version = "0.33", default-features = false, features = ["experimental_span_attributes"] } thiserror = "2" +unicode-segmentation = "1" confique = { version = "0.4", default-features = false, features = ["toml"] } clap = { version = "4", features = ["derive", "env"] } diff --git a/knot2/crates/knot-cobs/src/reaction.rs b/knot2/crates/knot-cobs/src/reaction.rs index 29912c322..13f138524 100644 --- a/knot2/crates/knot-cobs/src/reaction.rs +++ b/knot2/crates/knot-cobs/src/reaction.rs @@ -67,14 +67,14 @@ mod tests { #[test] fn reaction_changes_round_trip_with_their_kind() { for kind in [ - ReactionKind::Like, - ReactionKind::Unlike, - ReactionKind::Laugh, - ReactionKind::Celebration, - ReactionKind::Confused, - ReactionKind::Heart, - ReactionKind::Rocket, - ReactionKind::Eyes, + ReactionKind::new("👍").unwrap(), + ReactionKind::new("👎").unwrap(), + ReactionKind::new("😆").unwrap(), + ReactionKind::new("🎉").unwrap(), + ReactionKind::new("🫤").unwrap(), + ReactionKind::new("❤️").unwrap(), + ReactionKind::new("🚀").unwrap(), + ReactionKind::new("👀").unwrap(), ] { let change = open(kind); assert_eq!( diff --git a/knot2/crates/knot-index/tests/projections.rs b/knot2/crates/knot-index/tests/projections.rs index e52d1884e..53c40f999 100644 --- a/knot2/crates/knot-index/tests/projections.rs +++ b/knot2/crates/knot-index/tests/projections.rs @@ -1895,7 +1895,7 @@ fn reaction_index_answers_live_reactions_only() { &acc("nel"), 1, &parent, - ReactionKind::Like, + ReactionKind::new("👍").unwrap(), 20, ); let olarens_like = world.reaction( @@ -1904,7 +1904,7 @@ fn reaction_index_answers_live_reactions_only() { &acc("olaren"), 2, &parent, - ReactionKind::Like, + ReactionKind::new("👍").unwrap(), 30, ); let nels_heart = world.reaction( @@ -1913,7 +1913,7 @@ fn reaction_index_answers_live_reactions_only() { &acc("nel"), 3, &parent, - ReactionKind::Heart, + ReactionKind::new("❤️").unwrap(), 40, ); let index = world.index(); @@ -1922,11 +1922,11 @@ fn reaction_index_answers_live_reactions_only() { cold.refresh_social(&repo).unwrap(); for (actor, kind, expected) in [ - (&acc("nel"), ReactionKind::Like, Some(nels_like)), - (&acc("olaren"), ReactionKind::Like, Some(olarens_like)), - (&acc("nel"), ReactionKind::Heart, Some(nels_heart)), - (&acc("olaren"), ReactionKind::Heart, None), - (&acc("nel"), ReactionKind::Eyes, None), + (&acc("nel"), ReactionKind::new("👍").unwrap(), Some(nels_like)), + (&acc("olaren"), ReactionKind::new("👍").unwrap(), Some(olarens_like)), + (&acc("nel"), ReactionKind::new("❤️").unwrap(), Some(nels_heart)), + (&acc("olaren"), ReactionKind::new("❤️").unwrap(), None), + (&acc("nel"), ReactionKind::new("👀").unwrap(), None), ] { assert_eq!( index.social_live_reaction(&repo, &parent, actor, kind), @@ -1949,12 +1949,12 @@ fn reaction_index_answers_live_reactions_only() { ); index.refresh_social(&repo).unwrap(); assert_eq!( - index.social_live_reaction(&repo, &parent, &acc("nel"), ReactionKind::Like), + index.social_live_reaction(&repo, &parent, &acc("nel"), ReactionKind::new("👍").unwrap()), Resolved::Ready(None), "a deleted reaction should probably stop being found" ); assert_eq!( - index.social_live_reaction(&repo, &parent, &acc("olaren"), ReactionKind::Like), + index.social_live_reaction(&repo, &parent, &acc("olaren"), ReactionKind::new("👍").unwrap()), Resolved::Ready(Some(olarens_like)), "another actor's same-kind reaction should still be found" ); diff --git a/knot2/crates/knot-record/Cargo.toml b/knot2/crates/knot-record/Cargo.toml index 8c1b739fa..c368ef0b6 100644 --- a/knot2/crates/knot-record/Cargo.toml +++ b/knot2/crates/knot-record/Cargo.toml @@ -22,6 +22,7 @@ serde_bytes = { workspace = true } serde_ipld_dagcbor = { workspace = true } thiserror = { workspace = true } tracing = { workspace = true } +unicode-segmentation = { workspace = true } iroh-car = { workspace = true } url = { workspace = true } [dev-dependencies] diff --git a/knot2/crates/knot-record/src/reaction.rs b/knot2/crates/knot-record/src/reaction.rs index 3a585938a..a642d1d9e 100644 --- a/knot2/crates/knot-record/src/reaction.rs +++ b/knot2/crates/knot-record/src/reaction.rs @@ -1,5 +1,6 @@ use knot_types::{AccountDid, RecordBody, RecordBodyError, RepoDid, UnixSeconds}; -use serde::{Deserialize, Serialize, Serializer}; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; +use unicode_segmentation::UnicodeSegmentation; use crate::comment::{CommentRef, CommentRefEncoding, ref_encoding}; use crate::{CreatedAt, RecordError, encoded}; @@ -12,24 +13,52 @@ collection!( "Reaction collection is valid nsid" ); -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] -pub enum ReactionKind { - #[serde(rename = "👍")] - Like, - #[serde(rename = "👎")] - Unlike, - #[serde(rename = "😆")] - Laugh, - #[serde(rename = "🎉")] - Celebration, - #[serde(rename = "🫤")] - Confused, - #[serde(rename = "❤️")] - Heart, - #[serde(rename = "🚀")] - Rocket, - #[serde(rename = "👀")] - Eyes, +pub const MAX_REACTION_BYTES: usize = 64; + +#[derive(Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct ReactionKind { + bytes: [u8; MAX_REACTION_BYTES], + len: u8, +} + +impl ReactionKind { + pub fn new(value: &str) -> Result { + if value.len() > MAX_REACTION_BYTES { + return Err(format!("reaction {value:?} is longer than {MAX_REACTION_BYTES} bytes")); + } + if value.graphemes(true).count() > 1 { + return Err(format!("reaction {value:?} is longer than one grapheme")); + } + let mut bytes = [0; MAX_REACTION_BYTES]; + bytes[..value.len()].copy_from_slice(value.as_bytes()); + Ok(Self { + bytes, + len: value.len() as u8, + }) + } + + pub fn as_str(&self) -> &str { + std::str::from_utf8(&self.bytes[..usize::from(self.len)]) + .expect("A reaction is built from a str") + } +} + +impl std::fmt::Debug for ReactionKind { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + self.as_str().fmt(f) + } +} + +impl Serialize for ReactionKind { + fn serialize(&self, serializer: S) -> Result { + serializer.serialize_str(self.as_str()) + } +} + +impl<'de> Deserialize<'de> for ReactionKind { + fn deserialize>(deserializer: D) -> Result { + Self::new(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom) + } } #[derive(Debug, Clone, PartialEq, Eq)] @@ -101,7 +130,7 @@ mod tests { let record = ReactionRecord::new( squid(), subject(), - ReactionKind::Heart, + ReactionKind::new("❤️").unwrap(), UnixSeconds::new(1), editor(), ) @@ -117,14 +146,14 @@ mod tests { #[test] fn every_reaction_kind_serializes_its_lexicon_token() { let pairs = [ - (ReactionKind::Like, "👍"), - (ReactionKind::Unlike, "👎"), - (ReactionKind::Laugh, "😆"), - (ReactionKind::Celebration, "🎉"), - (ReactionKind::Confused, "🫤"), - (ReactionKind::Heart, "❤️"), - (ReactionKind::Rocket, "🚀"), - (ReactionKind::Eyes, "👀"), + (ReactionKind::new("👍").unwrap(), "👍"), + (ReactionKind::new("👎").unwrap(), "👎"), + (ReactionKind::new("😆").unwrap(), "😆"), + (ReactionKind::new("🎉").unwrap(), "🎉"), + (ReactionKind::new("🫤").unwrap(), "🫤"), + (ReactionKind::new("❤️").unwrap(), "❤️"), + (ReactionKind::new("🚀").unwrap(), "🚀"), + (ReactionKind::new("👀").unwrap(), "👀"), ]; for (kind, token) in pairs { assert_eq!( @@ -135,8 +164,10 @@ mod tests { } #[test] - fn unknown_token_fails_to_decode() { - let decoded: Result = serde_json::from_value(serde_json::json!("🔥")); + fn reaction_is_at_most_one_grapheme() { + let decoded: Result = serde_json::from_value(serde_json::json!("🔥🔥")); assert!(decoded.is_err()); + let decoded: ReactionKind = serde_json::from_value(serde_json::json!("🤍")).unwrap(); + assert_eq!(decoded.as_str(), "🤍"); } }