diff --git a/Cargo.lock b/Cargo.lock index 99009e7e1..350c7732a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7411,8 +7411,11 @@ version = "0.1.0" dependencies = [ "prost", "rand", + "serde", + "serde_json", "thiserror 2.0.6", "tonic", + "tracing", "xmtp_common", "xmtp_proto", ] diff --git a/xmtp_content_types/Cargo.toml b/xmtp_content_types/Cargo.toml index 2b7c506d1..82a8f5eac 100644 --- a/xmtp_content_types/Cargo.toml +++ b/xmtp_content_types/Cargo.toml @@ -8,6 +8,9 @@ license.workspace = true thiserror = { workspace = true } prost = { workspace = true, features = ["prost-derive"] } rand = { workspace = true } +serde = { workspace = true, features = ["derive"] } +serde_json = { workspace = true } +tracing.workspace = true # XMTP/Local xmtp_proto = { workspace = true, features = ["convert"] } diff --git a/xmtp_content_types/src/reaction.rs b/xmtp_content_types/src/reaction.rs index f70b925bc..fafe99c4a 100644 --- a/xmtp_content_types/src/reaction.rs +++ b/xmtp_content_types/src/reaction.rs @@ -3,6 +3,7 @@ use std::collections::HashMap; use crate::{CodecError, ContentCodec}; use prost::Message; +use serde::{Deserialize, Serialize}; use xmtp_proto::xmtp::mls::message_contents::{ content_types::ReactionV2, ContentTypeId, EncodedContent, }; @@ -47,6 +48,42 @@ impl ContentCodec for ReactionCodec { } } +// JSON format for legacy reaction is defined here: https://github.com/xmtp/xmtp-js/blob/main/content-types/content-type-reaction/src/Reaction.ts +#[derive(Debug, Serialize, Deserialize)] +pub struct LegacyReaction { + /// The message ID for the message that is being reacted to + pub reference: String, + /// The inbox ID of the user who sent the message that is being reacted to + #[serde(rename = "referenceInboxId", skip_serializing_if = "Option::is_none")] + pub reference_inbox_id: Option, + /// The action of the reaction ("added" or "removed") + pub action: String, + /// The content of the reaction + pub content: String, + /// The schema of the content ("unicode", "shortcode", or "custom") + pub schema: String, +} + +impl LegacyReaction { + pub fn decode(content: &[u8]) -> Option { + // Try to decode the content as UTF-8 string first + if let Ok(decoded_content) = String::from_utf8(content.to_vec()) { + tracing::info!( + "attempting legacy json deserialization: {}", + decoded_content + ); + // Try parsing as canonical JSON format + if let Ok(reaction) = serde_json::from_str::(&decoded_content) { + return Some(reaction); + } + tracing::error!("legacy json deserialization failed"); + } else { + tracing::error!("utf-8 deserialization failed"); + } + None + } +} + #[cfg(test)] pub(crate) mod tests { #[cfg(target_arch = "wasm32")] @@ -56,6 +93,7 @@ pub(crate) mod tests { ReactionAction, ReactionSchema, ReactionV2, }; + use serde_json::json; use xmtp_common::rand_string; use super::*; @@ -80,4 +118,27 @@ pub(crate) mod tests { assert_eq!(decoded.content, "👍".to_string()); assert_eq!(decoded.schema, ReactionSchema::Unicode as i32); } + + #[cfg_attr(target_arch = "wasm32", wasm_bindgen_test::wasm_bindgen_test)] + #[cfg_attr(not(target_arch = "wasm32"), test)] + fn test_legacy_reaction_deserialization() { + let reference = "0123456789abcdef"; + let legacy_json = json!({ + "reference": reference, + "referenceInboxId": "some_inbox_id", + "action": "added", + "content": "👍", + "schema": "unicode" + }); + + let content = legacy_json.to_string().into_bytes(); + let decoded_reference: String = LegacyReaction::decode(&content).unwrap().reference; + + assert_eq!(decoded_reference, reference); + + // Test invalid JSON + let invalid_content = b"invalid json"; + let failed_decode = LegacyReaction::decode(invalid_content); + assert!(failed_decode.is_none()); + } } diff --git a/xmtp_mls/src/groups/mod.rs b/xmtp_mls/src/groups/mod.rs index b72a18eb2..7b1ea9477 100644 --- a/xmtp_mls/src/groups/mod.rs +++ b/xmtp_mls/src/groups/mod.rs @@ -36,7 +36,7 @@ use openmls_traits::OpenMlsProvider; use prost::Message; use thiserror::Error; use tokio::sync::Mutex; -use xmtp_content_types::reaction::ReactionCodec; +use xmtp_content_types::reaction::{LegacyReaction, ReactionCodec}; use self::device_sync::DeviceSyncError; pub use self::group_permissions::PreconfiguredPolicies; @@ -348,13 +348,12 @@ impl TryFrom for QueryableContentFields { content_type_id.version_major, ) { (ReactionCodec::TYPE_ID, major) if major >= 2 => { - let reaction = ReactionV2::decode(content.content.as_slice())?; - hex::decode(reaction.reference).ok() - } - (ReactionCodec::TYPE_ID, _) => { - // TODO: Implement JSON deserialization for legacy reaction format - None + ReactionV2::decode(content.content.as_slice()) + .ok() + .and_then(|reaction| hex::decode(reaction.reference).ok()) } + (ReactionCodec::TYPE_ID, _) => LegacyReaction::decode(&content.content) + .and_then(|legacy_reaction| hex::decode(legacy_reaction.reference).ok()), _ => None, }; @@ -796,7 +795,9 @@ impl MlsGroup { fn extract_queryable_content_fields(message: &[u8]) -> QueryableContentFields { // Return early with default if decoding fails or type is missing EncodedContent::decode(message) - .inspect_err(|e| tracing::debug!("Failed to decode message as EncodedContent: {}", e)) + .inspect_err(|_| { + tracing::debug!("No queryable content fields, msg not formatted as encoded content") + }) .and_then(|content| { QueryableContentFields::try_from(content).inspect_err(|e| { tracing::debug!(