diff --git a/xmtp-ffi/include/xmtp_ffi.h b/xmtp-ffi/include/xmtp_ffi.h index 9241d00..51c1f5b 100644 --- a/xmtp-ffi/include/xmtp_ffi.h +++ b/xmtp-ffi/include/xmtp_ffi.h @@ -93,6 +93,33 @@ enum XmtpFfiDeliveryStatus typedef int32_t XmtpFfiDeliveryStatus; #endif // __cplusplus +enum XmtpFfiReactionAction +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + XMTP_FFI_REACTION_ACTION_UNSPECIFIED = 0, + XMTP_FFI_REACTION_ACTION_ADDED = 1, + XMTP_FFI_REACTION_ACTION_REMOVED = 2, +}; +#ifndef __cplusplus +typedef int32_t XmtpFfiReactionAction; +#endif // __cplusplus + +enum XmtpFfiReactionSchema +#ifdef __cplusplus + : int32_t +#endif // __cplusplus + { + XMTP_FFI_REACTION_SCHEMA_UNSPECIFIED = 0, + XMTP_FFI_REACTION_SCHEMA_UNICODE = 1, + XMTP_FFI_REACTION_SCHEMA_SHORTCODE = 2, + XMTP_FFI_REACTION_SCHEMA_CUSTOM = 3, +}; +#ifndef __cplusplus +typedef int32_t XmtpFfiReactionSchema; +#endif // __cplusplus + /** * Consent entity type. */ @@ -515,6 +542,18 @@ typedef struct XmtpFfiGroupPermissions { struct XmtpFfiPermissionPolicySet policy_set; } XmtpFfiGroupPermissions; +/** + * Ffi data for Reaction content + */ +typedef struct XmtpFfiReaction { + char *reference; + char *reference_inbox_id; + char *sender_inbox_id; + XmtpFfiReactionAction action; + char *content; + XmtpFfiReactionSchema schema; +} XmtpFfiReaction; + /** * An enriched (decoded) message exposed to C. * Contains metadata + the original encoded content bytes for upper-layer decoding. @@ -558,6 +597,10 @@ typedef struct XmtpFfiEnrichedMessage { * Expiration timestamp in nanoseconds (0 = no expiration). */ int64_t expires_at_ns; + /** + * Array of reactions + */ + struct XmtpFfiReaction *reactions; /** * Number of reactions. */ diff --git a/xmtp-ffi/src/conversation.rs b/xmtp-ffi/src/conversation.rs index 20cc210..2b3d787 100644 --- a/xmtp-ffi/src/conversation.rs +++ b/xmtp-ffi/src/conversation.rs @@ -1532,6 +1532,38 @@ pub(crate) fn decoded_to_enriched( let len = b.len() as i32; (Box::into_raw(b) as *mut u8, len) }; + + let reactions_vec: Vec = msg + .reactions + .iter() + .filter_map(|r| match &r.content { + xmtp_mls::messages::decoded_message::MessageBody::Reaction(reaction) => Some(FfiReaction { + reference: to_c_string(&hex::encode(&reaction.reference)), + reference_inbox_id: to_c_string(&reaction.reference_inbox_id), + sender_inbox_id: to_c_string(&r.metadata.sender_inbox_id), + action: match reaction.action() { + xmtp_proto::xmtp::mls::message_contents::content_types::ReactionAction::Added => FfiReactionAction::Added, + xmtp_proto::xmtp::mls::message_contents::content_types::ReactionAction::Removed => FfiReactionAction::Removed, + xmtp_proto::xmtp::mls::message_contents::content_types::ReactionAction::Unspecified => FfiReactionAction::Unspecified, + }, + content: to_c_string(&reaction.content), + schema: match reaction.schema() { + xmtp_proto::xmtp::mls::message_contents::content_types::ReactionSchema::Unspecified => FfiReactionSchema::Unspecified, + xmtp_proto::xmtp::mls::message_contents::content_types::ReactionSchema::Unicode => FfiReactionSchema::Unicode, + xmtp_proto::xmtp::mls::message_contents::content_types::ReactionSchema::Shortcode => FfiReactionSchema::Shortcode, + xmtp_proto::xmtp::mls::message_contents::content_types::ReactionSchema::Custom => FfiReactionSchema::Custom, + }, + }), + _ => None, + }) + .collect(); + + let reactions = if reactions_vec.is_empty() { + std::ptr::null_mut() + } else { + Box::into_raw(reactions_vec.into_boxed_slice()) as *mut FfiReaction + }; + FfiEnrichedMessage { id: to_c_string(&hex::encode(&msg.metadata.id)), group_id: to_c_string(&hex::encode(&msg.metadata.group_id)), @@ -1556,6 +1588,7 @@ pub(crate) fn decoded_to_enriched( None => std::ptr::null_mut(), }, expires_at_ns: msg.metadata.expires_at_ns.unwrap_or(0), + reactions, num_reactions: msg.reactions.len() as i32, num_replies: msg.num_replies as i32, content_bytes, diff --git a/xmtp-ffi/src/ffi.rs b/xmtp-ffi/src/ffi.rs index 753b4d6..91d6e11 100644 --- a/xmtp-ffi/src/ffi.rs +++ b/xmtp-ffi/src/ffi.rs @@ -3,6 +3,7 @@ use std::cell::RefCell; use std::ffi::{CStr, CString, c_char}; use std::sync::OnceLock; + use tokio::runtime::Runtime; // --------------------------------------------------------------------------- @@ -205,6 +206,23 @@ pub enum FfiPreferenceUpdateKind { HmacKey = 1, } +#[repr(i32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FfiReactionAction { + Unspecified = 0, + Added = 1, + Removed = 2, +} + +#[repr(i32)] +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FfiReactionSchema { + Unspecified = 0, + Unicode = 1, + Shortcode = 2, + Custom = 3, +} + // --------------------------------------------------------------------------- // Data transfer types (flat, repr(C)) // --------------------------------------------------------------------------- @@ -438,6 +456,17 @@ pub struct FfiGroupMetadata { pub conversation_type: FfiConversationType, } +/// Ffi data for Reaction content +#[repr(C)] +pub struct FfiReaction { + pub reference: *mut c_char, + pub reference_inbox_id: *mut c_char, + pub sender_inbox_id: *mut c_char, + pub action: FfiReactionAction, + pub content: *mut c_char, + pub schema: FfiReactionSchema, +} + /// Permission policy set for a conversation. #[repr(C)] pub struct FfiPermissionPolicySet { @@ -483,6 +512,8 @@ pub struct FfiEnrichedMessage { pub fallback_text: *mut c_char, /// Expiration timestamp in nanoseconds (0 = no expiration). pub expires_at_ns: i64, + /// Array of reactions + pub reactions: *mut FfiReaction, /// Number of reactions. pub num_reactions: i32, /// Number of replies. diff --git a/xmtp-sys/src/bindings.rs b/xmtp-sys/src/bindings.rs index e6e0356..7bc4128 100644 --- a/xmtp-sys/src/bindings.rs +++ b/xmtp-sys/src/bindings.rs @@ -50,6 +50,23 @@ pub enum XmtpFfiDeliveryStatus { } #[repr(i32)] #[non_exhaustive] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum XmtpFfiReactionAction { + XMTP_FFI_REACTION_ACTION_UNSPECIFIED = 0, + XMTP_FFI_REACTION_ACTION_ADDED = 1, + XMTP_FFI_REACTION_ACTION_REMOVED = 2, +} +#[repr(i32)] +#[non_exhaustive] +#[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] +pub enum XmtpFfiReactionSchema { + XMTP_FFI_REACTION_SCHEMA_UNSPECIFIED = 0, + XMTP_FFI_REACTION_SCHEMA_UNICODE = 1, + XMTP_FFI_REACTION_SCHEMA_SHORTCODE = 2, + XMTP_FFI_REACTION_SCHEMA_CUSTOM = 3, +} +#[repr(i32)] +#[non_exhaustive] #[doc = " Consent entity type."] #[derive(Debug, Copy, Clone, Hash, PartialEq, Eq)] pub enum XmtpFfiConsentEntityType { @@ -693,6 +710,43 @@ impl Default for XmtpFfiGroupPermissions { } } } +#[doc = " Ffi data for Reaction content"] +#[repr(C)] +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct XmtpFfiReaction { + pub reference: *mut ::core::ffi::c_char, + pub reference_inbox_id: *mut ::core::ffi::c_char, + pub sender_inbox_id: *mut ::core::ffi::c_char, + pub action: XmtpFfiReactionAction, + pub content: *mut ::core::ffi::c_char, + pub schema: XmtpFfiReactionSchema, +} +#[allow(clippy::unnecessary_operation, clippy::identity_op)] +const _: () = { + ["Size of XmtpFfiReaction"][::core::mem::size_of::() - 48usize]; + ["Alignment of XmtpFfiReaction"][::core::mem::align_of::() - 8usize]; + ["Offset of field: XmtpFfiReaction::reference"] + [::core::mem::offset_of!(XmtpFfiReaction, reference) - 0usize]; + ["Offset of field: XmtpFfiReaction::reference_inbox_id"] + [::core::mem::offset_of!(XmtpFfiReaction, reference_inbox_id) - 8usize]; + ["Offset of field: XmtpFfiReaction::sender_inbox_id"] + [::core::mem::offset_of!(XmtpFfiReaction, sender_inbox_id) - 16usize]; + ["Offset of field: XmtpFfiReaction::action"] + [::core::mem::offset_of!(XmtpFfiReaction, action) - 24usize]; + ["Offset of field: XmtpFfiReaction::content"] + [::core::mem::offset_of!(XmtpFfiReaction, content) - 32usize]; + ["Offset of field: XmtpFfiReaction::schema"] + [::core::mem::offset_of!(XmtpFfiReaction, schema) - 40usize]; +}; +impl Default for XmtpFfiReaction { + fn default() -> Self { + let mut s = ::core::mem::MaybeUninit::::uninit(); + unsafe { + ::core::ptr::write_bytes(s.as_mut_ptr(), 0, 1); + s.assume_init() + } + } +} #[doc = " An enriched (decoded) message exposed to C.\n Contains metadata + the original encoded content bytes for upper-layer decoding."] #[repr(C)] #[derive(Debug, Copy, Clone, PartialEq, Eq)] @@ -717,6 +771,8 @@ pub struct XmtpFfiEnrichedMessage { pub fallback_text: *mut ::core::ffi::c_char, #[doc = " Expiration timestamp in nanoseconds (0 = no expiration)."] pub expires_at_ns: i64, + #[doc = " Array of reactions"] + pub reactions: *mut XmtpFfiReaction, #[doc = " Number of reactions."] pub num_reactions: i32, #[doc = " Number of replies."] @@ -728,7 +784,7 @@ pub struct XmtpFfiEnrichedMessage { } #[allow(clippy::unnecessary_operation, clippy::identity_op)] const _: () = { - ["Size of XmtpFfiEnrichedMessage"][::core::mem::size_of::() - 104usize]; + ["Size of XmtpFfiEnrichedMessage"][::core::mem::size_of::() - 112usize]; ["Alignment of XmtpFfiEnrichedMessage"] [::core::mem::align_of::() - 8usize]; ["Offset of field: XmtpFfiEnrichedMessage::id"] @@ -753,14 +809,16 @@ const _: () = { [::core::mem::offset_of!(XmtpFfiEnrichedMessage, fallback_text) - 64usize]; ["Offset of field: XmtpFfiEnrichedMessage::expires_at_ns"] [::core::mem::offset_of!(XmtpFfiEnrichedMessage, expires_at_ns) - 72usize]; + ["Offset of field: XmtpFfiEnrichedMessage::reactions"] + [::core::mem::offset_of!(XmtpFfiEnrichedMessage, reactions) - 80usize]; ["Offset of field: XmtpFfiEnrichedMessage::num_reactions"] - [::core::mem::offset_of!(XmtpFfiEnrichedMessage, num_reactions) - 80usize]; + [::core::mem::offset_of!(XmtpFfiEnrichedMessage, num_reactions) - 88usize]; ["Offset of field: XmtpFfiEnrichedMessage::num_replies"] - [::core::mem::offset_of!(XmtpFfiEnrichedMessage, num_replies) - 84usize]; + [::core::mem::offset_of!(XmtpFfiEnrichedMessage, num_replies) - 92usize]; ["Offset of field: XmtpFfiEnrichedMessage::content_bytes"] - [::core::mem::offset_of!(XmtpFfiEnrichedMessage, content_bytes) - 88usize]; + [::core::mem::offset_of!(XmtpFfiEnrichedMessage, content_bytes) - 96usize]; ["Offset of field: XmtpFfiEnrichedMessage::content_bytes_len"] - [::core::mem::offset_of!(XmtpFfiEnrichedMessage, content_bytes_len) - 96usize]; + [::core::mem::offset_of!(XmtpFfiEnrichedMessage, content_bytes_len) - 104usize]; }; impl Default for XmtpFfiEnrichedMessage { fn default() -> Self { diff --git a/xmtp/src/content.rs b/xmtp/src/content.rs index 3ebee51..82e6dfb 100644 --- a/xmtp/src/content.rs +++ b/xmtp/src/content.rs @@ -10,6 +10,7 @@ use prost::Message as ProstMessage; use crate::conversation::{Conversation, Message}; use crate::error::Result; +use crate::ffi::take_c_string; use crate::types::SendOptions; /// Content type identifier on the XMTP network. @@ -91,6 +92,18 @@ pub enum ReactionAction { Removed = 2, } +impl ReactionAction { + /// Convert FFI `ReactionAction` (i32 enum) into the safe SDK type. + pub const fn from_ffi(v: i32) -> Option { + match v { + 0 => Some(Self::Unspecified), + 1 => Some(Self::Added), + 2 => Some(Self::Removed), + _ => None, + } + } +} + /// Reaction content schema. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, prost::Enumeration)] #[repr(i32)] @@ -105,6 +118,19 @@ pub enum ReactionSchema { Custom = 3, } +impl ReactionSchema { + /// Convert FFI `ReactionSchema` (i32 enum) into the safe SDK type. + pub const fn from_ffi(v: i32) -> Option { + match v { + 0 => Some(Self::Unspecified), + 1 => Some(Self::Unicode), + 2 => Some(Self::Shortcode), + 3 => Some(Self::Custom), + _ => None, + } + } +} + /// Metadata for a remotely hosted encrypted attachment. #[derive(Clone, PartialEq, Eq, Hash, ProstMessage)] pub struct RemoteAttachmentInfo { @@ -292,6 +318,8 @@ pub struct Reaction { pub reference: String, /// Inbox ID of the referenced message's sender. pub reference_inbox_id: String, + /// Inbox ID of the reaction sender. + pub sender_inbox_id: String, /// Reaction action. pub action: ReactionAction, /// The emoji / shortcode / custom content. @@ -300,6 +328,27 @@ pub struct Reaction { pub schema: ReactionSchema, } +impl Reaction { + /// Convert a single FFI reaction (already dereferenced) into the safe SDK type. + pub(crate) fn from_ffi(ffi: &xmtp_sys::XmtpFfiReaction) -> Self { + Self { + // SAFETY: `reference` is a C string allocated by the FFI layer (or null, handled by `take_c_string`). + reference: unsafe { take_c_string(ffi.reference) }.unwrap_or_default(), + // SAFETY: `reference_inbox_id` is a C string allocated by the FFI layer (or null, handled by `take_c_string`). + reference_inbox_id: unsafe { take_c_string(ffi.reference_inbox_id) } + .unwrap_or_default(), + // SAFETY: `sender_inbox_id` is a C string allocated by the FFI layer (or null, handled by `take_c_string`). + sender_inbox_id: unsafe { take_c_string(ffi.sender_inbox_id) }.unwrap_or_default(), + action: ReactionAction::from_ffi(ffi.action as i32) + .unwrap_or(ReactionAction::Unspecified), + // SAFETY: `content` is a C string allocated by the FFI layer (or null, handled by `take_c_string`). + content: unsafe { take_c_string(ffi.content) }.unwrap_or_default(), + schema: ReactionSchema::from_ffi(ffi.schema as i32) + .unwrap_or(ReactionSchema::Unspecified), + } + } +} + /// A decoded reply. #[derive(Debug, Clone)] pub struct Reply { @@ -506,6 +555,7 @@ pub fn decode(raw: &[u8]) -> Result { Ok(Content::Reaction(Reaction { reference: rv2.reference, reference_inbox_id: rv2.reference_inbox_id, + sender_inbox_id: String::new(), action: ReactionAction::try_from(rv2.action).unwrap_or(ReactionAction::Unspecified), content: rv2.content, schema: ReactionSchema::try_from(rv2.schema).unwrap_or(ReactionSchema::Unspecified), diff --git a/xmtp/src/conversation.rs b/xmtp/src/conversation.rs index 100e695..4189b8f 100644 --- a/xmtp/src/conversation.rs +++ b/xmtp/src/conversation.rs @@ -8,11 +8,12 @@ use std::ffi::{CStr, c_char}; use std::ptr; +use crate::content::Reaction; use crate::error::{self, Result}; use crate::ffi::{ FfiList, OwnedHandle, borrow_c_string, borrow_nullable_string, ffi_usize, identifiers_to_ffi, - read_borrowed_strings, take_c_string, take_nullable_string, to_c_string, to_c_string_array, - to_ffi_len, + reactions_from_raw_parts, read_borrowed_strings, take_c_string, take_nullable_string, + to_c_string, to_c_string_array, to_ffi_len, }; use crate::types::{ AccountIdentifier, ConsentState, ConversationDebugInfo, ConversationMetadata, ConversationType, @@ -81,6 +82,8 @@ pub struct Message { pub content: Vec, /// Expiration timestamp in nanoseconds (0 = no expiration). pub expires_at_ns: i64, + /// List of Reactions + pub reactions: Vec, /// Number of reactions to this message. pub num_reactions: i32, /// Number of replies to this message. @@ -701,6 +704,8 @@ pub(crate) fn read_enriched_message_list( fallback: unsafe { borrow_nullable_string(m.fallback_text) }, content, expires_at_ns: m.expires_at_ns, + // SAFETY: `m.reactions` points to a valid array of `m.num_reactions` + reactions: unsafe { reactions_from_raw_parts(m.reactions, m.num_reactions) }, num_reactions: m.num_reactions, num_replies: m.num_replies, }); diff --git a/xmtp/src/ffi.rs b/xmtp/src/ffi.rs index 8347399..e60f8b0 100644 --- a/xmtp/src/ffi.rs +++ b/xmtp/src/ffi.rs @@ -7,6 +7,7 @@ use std::ffi::{CStr, CString, c_char}; use std::ptr::NonNull; +use crate::content::Reaction; use crate::error::{Result, XmtpError}; /// RAII wrapper for an opaque FFI pointer. Calls `free` on drop. @@ -227,6 +228,18 @@ pub(crate) unsafe fn borrow_nullable_string(ptr: *mut c_char) -> Option } } +pub(crate) unsafe fn reactions_from_raw_parts( + ptr: *mut xmtp_sys::XmtpFfiReaction, + len: i32, +) -> Vec { + if ptr.is_null() || len <= 0 { + return Vec::new(); + } + // SAFETY: `ptr` is non-null and `len` is positive + let slice = unsafe { std::slice::from_raw_parts(ptr, ffi_usize(len)) }; + slice.iter().map(Reaction::from_ffi).collect() +} + #[cfg(test)] mod tests { use super::*;