Skip to main content

xmtp_mls/groups/
message_list.rs

1use crate::context::XmtpSharedContext;
2use crate::groups::MlsGroup;
3use crate::messages::decoded_message::DecodedMessage;
4use crate::messages::enrichment::{
5    EnrichMessageError, EnrichedStoredMessage, enrich_messages, enrich_messages_with_stored,
6};
7use xmtp_db::DbQuery;
8use xmtp_db::group_message::{ContentType as DbContentType, MsgQueryArgs};
9use xmtp_db::prelude::QueryGroupMessage;
10
11mod page;
12pub use page::{EnrichedHistoryPage, EnrichedRecoveryPage};
13
14impl<Context> MlsGroup<Context>
15where
16    Context: XmtpSharedContext,
17{
18    #[xmtp_common::mls_span]
19    pub fn find_messages_v2(
20        &self,
21        query: &MsgQueryArgs,
22    ) -> Result<Vec<DecodedMessage>, EnrichMessageError> {
23        let conn = self.context.db();
24        self.find_messages_v2_with_conn(query, conn)
25    }
26
27    #[xmtp_common::mls_span]
28    pub fn find_messages_v2_with_stored(
29        &self,
30        query: &MsgQueryArgs,
31    ) -> Result<Vec<EnrichedStoredMessage>, EnrichMessageError> {
32        let conn = self.context.db();
33        use xmtp_db::delivery::QueryDelivery;
34        let rows = conn
35            .app_visible_message_rows(
36                &self.group_id,
37                &filter_out_hidden_message_types_from_query(query),
38            )
39            .map_err(EnrichMessageError::Storage)?;
40        let cursors: std::collections::HashMap<_, _> = rows
41            .iter()
42            .map(|row| (row.stored.id.clone(), row.cursor))
43            .collect();
44        let mut enriched = enrich_messages_with_stored(
45            conn,
46            &self.group_id,
47            rows.into_iter().map(|row| row.stored).collect(),
48        )?;
49        for message in &mut enriched {
50            message.delivery_cursor = cursors.get(&message.stored.id).copied().flatten();
51        }
52        Ok(enriched)
53    }
54
55    #[xmtp_common::mls_span]
56    pub fn find_messages_v2_with_conn<C>(
57        &self,
58        query: &MsgQueryArgs,
59        conn: C,
60    ) -> Result<Vec<DecodedMessage>, EnrichMessageError>
61    where
62        C: QueryGroupMessage + DbQuery,
63    {
64        let initial_messages = conn.get_group_messages(
65            &self.group_id,
66            &filter_out_hidden_message_types_from_query(query),
67        )?;
68
69        enrich_messages(conn, &self.group_id, initial_messages)
70    }
71}
72
73fn filter_out_hidden_message_types_from_query(query: &MsgQueryArgs) -> MsgQueryArgs {
74    let mut new_query = query.clone();
75    let hidden_message_types = vec![
76        DbContentType::Reaction,
77        DbContentType::ReadReceipt,
78        DbContentType::DeleteMessage,
79    ];
80
81    let excluded_content_types = match &query.exclude_content_types {
82        Some(excluded) => {
83            let mut content_types = excluded.clone();
84            content_types.extend(
85                hidden_message_types
86                    .into_iter()
87                    .filter(|t| !excluded.contains(t)),
88            );
89            content_types
90        }
91        None => hidden_message_types,
92    };
93    new_query.exclude_content_types = Some(excluded_content_types);
94    new_query
95}
96
97#[cfg(test)]
98mod tests {
99    use super::*;
100    use crate::builder::ClientBuilder;
101    use crate::groups::QueryableContentFields;
102    use crate::messages::decoded_message::{ContentDecodeFailureKind, MessageBody};
103    use hex::ToHexExt;
104    use xmtp_common::time::now_ns;
105    use xmtp_content_types::ContentCodec;
106    use xmtp_content_types::test_utils::TestContentGenerator;
107    use xmtp_content_types::text::TextCodec;
108    use xmtp_cryptography::utils::generate_local_wallet;
109    use xmtp_db::Store;
110    use xmtp_db::group_message::{
111        ContentType as DbContentType, DeliveryStatus, GroupMessageKind, StoredGroupMessage,
112    };
113    use xmtp_proto::types::GroupId;
114    use xmtp_proto::xmtp::mls::message_contents::content_types::ReactionAction;
115    use xmtp_proto::xmtp::mls::message_contents::{ContentTypeId, EncodedContent};
116
117    async fn setup_test_group() -> (MlsGroup<impl XmtpSharedContext>, impl XmtpSharedContext) {
118        let client = ClientBuilder::new_test_client(&generate_local_wallet()).await;
119        let group = client.create_group(None, Default::default()).unwrap();
120
121        (group, client.context.clone())
122    }
123
124    fn create_test_message(
125        group_id: &GroupId,
126        message_id: Vec<u8>,
127        encoded_content: EncodedContent,
128        sent_at_ns: i64,
129        sender_inbox_id: String,
130    ) -> StoredGroupMessage {
131        let content_bytes = xmtp_content_types::encoded_content_to_bytes(encoded_content.clone());
132        let queryable_fields =
133            QueryableContentFields::try_from(encoded_content).unwrap_or_default();
134
135        StoredGroupMessage {
136            id: message_id,
137            group_id: *group_id,
138            decrypted_message_bytes: content_bytes,
139            sent_at_ns,
140            kind: GroupMessageKind::Application,
141            sender_installation_id: vec![1, 2, 3],
142            sender_inbox_id,
143            delivery_status: DeliveryStatus::Published,
144            content_type: queryable_fields.content_type,
145            version_major: queryable_fields.version_major,
146            version_minor: queryable_fields.version_minor,
147            authority_id: queryable_fields.authority_id,
148            reference_id: queryable_fields.reference_id,
149            sequence_id: 0,
150            envelope_hash: None,
151            expiry_ns: None,
152            expire_at_ns: None,
153            inserted_at_ns: 0,
154            should_push: true,
155            idempotency_key: sent_at_ns.to_string(),
156        }
157    }
158
159    // For tests that need malformed content
160    fn create_test_message_raw(
161        group_id: &GroupId,
162        message_id: Vec<u8>,
163        content: Vec<u8>,
164        sent_at_ns: i64,
165        sender_inbox_id: String,
166        content_type_id: Option<ContentTypeId>,
167        reference_id: Option<Vec<u8>>,
168    ) -> StoredGroupMessage {
169        let queryable_fields = content_type_id
170            .as_ref()
171            .map(|ct| {
172                (
173                    DbContentType::from_identifier(&ct.authority_id, &ct.type_id, ct.version_major),
174                    ct.version_major as i32,
175                    ct.version_minor as i32,
176                    ct.authority_id.clone(),
177                )
178            })
179            .unwrap_or((DbContentType::Text, 1, 0, "xmtp.org".to_string()));
180
181        StoredGroupMessage {
182            id: message_id,
183            group_id: *group_id,
184            decrypted_message_bytes: content,
185            sent_at_ns,
186            kind: GroupMessageKind::Application,
187            sender_installation_id: vec![1, 2, 3],
188            sender_inbox_id,
189            delivery_status: DeliveryStatus::Published,
190            content_type: queryable_fields.0,
191            version_major: queryable_fields.1,
192            version_minor: queryable_fields.2,
193            authority_id: queryable_fields.3,
194            reference_id,
195            sequence_id: 0,
196            envelope_hash: None,
197            expiry_ns: None,
198            expire_at_ns: None,
199            inserted_at_ns: 0,
200            should_push: true,
201            idempotency_key: sent_at_ns.to_string(),
202        }
203    }
204
205    // ===== Assertion Helpers =====
206
207    fn assert_message_count(messages: &[DecodedMessage], expected: usize) {
208        assert_eq!(
209            messages.len(),
210            expected,
211            "Expected {} messages, got {}",
212            expected,
213            messages.len()
214        );
215    }
216
217    fn assert_text_content(message: &DecodedMessage, expected: &str) {
218        match &message.content {
219            MessageBody::Text(text) => assert_eq!(text.content, expected),
220            _ => panic!("Expected text message, got {:?}", message.content),
221        }
222    }
223
224    fn assert_reaction_count(message: &DecodedMessage, expected: usize) {
225        assert_eq!(
226            message.reactions.len(),
227            expected,
228            "Expected {} reactions, got {}",
229            expected,
230            message.reactions.len()
231        );
232    }
233
234    fn assert_has_reaction(message: &DecodedMessage, emoji: &str) {
235        assert!(
236            message.reactions.iter().any(|r| {
237                if let MessageBody::Reaction(reaction) = &r.content {
238                    reaction.content == emoji
239                } else {
240                    false
241                }
242            }),
243            "Expected reaction '{}' not found",
244            emoji
245        );
246    }
247
248    fn assert_reply_references(reply: &DecodedMessage, expected_ref_id: &[u8]) {
249        match &reply.content {
250            MessageBody::Reply(reply_body) => {
251                assert!(
252                    reply_body.in_reply_to.is_some(),
253                    "Reply should have in_reply_to populated"
254                );
255                let referenced = reply_body.in_reply_to.as_ref().unwrap();
256                assert_eq!(referenced.metadata.id, expected_ref_id);
257            }
258            _ => panic!("Expected reply message, got {:?}", reply.content),
259        }
260    }
261
262    fn assert_reply_has_no_reference(reply: &DecodedMessage) {
263        match &reply.content {
264            MessageBody::Reply(reply_body) => {
265                assert!(
266                    reply_body.in_reply_to.is_none(),
267                    "Reply should not have in_reply_to populated"
268                );
269            }
270            _ => panic!("Expected reply message, got {:?}", reply.content),
271        }
272    }
273
274    fn find_message_by_id<'a>(messages: &'a [DecodedMessage], id: &[u8]) -> &'a DecodedMessage {
275        messages
276            .iter()
277            .find(|m| m.metadata.id == id)
278            .unwrap_or_else(|| panic!("Message with id {:?} not found", id))
279    }
280
281    fn create_and_store_message<S>(
282        conn: &S,
283        group_id: &GroupId,
284        message_id: Vec<u8>,
285        content: EncodedContent,
286        timestamp_offset: i64,
287        sender: &str,
288    ) -> Vec<u8>
289    where
290        StoredGroupMessage: Store<S>,
291    {
292        let msg = create_test_message(
293            group_id,
294            message_id.clone(),
295            content,
296            now_ns() + timestamp_offset,
297            sender.to_string(),
298        );
299        msg.store(conn).unwrap();
300        message_id
301    }
302
303    // ===== Tests =====
304
305    #[xmtp_common::test]
306    async fn test_exclude_content_types_with_custom_exclusions() {
307        let (group, context) = setup_test_group().await;
308        let conn = context.db();
309
310        // Store a text message
311        create_and_store_message(
312            &conn,
313            &group.group_id,
314            vec![1],
315            TestContentGenerator::text_content("Hello World"),
316            0,
317            "sender1",
318        );
319
320        // Store a GroupUpdated message
321        create_and_store_message(
322            &conn,
323            &group.group_id,
324            vec![2],
325            TestContentGenerator::group_updated_content(vec!["inbox1".to_string()]),
326            1000,
327            "sender2",
328        );
329
330        // Query with default args - should return both messages
331        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
332        assert_message_count(&messages, 2);
333
334        // Query excluding Text messages - should only return the GroupUpdated message
335        let query = MsgQueryArgs {
336            exclude_content_types: Some(vec![DbContentType::Text]),
337            ..Default::default()
338        };
339        let messages = group.find_messages_v2(&query).unwrap();
340
341        assert_message_count(&messages, 1);
342        if let MessageBody::GroupUpdated(_) = &messages[0].content {
343            // Expected
344        } else {
345            panic!(
346                "Expected GroupUpdated message, got {:?}",
347                messages[0].content
348            );
349        }
350    }
351
352    #[xmtp_common::test]
353    async fn test_find_messages_no_reactions_or_replies() {
354        let (group, context) = setup_test_group().await;
355        let conn = context.db();
356
357        // Store two simple text messages
358        create_and_store_message(
359            &conn,
360            &group.group_id,
361            vec![1],
362            TestContentGenerator::text_content("Hello World"),
363            0,
364            "sender1",
365        );
366
367        create_and_store_message(
368            &conn,
369            &group.group_id,
370            vec![2],
371            TestContentGenerator::text_content("Another message"),
372            1000,
373            "sender2",
374        );
375
376        // Query and verify
377        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
378        assert_message_count(&messages, 2);
379        assert_text_content(&messages[0], "Hello World");
380        assert_text_content(&messages[1], "Another message");
381        assert_reaction_count(&messages[0], 0);
382        assert_reaction_count(&messages[1], 0);
383    }
384
385    #[xmtp_common::test]
386    async fn test_find_messages_v2_with_reactions() {
387        let (group, context) = setup_test_group().await;
388        let conn = context.db();
389
390        // Store a text message
391        let msg_id = vec![1, 2, 3];
392        let msg_id_hex = msg_id.encode_hex();
393        create_and_store_message(
394            &conn,
395            &group.group_id,
396            msg_id.clone(),
397            TestContentGenerator::text_content("Hello World"),
398            0,
399            "sender1",
400        );
401
402        // Store reactions to the message
403        create_and_store_message(
404            &conn,
405            &group.group_id,
406            vec![4],
407            TestContentGenerator::reaction_content(&msg_id_hex, "👍", ReactionAction::Added),
408            1000,
409            "reactor1",
410        );
411
412        create_and_store_message(
413            &conn,
414            &group.group_id,
415            vec![5],
416            TestContentGenerator::reaction_content(&msg_id_hex, "❤️", ReactionAction::Added),
417            2000,
418            "reactor2",
419        );
420
421        // Query messages
422        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
423
424        // Should have 1 message (reactions are attached to the message, not returned separately)
425        assert_message_count(&messages, 1);
426
427        // Find the original message (should be first based on timestamp)
428        let original_msg = find_message_by_id(&messages, &msg_id);
429        assert_reaction_count(original_msg, 2);
430        assert_has_reaction(original_msg, "👍");
431        assert_has_reaction(original_msg, "❤️");
432    }
433
434    #[xmtp_common::test]
435    async fn test_find_messages_with_replies() {
436        let (group, context) = setup_test_group().await;
437        let conn = context.db();
438
439        // Store original message
440        let msg_id = vec![1, 2, 3];
441        let msg_id_hex = msg_id.encode_hex();
442        create_and_store_message(
443            &conn,
444            &group.group_id,
445            msg_id.clone(),
446            TestContentGenerator::text_content("Original message"),
447            0,
448            "sender1",
449        );
450
451        // Store a reply to the message
452        let reply_id = create_and_store_message(
453            &conn,
454            &group.group_id,
455            vec![4, 5, 6],
456            TestContentGenerator::reply_content(
457                &msg_id_hex,
458                TextCodec::content_type(),
459                b"This is a reply".to_vec(),
460            ),
461            1000,
462            "replier1",
463        );
464
465        // Query messages
466        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
467
468        assert_message_count(&messages, 2);
469
470        // Find the reply message and verify it references the original
471        let reply_msg = find_message_by_id(&messages, &reply_id);
472        assert_reply_references(reply_msg, &msg_id);
473
474        // Verify the referenced message content
475        if let MessageBody::Reply(reply) = &reply_msg.content {
476            let referenced_msg = reply.in_reply_to.as_ref().unwrap();
477            assert_text_content(referenced_msg, "Original message");
478        }
479    }
480
481    #[xmtp_common::test]
482    async fn test_find_messages_invalid_reply_reference() {
483        let (group, context) = setup_test_group().await;
484        let conn = context.db();
485
486        // Store a reply with invalid reference ID (not valid hex)
487        create_and_store_message(
488            &conn,
489            &group.group_id,
490            vec![1],
491            TestContentGenerator::reply_content(
492                "not-valid-hex!@#",
493                TextCodec::content_type(),
494                b"This is a reply".to_vec(),
495            ),
496            0,
497            "replier1",
498        );
499
500        // A sender-controlled reference that is not a message id resolves to
501        // no parent; the reply is still valid content.
502        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
503        assert_message_count(&messages, 1);
504        assert_reply_has_no_reference(&messages[0]);
505        if let MessageBody::Reply(reply) = &messages[0].content {
506            assert_eq!(reply.reference_id, "not-valid-hex!@#");
507        }
508    }
509
510    #[xmtp_common::test]
511    async fn test_find_messages_missing_reply_reference() {
512        let (group, context) = setup_test_group().await;
513        let conn = context.db();
514
515        // Store a reply with valid hex reference but non-existent message
516        let non_existent_id = vec![99, 99, 99];
517        create_and_store_message(
518            &conn,
519            &group.group_id,
520            vec![1],
521            TestContentGenerator::reply_content(
522                &non_existent_id.encode_hex(),
523                TextCodec::content_type(),
524                b"This is a reply".to_vec(),
525            ),
526            0,
527            "replier1",
528        );
529
530        // Query messages - should still return the reply but with None in_reply_to
531        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
532        assert_message_count(&messages, 1);
533        assert_reply_has_no_reference(&messages[0]);
534
535        if let MessageBody::Reply(reply) = &messages[0].content {
536            assert_eq!(reply.reference_id, non_existent_id.encode_hex());
537        }
538    }
539
540    // verifies: CTYPE-008, CTYPE-009
541    #[xmtp_common::test]
542    async fn test_find_messages_undecodable_messages() {
543        let (group, context) = setup_test_group().await;
544        let conn = context.db();
545
546        // Store a valid text message
547        create_and_store_message(
548            &conn,
549            &group.group_id,
550            vec![1],
551            TestContentGenerator::text_content("Valid message"),
552            0,
553            "sender1",
554        );
555
556        // An old row: bytes that are not an EncodedContent, cached as Text
557        // the way a client stored it before classification was checked.
558        let malformed = vec![0xff, 0x00, 0x80];
559        create_test_message_raw(
560            &group.group_id,
561            vec![2],
562            malformed.clone(),
563            now_ns() + 1000,
564            "sender2".into(),
565            None,
566            None,
567        )
568        .store(&conn)
569        .unwrap();
570
571        // A complete typed envelope with no core codec
572        create_and_store_message(
573            &conn,
574            &group.group_id,
575            vec![3],
576            TestContentGenerator::invalid_content(),
577            2000,
578            "sender3",
579        );
580
581        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
582        assert_message_count(&messages, 3);
583        assert_text_content(&messages[0], "Valid message");
584
585        assert_eq!(messages[1].metadata.id, vec![2]);
586        assert_eq!(messages[1].metadata.content_type, None);
587        let MessageBody::Undecodable(undecodable) = &messages[1].content else {
588            panic!(
589                "expected undecodable content, got {:?}",
590                messages[1].content
591            );
592        };
593        assert_eq!(undecodable.raw_bytes, malformed);
594        assert_eq!(
595            undecodable.failure.kind,
596            ContentDecodeFailureKind::MalformedEnvelope
597        );
598
599        let MessageBody::Custom(content) = &messages[2].content else {
600            panic!("expected custom content for unknown type message");
601        };
602        assert_eq!(
603            content.encoded.fallback,
604            Some("Invalid message".to_string())
605        );
606        assert_eq!(
607            messages[2].fallback_text,
608            Some("Invalid message".to_string())
609        );
610    }
611
612    /// History, lookup by id, and a reply's parent all keep a malformed row
613    /// with its id and exact bytes, and later rows stay visible.
614    // verifies: CTYPE-008
615    #[xmtp_common::test(unwrap_try = true)]
616    async fn malformed_rows_are_kept_on_history_lookup_and_parent_paths() {
617        let client = ClientBuilder::new_test_client(&generate_local_wallet()).await;
618        let group = client.create_group(None, Default::default())?;
619        let conn = client.context.db();
620
621        let malformed = vec![0xff, 0x00, 0x80];
622        let malformed_id = vec![7, 7, 7];
623        create_test_message_raw(
624            &group.group_id,
625            malformed_id.clone(),
626            malformed.clone(),
627            now_ns(),
628            "sender1".into(),
629            None,
630            None,
631        )
632        .store(&conn)?;
633        let reply_id = create_and_store_message(
634            &conn,
635            &group.group_id,
636            vec![8, 8, 8],
637            TestContentGenerator::reply_content(
638                &malformed_id.encode_hex(),
639                TextCodec::content_type(),
640                b"replying to bytes".to_vec(),
641            ),
642            1000,
643            "sender2",
644        );
645        create_and_store_message(
646            &conn,
647            &group.group_id,
648            vec![9, 9, 9],
649            TestContentGenerator::text_content("later"),
650            2000,
651            "sender3",
652        );
653
654        let messages = group.find_messages_v2(&MsgQueryArgs::default())?;
655        assert_eq!(
656            messages
657                .iter()
658                .map(|message| message.metadata.id.clone())
659                .collect::<Vec<_>>(),
660            vec![malformed_id.clone(), reply_id.clone(), vec![9, 9, 9]]
661        );
662        let is_kept = |message: &DecodedMessage| {
663            matches!(&message.content, MessageBody::Undecodable(undecodable)
664                if undecodable.raw_bytes == malformed
665                    && undecodable.failure.kind == ContentDecodeFailureKind::MalformedEnvelope)
666        };
667        assert!(is_kept(&messages[0]), "history: {:?}", messages[0].content);
668        assert_text_content(&messages[2], "later");
669
670        let MessageBody::Reply(reply) = &messages[1].content else {
671            panic!("expected reply, got {:?}", messages[1].content);
672        };
673        let parent = reply.in_reply_to.as_ref().expect("reply parent");
674        assert_eq!(parent.metadata.id, malformed_id);
675        assert!(is_kept(parent), "parent: {:?}", parent.content);
676
677        let looked_up = client.message_v2(malformed_id.clone())?;
678        assert_eq!(looked_up.metadata.id, malformed_id);
679        assert!(is_kept(&looked_up), "lookup: {:?}", looked_up.content);
680    }
681
682    #[xmtp_common::test]
683    async fn test_find_messages_invalid_reactions() {
684        let (group, context) = setup_test_group().await;
685        let conn = context.db();
686
687        // Store a text message
688        let msg_id = vec![1, 2, 3];
689        let msg_id_hex = msg_id.encode_hex();
690        create_and_store_message(
691            &conn,
692            &group.group_id,
693            msg_id.clone(),
694            TestContentGenerator::text_content("Hello World"),
695            0,
696            "sender1",
697        );
698
699        // Store a valid reaction
700        create_and_store_message(
701            &conn,
702            &group.group_id,
703            vec![4],
704            TestContentGenerator::reaction_content(&msg_id_hex, "👍", ReactionAction::Added),
705            1000,
706            "reactor1",
707        );
708
709        // Store an invalid reaction (malformed content)
710        let reaction_type = xmtp_content_types::reaction::ReactionCodec::content_type();
711        let invalid_reaction = create_test_message_raw(
712            &group.group_id,
713            vec![5],
714            b"invalid reaction bytes".to_vec(),
715            now_ns() + 2000,
716            "reactor2".to_string(),
717            Some(reaction_type),
718            Some(msg_id.clone()),
719        );
720        invalid_reaction.store(&conn).unwrap();
721
722        // Query messages
723        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
724
725        // Should have 1 message total
726        assert_message_count(&messages, 1);
727
728        // Find the original message
729        let original_msg = find_message_by_id(&messages, &msg_id);
730
731        // Should only have the valid reaction, invalid one should be filtered out
732        assert_reaction_count(original_msg, 1);
733        assert_has_reaction(original_msg, "👍");
734    }
735
736    #[xmtp_common::test]
737    async fn test_hidden_message_types_are_filtered() {
738        let (group, context) = setup_test_group().await;
739        let conn = context.db();
740
741        // Store a regular text message
742        create_and_store_message(
743            &conn,
744            &group.group_id,
745            vec![1],
746            TestContentGenerator::text_content("Hello World"),
747            0,
748            "sender1",
749        );
750
751        // Store a reaction (should be hidden)
752        let msg_id_hex = vec![1].encode_hex();
753        create_and_store_message(
754            &conn,
755            &group.group_id,
756            vec![2],
757            TestContentGenerator::reaction_content(&msg_id_hex, "👍", ReactionAction::Added),
758            1000,
759            "reactor1",
760        );
761
762        // Store a read receipt (should be hidden)
763        create_and_store_message(
764            &conn,
765            &group.group_id,
766            vec![3],
767            TestContentGenerator::read_receipt_content(),
768            2000,
769            "reader1",
770        );
771
772        // Store a delete message (should be hidden)
773        create_and_store_message(
774            &conn,
775            &group.group_id,
776            vec![4],
777            TestContentGenerator::delete_message_content(&msg_id_hex),
778            3000,
779            "deleter1",
780        );
781
782        // Store another text message
783        create_and_store_message(
784            &conn,
785            &group.group_id,
786            vec![5],
787            TestContentGenerator::text_content("Second message"),
788            4000,
789            "sender2",
790        );
791
792        // Query messages - should only return the 2 text messages
793        // Reactions, ReadReceipt, and DeleteMessage should be filtered out
794        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
795
796        // We expect only 2 messages (the text messages)
797        // Reaction is attached to message 1, ReadReceipt and DeleteMessage are filtered
798        assert_message_count(&messages, 2);
799        assert_text_content(&messages[0], "Hello World");
800        assert_text_content(&messages[1], "Second message");
801
802        // The reaction should be attached to the first message
803        assert_reaction_count(&messages[0], 1);
804        assert_has_reaction(&messages[0], "👍");
805    }
806
807    #[xmtp_common::test]
808    async fn test_find_messages_chain_of_replies() {
809        let (group, context) = setup_test_group().await;
810        let conn = context.db();
811
812        // Store original message
813        let msg1_id = vec![1];
814        let msg1_id_hex = msg1_id.encode_hex();
815        create_and_store_message(
816            &conn,
817            &group.group_id,
818            msg1_id.clone(),
819            TestContentGenerator::text_content("Original message"),
820            0,
821            "sender1",
822        );
823
824        // Store first reply (reply to original)
825        let msg2_id = vec![2];
826        let msg2_id_hex = msg2_id.encode_hex();
827        create_and_store_message(
828            &conn,
829            &group.group_id,
830            msg2_id.clone(),
831            TestContentGenerator::reply_content(
832                &msg1_id_hex,
833                TextCodec::content_type(),
834                b"First reply".to_vec(),
835            ),
836            1000,
837            "replier1",
838        );
839
840        // Store second reply (reply to first reply)
841        let msg3_id = vec![3];
842        create_and_store_message(
843            &conn,
844            &group.group_id,
845            msg3_id.clone(),
846            TestContentGenerator::reply_content(
847                &msg2_id_hex,
848                TextCodec::content_type(),
849                b"Second reply - reply to reply".to_vec(),
850            ),
851            2000,
852            "replier2",
853        );
854
855        // Query messages
856        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
857
858        assert_message_count(&messages, 3);
859
860        // Find the first reply
861        let first_reply = find_message_by_id(&messages, &msg2_id);
862        if let MessageBody::Reply(reply) = &first_reply.content {
863            // Should have reference to original message
864            assert!(reply.in_reply_to.is_some());
865            let referenced = reply.in_reply_to.as_ref().unwrap();
866            assert_eq!(referenced.metadata.id, msg1_id);
867
868            // The referenced message should NOT have its own in_reply_to (no recursive resolution)
869            if let MessageBody::Text(_) = &referenced.content {
870                // Expected: original message is just text
871            } else {
872                panic!("Expected text in first layer reference");
873            }
874        } else {
875            panic!("Expected reply message");
876        }
877
878        // Find the second reply (reply to reply)
879        let second_reply = find_message_by_id(&messages, &msg3_id);
880        if let MessageBody::Reply(reply) = &second_reply.content {
881            // Should have reference to first reply
882            assert!(reply.in_reply_to.is_some());
883            let referenced = reply.in_reply_to.as_ref().unwrap();
884            assert_eq!(referenced.metadata.id, msg2_id);
885
886            // The referenced message should be a Reply but its in_reply_to should be None (not resolved recursively)
887            if let MessageBody::Reply(inner_reply) = &referenced.content {
888                // We don't recursively resolve, so the inner reply's in_reply_to should be None
889                assert!(
890                    inner_reply.in_reply_to.is_none(),
891                    "Should not recursively resolve replies - got {:?}",
892                    inner_reply.in_reply_to
893                );
894            } else {
895                panic!("Expected reply in second layer reference");
896            }
897        } else {
898            panic!("Expected reply message");
899        }
900    }
901
902    #[xmtp_common::test]
903    async fn test_reply_with_custom_inner_content() {
904        let (group, context) = setup_test_group().await;
905        let conn = context.db();
906
907        // Store original message
908        let msg_id = vec![1, 2, 3];
909        let msg_id_hex = msg_id.encode_hex();
910        create_and_store_message(
911            &conn,
912            &group.group_id,
913            msg_id.clone(),
914            TestContentGenerator::text_content("Original message"),
915            0,
916            "sender1",
917        );
918
919        // Create a custom/unknown content type
920        let custom_content_type = ContentTypeId {
921            authority_id: "custom.org".to_string(),
922            type_id: "custom/payload".to_string(),
923            version_major: 1,
924            version_minor: 0,
925        };
926
927        // Store a reply with custom inner content
928        let reply_id = create_and_store_message(
929            &conn,
930            &group.group_id,
931            vec![4, 5, 6],
932            TestContentGenerator::reply_content(
933                &msg_id_hex,
934                custom_content_type,
935                b"custom payload data".to_vec(),
936            ),
937            1000,
938            "replier1",
939        );
940
941        // Query messages
942        let messages = group.find_messages_v2(&MsgQueryArgs::default()).unwrap();
943        assert_message_count(&messages, 2);
944
945        // Find the reply message
946        let reply_msg = find_message_by_id(&messages, &reply_id);
947
948        // Verify the message is decoded as Reply (not Custom)
949        if let MessageBody::Reply(reply) = &reply_msg.content {
950            // The reply should reference the original message
951            assert!(reply.in_reply_to.is_some());
952            let referenced = reply.in_reply_to.as_ref().unwrap();
953            assert_eq!(referenced.metadata.id, msg_id);
954
955            // The inner content should be Custom (since we used an unknown content type)
956            if let MessageBody::Custom(custom) = reply.content.as_ref() {
957                assert_eq!(
958                    custom.encoded.r#type.as_ref().unwrap().type_id,
959                    "custom/payload"
960                );
961                assert_eq!(custom.encoded.content, b"custom payload data");
962            } else {
963                panic!(
964                    "Expected Custom inner content in Reply, got {:?}",
965                    reply.content
966                );
967            }
968        } else {
969            panic!("Expected Reply message, got {:?}", reply_msg.content);
970        }
971    }
972}