From fd7c3f1ca868daa3b277d5679ca195b09dde01f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 17:24:26 -0300 Subject: [PATCH 1/5] Render templates and related business message content through shared projections --- crates/chat-store/src/business.rs | 656 ++++++++++++++++++ crates/chat-store/src/lib.rs | 5 + crates/chat-store/src/materialize.rs | 91 +-- crates/chat-store/tests/business.rs | 55 ++ crates/core/src/business.rs | 108 +++ crates/core/src/chat/message.rs | 13 +- crates/core/src/lib.rs | 3 + crates/daemon/src/session_bridge/tests.rs | 23 + .../daemon/src/session_bridge/wire_events.rs | 16 +- crates/gui/src/app/messages.rs | 45 +- .../src/components/message_bubble/business.rs | 122 ++++ .../gui/src/components/message_bubble/mod.rs | 30 +- crates/gui/src/components/rich_text.rs | 27 + crates/session/src/mentions.rs | 5 + crates/session/src/quoting.rs | 32 +- crates/session/src/whatsapp/convert.rs | 45 +- crates/session/src/whatsapp/media.rs | 73 +- crates/session/src/whatsapp/mod.rs | 16 +- crates/session/src/whatsapp/tests.rs | 2 + docs/message-coverage.md | 61 ++ 20 files changed, 1345 insertions(+), 83 deletions(-) create mode 100644 crates/chat-store/src/business.rs create mode 100644 crates/chat-store/tests/business.rs create mode 100644 crates/core/src/business.rs create mode 100644 crates/gui/src/components/message_bubble/business.rs create mode 100644 docs/message-coverage.md diff --git a/crates/chat-store/src/business.rs b/crates/chat-store/src/business.rs new file mode 100644 index 00000000..e5df1a2b --- /dev/null +++ b/crates/chat-store/src/business.rs @@ -0,0 +1,656 @@ +//! Bounded, shared projections of business messages for live traffic and history. +use oxidezap_core::{BusinessAction, BusinessButton, BusinessContent, BusinessKind}; +use waproto::whatsapp as wa; + +const TEXT_LIMIT: usize = 16 * 1024; +const BUTTON_LIMIT: usize = 20; +const LABEL_LIMIT: usize = 512; + +fn text(value: Option<&str>) -> String { + bounded_text(value, TEXT_LIMIT) +} +fn bounded_text(value: Option<&str>, limit: usize) -> String { + let value = value.unwrap_or_default(); + if value.len() <= limit { + return value.to_owned(); + } + let mut end = limit; + while !value.is_char_boundary(end) { + end -= 1; + } + format!("{}…", &value[..end]) +} +fn card(kind: BusinessKind) -> BusinessContent { + BusinessContent { + kind, + header: String::new(), + body: String::new(), + footer: String::new(), + buttons: Vec::new(), + notice: None, + } +} +fn button(out: &mut BusinessContent, label: Option<&str>, action: BusinessAction) { + if out.buttons.len() >= BUTTON_LIMIT { + out.notice = Some("Additional choices are available on your phone.".into()); + return; + } + out.buttons.push(BusinessButton { + label: bounded_text( + label + .filter(|s| !s.trim().is_empty()) + .or(Some("Unsupported button")), + LABEL_LIMIT, + ), + action, + }); +} + +pub fn hydrated_template( + message: &wa::Message, +) -> Option<&wa::message::template_message::HydratedFourRowTemplate> { + let template = template_of(message)?; + template + .hydrated_template + .as_option() + .or_else(|| match template.format.as_ref() { + Some(wa::message::template_message::Format::HydratedFourRowTemplate(t)) => { + Some(t.as_ref()) + } + _ => None, + }) +} +fn template_of(message: &wa::Message) -> Option<&wa::message::TemplateMessage> { + message.template_message.as_option().or_else(|| { + message + .highly_structured_message + .as_option()? + .hydrated_hsm + .as_option() + }) +} +pub fn interactive_business(message: &wa::Message) -> Option<&wa::message::InteractiveMessage> { + if let Some(template) = template_of(message) + && let Some(wa::message::template_message::Format::InteractiveMessageTemplate(inner)) = + template.format.as_ref() + { + return Some(inner); + } + message.interactive_message.as_option() +} + +fn finish(message: &wa::Message, mut out: BusinessContent) -> BusinessContent { + if let Some(media) = business_media(message) { + if out.body.is_empty() { + out.body = text(match &media { + BusinessMedia::Image(m) => m.caption.as_deref(), + BusinessMedia::Video(m) => m.caption.as_deref(), + BusinessMedia::Document(m) => m.caption.as_deref(), + BusinessMedia::Location(_) | BusinessMedia::Thumbnail(_) => None, + }); + } + if let BusinessMedia::Location(location) = media { + let location = [ + text(location.name.as_deref()), + text(location.address.as_deref()), + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"); + if !location.is_empty() { + if !out.header.is_empty() { + out.header.push('\n'); + } + out.header.push_str(&location); + } else { + out.notice = Some("Location attachment is available on your phone.".into()); + } + } + } + if out.header.is_empty() + && out.body.is_empty() + && out.footer.is_empty() + && out.buttons.is_empty() + && out.notice.is_none() + { + out.notice = Some("Business message content is unavailable. Open it on your phone.".into()); + } + out +} + +pub fn business_content(message: &wa::Message) -> Option { + let message = crate::normalized_message(message); + if let Some(interactive) = interactive_business(message) { + let mut out = card(if template_of(message).is_some() { + BusinessKind::Template + } else { + BusinessKind::Interactive + }); + if let Some(header) = interactive.header.as_option() { + out.header = [ + text(header.title.as_deref()), + text(header.subtitle.as_deref()), + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"); + } + out.body = text(interactive.body.as_option().and_then(|b| b.text.as_deref())); + out.footer = text( + interactive + .footer + .as_option() + .and_then(|f| f.text.as_deref()), + ); + use wa::message::interactive_message::InteractiveMessage; + match interactive.interactive_message.as_ref() { + Some(InteractiveMessage::NativeFlowMessage(flow)) => { + for item in flow.buttons.iter().take(BUTTON_LIMIT + 1) { + // Parse only bounded JSON to read an explicit label. Never expose IDs, + // URLs, tokens, or unknown flow parameters as a fallback. + let params = item + .button_params_json + .as_deref() + .filter(|s| s.len() <= TEXT_LIMIT) + .and_then(|s| serde_json::from_str::(s).ok()); + let label = params + .as_ref() + .and_then(|v| v.get("display_text")) + .and_then(|v| v.as_str()); + let action = match item.name.as_deref() { + Some("quick_reply") => BusinessAction::Reply, + Some("cta_url") => BusinessAction::OpenLink, + Some("cta_call") => BusinessAction::Call, + _ => BusinessAction::NativeFlow, + }; + button(&mut out, label, action); + } + } + Some(InteractiveMessage::CarouselMessage(carousel)) => { + // Keep readable card text even when its attachment/action cannot + // be displayed. Do not recurse through nested carousel payloads. + for card in carousel.cards.iter().take(10) { + for value in [ + card.header.as_option().and_then(|h| h.title.as_deref()), + card.body.as_option().and_then(|b| b.text.as_deref()), + card.footer.as_option().and_then(|f| f.text.as_deref()), + ] + .into_iter() + .flatten() + { + if out.body.len() >= TEXT_LIMIT { + break; + } + if !out.body.is_empty() { + out.body.push('\n'); + } + out.body.push_str(&bounded_text( + Some(value), + TEXT_LIMIT.saturating_sub(out.body.len()), + )); + } + if out.body.len() >= TEXT_LIMIT { + break; + } + } + out.notice = + Some("Carousel attachments and actions are available on your phone.".into()); + } + Some(_) => { + out.notice = Some("This business interaction is available on your phone.".into()) + } + None => {} + } + if interactive + .footer + .as_option() + .is_some_and(|footer| footer.media.is_some()) + { + out.notice = Some("Footer attachment is available on your phone.".into()); + } + if interactive + .header + .as_option() + .is_some_and(|header| header.media.is_some()) + && business_media(message).is_none() + { + out.notice = Some("Header attachment is available on your phone.".into()); + } + return Some(finish(message, out)); + } + if let Some(template) = hydrated_template(message) { + let mut out = card(BusinessKind::Template); + if let Some( + wa::message::template_message::hydrated_four_row_template::Title::HydratedTitleText( + title, + ), + ) = template.title.as_ref() + { + out.header = text(Some(title)); + } + out.body = text(template.hydrated_content_text.as_deref()); + out.footer = text(template.hydrated_footer_text.as_deref()); + use wa::hydrated_template_button::HydratedButton; + for item in template.hydrated_buttons.iter().take(BUTTON_LIMIT + 1) { + let (label, action) = match item.hydrated_button.as_ref() { + Some(HydratedButton::QuickReplyButton(b)) => { + (b.display_text.as_deref(), BusinessAction::Reply) + } + Some(HydratedButton::UrlButton(b)) => { + (b.display_text.as_deref(), BusinessAction::OpenLink) + } + Some(HydratedButton::CallButton(b)) => { + (b.display_text.as_deref(), BusinessAction::Call) + } + _ => (None, BusinessAction::Unsupported), + }; + button(&mut out, label, action); + } + return Some(finish(message, out)); + } + if template_of(message).is_some() || message.highly_structured_message.is_set() { + let mut out = card(BusinessKind::Template); + out.notice = Some("This template requires translation on your phone.".into()); + return Some(finish(message, out)); + } + if let Some(buttons) = message.buttons_message.as_option() { + let mut out = card(BusinessKind::Buttons); + if let Some(wa::message::buttons_message::Header::Text(title)) = buttons.header.as_ref() { + out.header = text(Some(title)); + } + out.body = text(buttons.content_text.as_deref()); + out.footer = text(buttons.footer_text.as_deref()); + for item in buttons.buttons.iter().take(BUTTON_LIMIT + 1) { + button( + &mut out, + item.button_text + .as_option() + .and_then(|t| t.display_text.as_deref()), + if item.native_flow_info.is_set() { + BusinessAction::NativeFlow + } else { + BusinessAction::Reply + }, + ); + } + return Some(finish(message, out)); + } + if let Some(list) = message.list_message.as_option() { + let mut out = card(BusinessKind::List); + out.header = text(list.title.as_deref()); + out.body = text(list.description.as_deref()); + out.footer = text(list.footer_text.as_deref()); + for section in list.sections.iter().take(BUTTON_LIMIT) { + for row in section.rows.iter().take(BUTTON_LIMIT + 1) { + let label = [ + text(section.title.as_deref()), + text(row.title.as_deref()), + text(row.description.as_deref()), + ] + .into_iter() + .filter(|s| !s.is_empty()) + .collect::>() + .join("\n"); + button(&mut out, Some(&label), BusinessAction::Reply); + if out.buttons.len() >= BUTTON_LIMIT { + break; + } + } + } + if list.sections.len() > BUTTON_LIMIT + || list + .sections + .iter() + .take(BUTTON_LIMIT) + .fold(0usize, |count, section| { + count.saturating_add(section.rows.len()) + }) + > BUTTON_LIMIT + { + out.notice = Some("Additional choices are available on your phone.".into()); + } + if out.buttons.is_empty() { + button( + &mut out, + list.button_text.as_deref(), + BusinessAction::Unsupported, + ); + } + return Some(finish(message, out)); + } + let response = message + .template_button_reply_message + .as_option() + .and_then(|r| r.selected_display_text.as_deref()) + .or_else(|| { + message + .buttons_response_message + .as_option() + .and_then(|r| match r.response.as_ref() { + Some(wa::message::buttons_response_message::Response::SelectedDisplayText( + s, + )) => Some(s.as_str()), + _ => None, + }) + }) + .or_else(|| { + message + .list_response_message + .as_option() + .and_then(|r| r.title.as_deref()) + }) + .or_else(|| { + message + .interactive_response_message + .as_option() + .and_then(|r| r.body.as_option()) + .and_then(|b| b.text.as_deref()) + }); + response.map(|body| { + let mut out = card(BusinessKind::Response); + out.body = text(Some(body)); + out + }) +} + +/// Borrow the attachment without copying a protobuf tree or fetching anything. +pub enum BusinessMedia<'a> { + Image(&'a wa::message::ImageMessage), + Video(&'a wa::message::VideoMessage), + Document(&'a wa::message::DocumentMessage), + Location(&'a wa::message::LocationMessage), + Thumbnail(&'a [u8]), +} +pub fn business_media(message: &wa::Message) -> Option> { + if let Some(interactive) = interactive_business(message) { + use wa::message::interactive_message::header::Media; + let header = interactive.header.as_option()?; + return match header.media.as_ref()? { + Media::JpegThumbnail(bytes) if bytes.len() <= 512 * 1024 => { + Some(BusinessMedia::Thumbnail(bytes)) + } + Media::ImageMessage(m) if header.has_media_attachment == Some(true) => { + Some(BusinessMedia::Image(m)) + } + Media::VideoMessage(m) if header.has_media_attachment == Some(true) => { + Some(BusinessMedia::Video(m)) + } + Media::DocumentMessage(m) if header.has_media_attachment == Some(true) => { + Some(BusinessMedia::Document(m)) + } + Media::LocationMessage(m) if header.has_media_attachment == Some(true) => { + Some(BusinessMedia::Location(m)) + } + _ => None, + }; + } + if let Some(template) = hydrated_template(message) { + use wa::message::template_message::hydrated_four_row_template::Title; + return match template.title.as_ref()? { + Title::ImageMessage(m) => Some(BusinessMedia::Image(m)), + Title::VideoMessage(m) => Some(BusinessMedia::Video(m)), + Title::DocumentMessage(m) => Some(BusinessMedia::Document(m)), + Title::LocationMessage(m) => Some(BusinessMedia::Location(m)), + _ => None, + }; + } + if let Some(template) = template_of(message) + && let Some(wa::message::template_message::Format::FourRowTemplate(template)) = + template.format.as_ref() + { + use wa::message::template_message::four_row_template::Title; + return match template.title.as_ref()? { + Title::ImageMessage(m) => Some(BusinessMedia::Image(m)), + Title::VideoMessage(m) => Some(BusinessMedia::Video(m)), + Title::DocumentMessage(m) => Some(BusinessMedia::Document(m)), + Title::LocationMessage(m) => Some(BusinessMedia::Location(m)), + _ => None, + }; + } + if let Some(buttons) = message.buttons_message.as_option() { + use wa::message::buttons_message::Header; + return match buttons.header.as_ref()? { + Header::ImageMessage(m) => Some(BusinessMedia::Image(m)), + Header::VideoMessage(m) => Some(BusinessMedia::Video(m)), + Header::DocumentMessage(m) => Some(BusinessMedia::Document(m)), + Header::LocationMessage(m) => Some(BusinessMedia::Location(m)), + _ => None, + }; + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use buffa::MessageField; + use wa::message::template_message::hydrated_four_row_template::Title; + use wa::message::template_message::{Format, HydratedFourRowTemplate}; + + fn hydrated() -> wa::Message { + let mut template = HydratedFourRowTemplate::default(); + template.title = Some(Title::HydratedTitleText("Order update".into())); + template.hydrated_content_text = Some("Hello *customer*\nYour order is ready.".into()); + template.hydrated_footer_text = Some("Example shop".into()); + let mut reply = wa::hydrated_template_button::HydratedQuickReplyButton::default(); + reply.display_text = Some("Thanks".into()); + reply.id = Some("private-response-token".into()); + let mut button = wa::HydratedTemplateButton::default(); + button.hydrated_button = + Some(wa::hydrated_template_button::HydratedButton::QuickReplyButton(reply.into())); + template.hydrated_buttons.push(button); + let mut envelope = wa::message::TemplateMessage::default(); + envelope.format = Some(Format::HydratedFourRowTemplate(template.into())); + let mut message = wa::Message::default(); + message.template_message = MessageField::some(envelope); + message + } + + #[test] + fn hydrated_preserves_formatting_and_never_projects_response_ids() { + let out = business_content(&hydrated()).unwrap(); + assert_eq!(out.header, "Order update"); + assert_eq!(out.body, "Hello *customer*\nYour order is ready."); + assert_eq!(out.footer, "Example shop"); + assert_eq!(out.buttons[0].label, "Thanks"); + assert_eq!(out.buttons[0].action, BusinessAction::Reply); + assert!(!out.summary().contains("private-response-token")); + } + + #[test] + fn interactive_template_body_is_visible_without_hydrated_rows() { + let mut body = wa::message::interactive_message::Body::default(); + body.text = Some("Your delivery is ready".into()); + let mut interactive = wa::message::InteractiveMessage::default(); + interactive.body = MessageField::some(body); + let mut envelope = wa::message::TemplateMessage::default(); + envelope.format = Some(Format::InteractiveMessageTemplate(interactive.into())); + let mut message = wa::Message::default(); + message.template_message = MessageField::some(envelope); + assert_eq!( + business_content(&message).unwrap().body, + "Your delivery is ready" + ); + assert_eq!( + crate::materialize::extract_text(&message).as_deref(), + Some("Your delivery is ready") + ); + } + + #[test] + fn nested_wrappers_and_encoded_reload_keep_the_same_card() { + let expected = business_content(&hydrated()).unwrap(); + let mut message = hydrated(); + for _ in 0..5 { + let mut wrapper = wa::message::FutureProofMessage::default(); + wrapper.message = MessageField::some(message); + message = wa::Message::default(); + message.ephemeral_message = MessageField::some(wrapper); + } + let bytes = waproto::codec::message_to_vec(&message); + let decoded = waproto::codec::message_decode(&bytes).unwrap(); + assert_eq!(business_content(&decoded), Some(expected)); + assert_eq!( + crate::MessageKind::of(&decoded), + crate::MessageKind::Template + ); + } + + #[test] + fn missing_translation_has_a_safe_explanation() { + let mut message = wa::Message::default(); + message.template_message = MessageField::some(wa::message::TemplateMessage::default()); + let out = business_content(&message).unwrap(); + assert!(out.summary().contains("translation")); + assert!(out.body.is_empty()); + } + + #[test] + fn long_unicode_and_choice_counts_are_bounded() { + let mut message = hydrated(); + let envelope = message.template_message.as_option_mut().unwrap(); + let Some(Format::HydratedFourRowTemplate(template)) = envelope.format.as_mut() else { + panic!() + }; + template.hydrated_content_text = Some("🙂".repeat(TEXT_LIMIT)); + template.hydrated_buttons = vec![wa::HydratedTemplateButton::default(); 100]; + let out = business_content(&message).unwrap(); + assert!(out.body.len() <= TEXT_LIMIT + '…'.len_utf8()); + assert_eq!(out.buttons.len(), BUTTON_LIMIT); + assert!(out.notice.is_some()); + } + + #[test] + fn native_flow_only_reads_explicit_labels_from_bounded_valid_json() { + use wa::message::interactive_message::{InteractiveMessage, NativeFlowMessage}; + let mut flow = NativeFlowMessage::default(); + for params in [ + r#"{"display_text":"Track order","url":"https://example.invalid/private","id":"secret"}"#, + "{malformed", + r#"{"token":"hidden"}"#, + ] { + let mut button = + wa::message::interactive_message::native_flow_message::NativeFlowButton::default(); + button.name = Some("cta_url".into()); + button.button_params_json = Some(params.into()); + flow.buttons.push(button); + } + let mut interactive = wa::message::InteractiveMessage::default(); + interactive.interactive_message = Some(InteractiveMessage::NativeFlowMessage(flow.into())); + let mut message = wa::Message::default(); + message.interactive_message = MessageField::some(interactive); + let out = business_content(&message).unwrap(); + assert_eq!(out.buttons[0].label, "Track order"); + assert_eq!(out.buttons[1].label, "Unsupported button"); + let summary = out.summary(); + for hidden in ["secret", "hidden", "https://", "malformed"] { + assert!(!summary.contains(hidden)); + } + } + + #[test] + fn business_header_attachment_is_borrowed_without_media_fetch() { + let mut message = hydrated(); + let Some(Format::HydratedFourRowTemplate(template)) = message + .template_message + .as_option_mut() + .unwrap() + .format + .as_mut() + else { + panic!() + }; + let mut image = wa::message::ImageMessage::default(); + image.jpeg_thumbnail = Some(vec![1, 2, 3]); + template.title = Some(Title::ImageMessage(image.into())); + assert!(matches!( + business_media(&message), + Some(BusinessMedia::Image(_)) + )); + assert!(!business_content(&message).unwrap().body.is_empty()); + } +} + +#[cfg(test)] +mod related_tests { + use super::*; + use buffa::MessageField; + + #[test] + fn list_choices_keep_display_text_and_descriptions_without_ids() { + let mut row = wa::message::list_message::Row::default(); + row.title = Some("Morning".into()); + row.description = Some("Before noon".into()); + row.row_id = Some("private-row-id".into()); + let mut section = wa::message::list_message::Section::default(); + section.rows.push(row); + let mut list = wa::message::ListMessage::default(); + list.title = Some("Delivery".into()); + list.description = Some("Choose a time".into()); + list.sections.push(section); + let mut message = wa::Message::default(); + message.list_message = MessageField::some(list); + let out = business_content(&message).unwrap(); + assert_eq!(out.kind, BusinessKind::List); + assert_eq!(out.buttons[0].label, "Morning\nBefore noon"); + assert!(!out.summary().contains("private-row-id")); + } + + #[test] + fn legacy_buttons_keep_their_header_footer_and_display_label() { + let mut label = wa::message::buttons_message::button::ButtonText::default(); + label.display_text = Some("Confirm".into()); + let mut button = wa::message::buttons_message::Button::default(); + button.button_text = MessageField::some(label); + button.button_id = Some("response-token".into()); + let mut buttons = wa::message::ButtonsMessage::default(); + buttons.header = Some(wa::message::buttons_message::Header::Text( + "Delivery".into(), + )); + buttons.content_text = Some("Confirm delivery".into()); + buttons.footer_text = Some("Example shop".into()); + buttons.buttons.push(button); + let mut message = wa::Message::default(); + message.buttons_message = MessageField::some(buttons); + let out = business_content(&message).unwrap(); + assert_eq!(out.kind, BusinessKind::Buttons); + assert_eq!(out.header, "Delivery"); + assert_eq!(out.footer, "Example shop"); + assert_eq!(out.buttons[0].label, "Confirm"); + assert!(!out.summary().contains("response-token")); + } + + #[test] + fn template_replies_show_the_selected_label_only() { + let mut reply = wa::message::TemplateButtonReplyMessage::default(); + reply.selected_display_text = Some("Confirmed".into()); + reply.selected_id = Some("response-token".into()); + let mut message = wa::Message::default(); + message.template_button_reply_message = MessageField::some(reply); + let out = business_content(&message).unwrap(); + assert_eq!(out.body, "Confirmed"); + assert_eq!(out.kind, BusinessKind::Response); + assert!(!out.summary().contains("response-token")); + } + + #[test] + fn unsupported_carousel_preserves_readable_card_bodies() { + let mut body = wa::message::interactive_message::Body::default(); + body.text = Some("Example product description".into()); + let mut card = wa::message::InteractiveMessage::default(); + card.body = MessageField::some(body); + let mut carousel = wa::message::interactive_message::CarouselMessage::default(); + carousel.cards.push(card); + let mut interactive = wa::message::InteractiveMessage::default(); + interactive.interactive_message = Some( + wa::message::interactive_message::InteractiveMessage::CarouselMessage(carousel.into()), + ); + let mut message = wa::Message::default(); + message.interactive_message = MessageField::some(interactive); + let out = business_content(&message).unwrap(); + assert_eq!(out.body, "Example product description"); + assert!(out.notice.is_some()); + } +} diff --git a/crates/chat-store/src/lib.rs b/crates/chat-store/src/lib.rs index 0c20663b..1008bd01 100644 --- a/crates/chat-store/src/lib.rs +++ b/crates/chat-store/src/lib.rs @@ -60,3 +60,8 @@ pub use types::{ MessageCoverage, MessageCursor, MessageKind, MessageStatus, ReactionEntry, ReceiptEntry, StoreChange, StoredMessage, }; + +mod business; +pub use business::{ + BusinessMedia, business_content, business_media, hydrated_template, interactive_business, +}; diff --git a/crates/chat-store/src/materialize.rs b/crates/chat-store/src/materialize.rs index 695f7122..b761b105 100644 --- a/crates/chat-store/src/materialize.rs +++ b/crates/chat-store/src/materialize.rs @@ -80,7 +80,7 @@ pub fn message_kind(message: &wa::Message) -> &'static str { "event" } else if base.group_invite_message.is_set() { "group_invite" - } else if base.template_message.is_set() { + } else if base.template_message.is_set() || base.highly_structured_message.is_set() { "template" } else if base.template_button_reply_message.is_set() { "template_reply" @@ -130,20 +130,35 @@ pub(crate) fn unavailable_kind(unavailable_type: UnavailableType) -> Option<&'st /// Repeating the pass handles valid nested combinations (for example a group /// mention around an associated child around a future-proof poll). pub fn normalized_message(message: &wa::Message) -> &wa::Message { - let mut base = message.get_base_message(); - loop { - let inner = [ - &base.group_mentioned_message, - &base.associated_child_message, - &base.poll_creation_message_v4, - ] - .into_iter() - .find_map(|wrapper| wrapper.as_option().and_then(|w| w.message.as_option())); + let mut base = message; + // A finite budget also handles malformed, deeply nested carriers without + // unbounded projection work. Each pass borrows the next message. + for _ in 0..32 { + let inner = base + .device_sent_message + .as_option() + .and_then(|w| w.message.as_option()) + .or_else(|| { + [ + &base.ephemeral_message, + &base.view_once_message, + &base.view_once_message_v2, + &base.view_once_message_v2_extension, + &base.document_with_caption_message, + &base.edited_message, + &base.group_mentioned_message, + &base.associated_child_message, + &base.poll_creation_message_v4, + ] + .into_iter() + .find_map(|w| w.as_option().and_then(|w| w.message.as_option())) + }); match inner { - Some(inner) if !std::ptr::eq(inner, base) => base = inner.get_base_message(), - _ => return base, + Some(inner) => base = inner, + None => return base, } } + base } /// Shared poll-creation detector for classification and storage compaction. @@ -282,56 +297,20 @@ pub(crate) fn extract_text(base: &wa::Message) -> Option { }, ); } + if let Some(business) = crate::business_content(base) { + let body = if business.body.is_empty() { + business.header + } else { + business.body + }; + return (!body.is_empty()).then_some(body); + } base.text_content() .or_else(|| base.get_caption()) - .or_else(|| business_text(base)) .map(str::to_owned) .or_else(|| crate::shared_contacts_text(base)) } -/// Body text of the business content carriers. Extraction mirrors WA Web's -/// per-type parsers; footers/buttons stay display-side (readable from the proto). -fn business_text(base: &wa::Message) -> Option<&str> { - if let Some(tpl) = base.template_message.as_option() { - use wa::message::template_message::Format; - // WA Web reads hydratedTemplate ?? format's hydratedFourRowTemplate. - return tpl - .hydrated_template - .as_option() - .or_else(|| match tpl.format.as_ref() { - Some(Format::HydratedFourRowTemplate(t)) => Some(t.as_ref()), - _ => None, - }) - .and_then(|t| t.hydrated_content_text.as_deref()); - } - if let Some(reply) = base.template_button_reply_message.as_option() { - return reply.selected_display_text.as_deref(); - } - if let Some(buttons) = base.buttons_message.as_option() { - return buttons.content_text.as_deref(); - } - if let Some(resp) = base.buttons_response_message.as_option() { - use wa::message::buttons_response_message::Response; - return match resp.response.as_ref() { - Some(Response::SelectedDisplayText(text)) => Some(text.as_str()), - _ => None, - }; - } - if let Some(list) = base.list_message.as_option() { - return list.description.as_deref(); - } - if let Some(resp) = base.list_response_message.as_option() { - return resp.title.as_deref(); - } - if let Some(interactive) = base.interactive_message.as_option() { - return interactive.body.as_option().and_then(|b| b.text.as_deref()); - } - if let Some(resp) = base.interactive_response_message.as_option() { - return resp.body.as_option().and_then(|b| b.text.as_deref()); - } - None -} - /// Whether the unwrapped message carries anything a chat log should show. /// Guards against storing rows for pure-bookkeeping payloads that classify as /// "unknown". Decided structurally: strip the bookkeeping carriers and check diff --git a/crates/chat-store/tests/business.rs b/crates/chat-store/tests/business.rs new file mode 100644 index 00000000..f2d173c1 --- /dev/null +++ b/crates/chat-store/tests/business.rs @@ -0,0 +1,55 @@ +//! Synthetic wire-to-durable-row coverage for business cards. +mod common; +use common::*; + +#[tokio::test] +async fn interactive_template_materializes_body_and_preserves_the_card() { + let (store, chat_store) = test_store().await; + let mut body = wa::message::interactive_message::Body::default(); + body.text = Some("*Order* is ready".into()); + let mut interactive = wa::message::InteractiveMessage::default(); + interactive.body = MessageField::some(body); + let mut template = wa::message::TemplateMessage::default(); + template.format = + Some(wa::message::template_message::Format::InteractiveMessageTemplate(interactive.into())); + let mut proto = wa::Message::default(); + proto.template_message = MessageField::some(template); + let expected = oxidezap_chat_store::business_content(&proto); + feed( + &chat_store, + [message_event( + proto, + incoming_info(PEER, PEER, "TEMPLATE-1", 1_700_000_000), + )], + ) + .await; + let rows = chat_store.messages(&jid(PEER), None, 10).await.unwrap(); + assert_eq!(rows[0].kind, MessageKind::Template); + assert_eq!(rows[0].text.as_deref(), Some("*Order* is ready")); + assert_eq!( + rows[0] + .message + .as_deref() + .and_then(oxidezap_chat_store::business_content), + expected + ); + assert_eq!( + chat_store.chats(false, 10).await.unwrap()[0] + .last_message_preview + .as_deref(), + Some("*Order* is ready") + ); + let reopened = ChatStore::new(&store).await.unwrap(); + let stored = reopened + .message(&jid(PEER), "TEMPLATE-1") + .await + .unwrap() + .unwrap(); + assert_eq!( + stored + .message + .as_deref() + .and_then(oxidezap_chat_store::business_content), + expected + ); +} diff --git a/crates/core/src/business.rs b/crates/core/src/business.rs new file mode 100644 index 00000000..c1490a0a --- /dev/null +++ b/crates/core/src/business.rs @@ -0,0 +1,108 @@ +//! Display-only business content. No response IDs, flow JSON, or executable actions. +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BusinessContent { + pub kind: BusinessKind, + pub header: String, + pub body: String, + pub footer: String, + pub buttons: Vec, + /// A readable limitation, never raw protobuf identifiers or payloads. + pub notice: Option, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BusinessKind { + Template, + Buttons, + List, + Interactive, + Response, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BusinessButton { + pub label: String, + pub action: BusinessAction, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum BusinessAction { + Reply, + OpenLink, + Call, + NativeFlow, + Unsupported, +} + +impl BusinessAction { + pub fn unavailable_reason(self) -> &'static str { + match self { + Self::Reply => "Button replies are not supported by this client.", + Self::OpenLink => "Opening business button links is not supported by this client.", + Self::Call => "Calling business button numbers is not supported by this client.", + Self::NativeFlow => "This interactive action must be completed on your phone.", + Self::Unsupported => "This button action is not supported by this client.", + } + } +} + +impl BusinessContent { + /// Human-readable output for CLI clients which do not draw a business card. + pub fn summary(&self) -> String { + let mut lines: Vec<&str> = [&self.header, &self.body, &self.footer] + .into_iter() + .map(String::as_str) + .filter(|s| !s.is_empty()) + .collect(); + lines.extend(self.buttons.iter().map(|button| button.label.as_str())); + lines.extend(self.notice.as_deref()); + lines.join("\n") + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn business_cards_cross_daemon_serialization_and_old_messages_still_decode() { + let mut message = crate::ChatMessage::new_incoming( + "synthetic".into(), + "example@lid".into(), + "Ready".into(), + ); + message.business = Some(BusinessContent { + kind: BusinessKind::Template, + header: "Order".into(), + body: "Ready".into(), + footer: "Example shop".into(), + buttons: vec![BusinessButton { + label: "Thanks".into(), + action: BusinessAction::Reply, + }], + notice: None, + }); + let encoded = serde_json::to_value(&message).unwrap(); + assert_eq!( + serde_json::from_value::(encoded.clone()).unwrap(), + message + ); + let mut old = encoded; + old.as_object_mut().unwrap().remove("business"); + assert!( + serde_json::from_value::(old) + .unwrap() + .business + .is_none() + ); + message.status = crate::MessageStatus::Failed; + message.is_from_me = true; + assert!( + message.resend().is_none(), + "a business body must not become a button response or resend" + ); + } +} diff --git a/crates/core/src/chat/message.rs b/crates/core/src/chat/message.rs index eb18f155..bd7a76e5 100644 --- a/crates/core/src/chat/message.rs +++ b/crates/core/src/chat/message.rs @@ -65,6 +65,9 @@ pub struct ChatMessage { /// on. `None` on every other message. #[serde(default, skip_serializing_if = "Option::is_none")] pub poll: Option, + /// Display-only template, interactive card or business response. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub business: Option, } /// What a failed message can be sent again as. @@ -128,6 +131,7 @@ impl ChatMessage { edited: false, system: None, poll: None, + business: None, } } @@ -160,6 +164,7 @@ impl ChatMessage { edited: false, system: None, poll: None, + business: None, } } @@ -211,7 +216,7 @@ impl ChatMessage { // message would send the caption without the file. Failed media has // no resendable form here — the echo of a video holds no bytes — so // it offers no retry rather than a wrong one. - if self.media.is_none() && !self.content.is_empty() { + if self.business.is_none() && self.media.is_none() && !self.content.is_empty() { return Some(Resend::Text(&self.content)); } // A voice note has no text and is not therefore beyond recovery: the @@ -229,6 +234,12 @@ impl ChatMessage { /// - For text-only messages: the message content /// - For media messages: "[MediaType] caption" or just "[MediaType]" pub fn preview_text(&self) -> String { + if let Some(business) = &self.business { + if !self.content.is_empty() { + return self.content.clone(); + } + return business.summary(); + } if let Some(poll) = &self.poll { return format!("\u{1f4ca} {}", poll.question); } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 36c8788b..4cd9f150 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -76,3 +76,6 @@ pub use rich_text::{ pub use status::{StatusAuthor, StatusFeed}; pub use system_notice::{CallOutcome, CallRecord, SystemNotice, format_duration}; pub use video::{CallVideo, CallVideoFrame, VideoStream}; + +mod business; +pub use business::{BusinessAction, BusinessButton, BusinessContent, BusinessKind}; diff --git a/crates/daemon/src/session_bridge/tests.rs b/crates/daemon/src/session_bridge/tests.rs index eb8f5173..5bc5ef19 100644 --- a/crates/daemon/src/session_bridge/tests.rs +++ b/crates/daemon/src/session_bridge/tests.rs @@ -886,3 +886,26 @@ async fn work_in_flight_is_capped_rather_than_queued() { "permits come back when the work they paid for is over" ); } + +#[test] +fn business_cli_summary_includes_labels_without_response_payloads() { + use oxidezap_core::{BusinessAction, BusinessButton, BusinessContent, BusinessKind}; + let mut message = fixtures::message("template-fixture", "example@lid", "Ready"); + message.business = Some(BusinessContent { + kind: BusinessKind::Template, + header: "Order".into(), + body: "Ready".into(), + footer: "Example shop".into(), + buttons: vec![BusinessButton { + label: "Track order".into(), + action: BusinessAction::OpenLink, + }], + notice: None, + }); + let dto = super::wire_events::chat_message_to_dto("example@lid", message); + assert_eq!(dto.kind, "template"); + assert_eq!( + dto.text.as_deref(), + Some("Order\nReady\nExample shop\nTrack order") + ); +} diff --git a/crates/daemon/src/session_bridge/wire_events.rs b/crates/daemon/src/session_bridge/wire_events.rs index 5cc9c11e..053a4af1 100644 --- a/crates/daemon/src/session_bridge/wire_events.rs +++ b/crates/daemon/src/session_bridge/wire_events.rs @@ -110,6 +110,7 @@ pub(crate) fn chat_to_dto(chat: oxidezap_core::Chat) -> ChatDto { } pub(crate) fn chat_message_to_dto(chat_jid: &str, msg: ChatMessage) -> MessageDto { + let summary = msg.business.as_ref().map(|b| b.summary()); let (kind, media) = if msg.revoked { ("revoked", None) } else if let Some(media) = &msg.media { @@ -154,6 +155,17 @@ pub(crate) fn chat_message_to_dto(chat_jid: &str, msg: ChatMessage) -> MessageDt ("text", None) }; + let kind = if msg.revoked { + kind + } else { + msg.business.as_ref().map_or(kind, |b| match b.kind { + oxidezap_core::BusinessKind::Template => "template", + oxidezap_core::BusinessKind::Buttons => "buttons", + oxidezap_core::BusinessKind::List => "list", + oxidezap_core::BusinessKind::Interactive => "interactive", + oxidezap_core::BusinessKind::Response => "business_response", + }) + }; let status = match msg.status { oxidezap_core::MessageStatus::Pending => "pending", oxidezap_core::MessageStatus::Sent => "sent", @@ -179,7 +191,9 @@ pub(crate) fn chat_message_to_dto(chat_jid: &str, msg: ChatMessage) -> MessageDt sender_jid: msg.sender, from_me: msg.is_from_me, timestamp_ms: msg.timestamp.timestamp_millis(), - text: if msg.content.is_empty() { + text: if let Some(summary) = summary { + Some(summary) + } else if msg.content.is_empty() { None } else { Some(msg.content) diff --git a/crates/gui/src/app/messages.rs b/crates/gui/src/app/messages.rs index 3a61695b..d7794bd1 100644 --- a/crates/gui/src/app/messages.rs +++ b/crates/gui/src/app/messages.rs @@ -208,10 +208,7 @@ impl MessageListCache { items: build_items(messages, typing.clone()).into(), typing, ids: messages.iter().map(BubbleIds::of).collect(), - text: messages - .iter() - .map(|message| BubbleText::of(&message.content)) - .collect(), + text: messages.iter().map(BubbleText::of_message).collect(), messages: messages.iter().cloned().map(Arc::new).collect(), } } @@ -304,7 +301,7 @@ mod tests { chat_jid: jid.into(), ids: BubbleIds::of(&message), selection_order: 0, - text: BubbleText::of(&message.content), + text: BubbleText::of_message(&message), message: Arc::new(message), attempted_vote: None, playing_message_id: None, @@ -631,4 +628,42 @@ mod tests { kind: oxidezap_core::ComposingKind::Text, } } + #[gpui::test] + fn business_headers_footers_and_unavailable_choices_render_offline( + cx: &mut gpui::TestAppContext, + ) { + use oxidezap_core::{BusinessAction, BusinessButton, BusinessContent, BusinessKind}; + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + let mut message = fixtures::message("BUSINESS", fixtures::PEER, "*Your order* is ready"); + message.business = Some(BusinessContent { + kind: BusinessKind::Template, + header: "Order update".into(), + body: message.content.clone(), + footer: "_Example shop_".into(), + buttons: vec![BusinessButton { + label: "Track order".into(), + action: BusinessAction::OpenLink, + }], + notice: None, + }); + let rows = vec![(fixtures::PEER.into(), false, message)]; + let window = cx.open_window(gpui::size(gpui::px(1000.), gpui::px(800.)), |window, cx| { + let app = cx.new(WhatsAppApp::new); + let fixture = cx.new(|_| EditedBubbleFixture { app, rows }); + gpui_component::Root::new(fixture, window, cx) + }); + let mut cx = gpui::VisualTestContext::from_window(window.into(), cx); + cx.run_until_parked(); + cx.update(|window, cx| window.draw(cx).clear(cx)); + for id in [ + "business-header-BUSINESS", + "business-footer-BUSINESS", + "business-button-BUSINESS-0", + ] { + assert!(cx.debug_bounds(id).is_some(), "{id}"); + } + } } diff --git a/crates/gui/src/components/message_bubble/business.rs b/crates/gui/src/components/message_bubble/business.rs new file mode 100644 index 00000000..ab1c5bf7 --- /dev/null +++ b/crates/gui/src/components/message_bubble/business.rs @@ -0,0 +1,122 @@ +//! Display-only choices: rendering never performs a business action. +use crate::components::{BubbleText, render_rich_text}; +use crate::theme::Metrics; +use gpui::{ + App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, + prelude::FluentBuilder as _, +}; +use gpui_component::ActiveTheme as _; +use oxidezap_core::BusinessContent; +use std::sync::Arc; + +#[derive(Clone)] +pub(crate) struct BusinessText { + header: BubbleText, + footer: BubbleText, + header_key: Arc, + footer_key: Arc, + header_id: SharedString, + footer_id: SharedString, + button_ids: Arc<[SharedString]>, +} +impl BusinessText { + pub(crate) fn new(id: &str, content: &BusinessContent) -> Self { + Self { + header: BubbleText::of(&content.header), + footer: BubbleText::of(&content.footer), + header_key: format!("business-header-{id}").into(), + footer_key: format!("business-footer-{id}").into(), + header_id: format!("business-header-{id}").into(), + footer_id: format!("business-footer-{id}").into(), + button_ids: content + .buttons + .iter() + .enumerate() + .map(|(ix, _)| format!("business-button-{id}-{ix}").into()) + .collect(), + } + } + pub(crate) fn link_targets(&self) -> impl Iterator { + self.header + .link_targets() + .iter() + .chain(self.footer.link_targets().iter()) + } +} + +pub(super) fn render_header( + parsed: Option<&BusinessText>, + order: u64, + metrics: Metrics, + cx: &App, +) -> impl IntoElement + use<> { + div() + .id(parsed + .map(|p| p.header_id.clone()) + .unwrap_or_else(|| "business-header-unavailable".into())) + .min_w_0() + .text_size(metrics.text_body()) + .text_color(cx.theme().foreground) + .font_weight(gpui::FontWeight::SEMIBOLD) + .when_some(parsed, |el, parsed| { + el.when(!parsed.header.is_empty(), |el| { + el.child(render_rich_text( + &parsed.header, + Arc::clone(&parsed.header_key), + order.saturating_mul(3), + cx, + )) + }) + }) +} + +pub(super) fn render_footer( + content: &BusinessContent, + parsed: Option<&BusinessText>, + order: u64, + metrics: Metrics, + cx: &App, +) -> impl IntoElement + use<> { + div() + .id(parsed + .map(|p| p.footer_id.clone()) + .unwrap_or_else(|| "business-footer-unavailable".into())) + .flex() + .flex_col() + .min_w_0() + .gap(metrics.space_xs()) + .text_size(metrics.text_small()) + .text_color(cx.theme().muted_foreground) + .when_some(parsed, |el, parsed| { + el.when(!parsed.footer.is_empty(), |el| { + el.child(render_rich_text( + &parsed.footer, + Arc::clone(&parsed.footer_key), + order.saturating_mul(3).saturating_add(2), + cx, + )) + }) + }) + .children(content.buttons.iter().enumerate().map(|(ix, button)| { + div() + .id(parsed + .and_then(|p| p.button_ids.get(ix)) + .cloned() + .unwrap_or_else(|| format!("business-choice-{ix}").into())) + .flex() + .flex_col() + .min_w_0() + .p(metrics.space_xs()) + .rounded(metrics.radius_sm()) + .border_1() + .border_color(cx.theme().border) + .child( + div() + .text_color(cx.theme().foreground) + .child(button.label.clone()), + ) + // Visible explanations remain readable without hover or network. + .child(button.action.unavailable_reason()) + })) + .children(content.notice.clone()) +} diff --git a/crates/gui/src/components/message_bubble/mod.rs b/crates/gui/src/components/message_bubble/mod.rs index 0e4ee2ba..5bd1a024 100644 --- a/crates/gui/src/components/message_bubble/mod.rs +++ b/crates/gui/src/components/message_bubble/mod.rs @@ -7,6 +7,7 @@ //! step with a number kept elsewhere. pub mod audio; +pub(crate) mod business; mod media; mod poll; mod quote; @@ -169,7 +170,10 @@ pub fn render_message_bubble( // for and the only route to these commands that does not require finding a // control that is invisible until the pointer is already over it. let menu_id = message_id.clone(); - let menu_text = message.content.clone(); + let menu_text = message + .business + .as_ref() + .map_or_else(|| message.content.clone(), |business| business.summary()); let menu_failed = can_retry; let menu_jid = props.chat_jid.clone(); let now_ms = wacore::time::now_millis(); @@ -265,6 +269,14 @@ pub fn render_message_bubble( .children(message.quoted.as_ref().map(|quoted| { render_quote(quoted, entity.clone(), metrics, cx) })) + .children(message.business.as_ref().map(|_| { + business::render_header( + content.business(), + props.selection_order, + metrics, + cx, + ) + })) .children(message.poll.as_ref().map(|poll| { render_poll( poll, @@ -328,7 +340,10 @@ pub fn render_message_bubble( .child(render_rich_text( content, ids.selection_key.clone(), - props.selection_order, + props + .selection_order + .saturating_mul(3) + .saturating_add(1), cx, )), ) @@ -342,7 +357,16 @@ pub fn render_message_bubble( metrics, cx, )), - ), + ) + .children(message.business.as_ref().map(|business| { + business::render_footer( + business, + content.business(), + props.selection_order, + metrics, + cx, + ) + })), ), ) .when(!link_buttons.is_empty(), |el| { diff --git a/crates/gui/src/components/rich_text.rs b/crates/gui/src/components/rich_text.rs index 5bc5226e..2d32f7ca 100644 --- a/crates/gui/src/components/rich_text.rs +++ b/crates/gui/src/components/rich_text.rs @@ -58,6 +58,7 @@ pub struct BubbleText { /// `SharedString` on every frame copied peer-controlled text with the /// message length, over and over, for no new information. link_targets: Arc<[SharedString]>, + business: Option>, } impl BubbleText { @@ -79,6 +80,7 @@ impl BubbleText { runs: Arc::from([]), links, link_targets, + business: None, }; } let runs = rich.runs(); @@ -87,9 +89,34 @@ impl BubbleText { runs: runs.into(), links, link_targets, + business: None, } } + /// Parse every displayed text field once, when a timeline is rebuilt. + pub fn of_message(message: &oxidezap_core::ChatMessage) -> Self { + let mut parsed = Self::of(&message.content); + parsed.business = message.business.as_ref().map(|business| { + Arc::new(super::message_bubble::business::BusinessText::new( + &message.id, + business, + )) + }); + if let Some(business) = &parsed.business { + parsed.link_targets = parsed + .link_targets + .iter() + .chain(business.link_targets()) + .cloned() + .collect(); + } + parsed + } + + pub(crate) fn business(&self) -> Option<&super::message_bubble::business::BusinessText> { + self.business.as_deref() + } + /// Whether there is anything to draw. The bubble asks before it builds a /// text box at all, because a media message routinely has no caption. pub fn is_empty(&self) -> bool { diff --git a/crates/session/src/mentions.rs b/crates/session/src/mentions.rs index 08101f5b..1eb372ea 100644 --- a/crates/session/src/mentions.rs +++ b/crates/session/src/mentions.rs @@ -56,6 +56,11 @@ pub(crate) async fn hydrate_mention_lists( continue; } msg.content = apply_to(&pairs, std::mem::take(&mut msg.content)); + if let Some(business) = msg.business.as_mut() { + business.body = msg.content.clone(); + business.header = apply_to(&pairs, std::mem::take(&mut business.header)); + business.footer = apply_to(&pairs, std::mem::take(&mut business.footer)); + } if let Some(media) = msg.media.as_mut() && let Some(caption) = media.caption.take() { diff --git a/crates/session/src/quoting.rs b/crates/session/src/quoting.rs index 3c241022..479efa0d 100644 --- a/crates/session/src/quoting.rs +++ b/crates/session/src/quoting.rs @@ -26,7 +26,7 @@ pub fn quoted_from(message: &wa::Message) -> Option { let base = context .quoted_message .as_option() - .map(|quoted| quoted.get_base_message()); + .map(oxidezap_chat_store::normalized_message); let sender = context.participant.clone().unwrap_or_default(); Some(QuotedMessage { @@ -38,9 +38,16 @@ pub fn quoted_from(message: &wa::Message) -> Option { sender_name: String::new(), sender, preview: base - .and_then(|base| base.text_content().or_else(|| base.get_caption())) - .unwrap_or_default() - .to_string(), + .and_then(|base| { + oxidezap_chat_store::business_content(base) + .map(|b| b.summary()) + .or_else(|| { + base.text_content() + .or_else(|| base.get_caption()) + .map(str::to_owned) + }) + }) + .unwrap_or_default(), kind: base.and_then(quoted_kind), }) } @@ -76,10 +83,27 @@ fn context_info(message: &wa::Message) -> Option<&wa::ContextInfo> { audio_message, document_message, sticker_message, + template_message, + template_button_reply_message, + buttons_message, + buttons_response_message, + list_message, + list_response_message, + interactive_message, + interactive_response_message, ) } fn quoted_kind(message: &wa::Message) -> Option { + if let Some(media) = oxidezap_chat_store::business_media(message) { + use oxidezap_chat_store::BusinessMedia; + return match media { + BusinessMedia::Image(_) | BusinessMedia::Thumbnail(_) => Some(QuotedKind::Image), + BusinessMedia::Video(_) => Some(QuotedKind::Video), + BusinessMedia::Document(_) => Some(QuotedKind::Document), + BusinessMedia::Location(_) => None, + }; + } if message.image_message.is_set() { Some(QuotedKind::Image) } else if message.video_message.is_set() || message.ptv_message.is_set() { diff --git a/crates/session/src/whatsapp/convert.rs b/crates/session/src/whatsapp/convert.rs index 4731b898..b9939607 100644 --- a/crates/session/src/whatsapp/convert.rs +++ b/crates/session/src/whatsapp/convert.rs @@ -56,9 +56,14 @@ pub(super) fn stored_to_chat_message(stored: oxidezap_chat_store::StoredMessage) .map(oxidezap_chat_store::normalized_message); let media = base_message.and_then(|message| media::media_of(message, None)); let poll = base_message.and_then(poll_of); + let business = base_message.and_then(oxidezap_chat_store::business_content); let contacts = base_message.and_then(oxidezap_chat_store::shared_contacts_text); let content = match (&stored.text, stored.revoked) { (_, true) => "[Message deleted]".to_string(), + (_, _) if business.is_some() => business + .as_ref() + .map(|b| b.body.clone()) + .unwrap_or_default(), (Some(text), _) => text.clone(), (None, _) if contacts.is_some() => contacts.unwrap_or_default(), (None, _) if poll.is_some() => poll @@ -106,6 +111,7 @@ pub(super) fn stored_to_chat_message(stored: oxidezap_chat_store::StoredMessage) edited: stored.edited_at.is_some(), system, poll, + business, } } @@ -250,7 +256,7 @@ mod tests { use super::*; use whatsapp_rust::buffa::MessageField; - fn stored_poll_creation() -> oxidezap_chat_store::StoredMessage { + pub(super) fn stored_poll_creation() -> oxidezap_chat_store::StoredMessage { let proto = { let mut constructed: wa::Message = Default::default(); constructed.poll_creation_message_v3 = MessageField::some({ @@ -482,3 +488,40 @@ mod tests { ); } } + +#[cfg(test)] +mod business_tests { + use super::*; + use whatsapp_rust::buffa::MessageField; + #[test] + fn older_template_rows_without_denormalized_text_reload_from_the_proto() { + let mut body = wa::message::interactive_message::Body::default(); + body.text = Some("*Delivery* is ready".into()); + let mut interactive = wa::message::InteractiveMessage::default(); + interactive.body = MessageField::some(body); + let mut template = wa::message::TemplateMessage::default(); + template.format = Some( + wa::message::template_message::Format::InteractiveMessageTemplate(interactive.into()), + ); + let mut proto = wa::Message::default(); + proto.template_message = MessageField::some(template); + let mut stored = tests::stored_poll_creation(); + stored.kind = oxidezap_chat_store::MessageKind::Template; + stored.message = Some(Box::new(proto)); + stored.text = None; + let projected = stored_to_chat_message(stored.clone()); + assert_eq!(projected.content, "*Delivery* is ready"); + assert_eq!( + projected.business, + stored + .message + .as_deref() + .and_then(oxidezap_chat_store::business_content) + ); + stored.revoked = true; + let revoked = stored_to_chat_message(stored); + assert!(revoked.business.is_none()); + assert!(revoked.media.is_none()); + assert_eq!(revoked.content, "[Message deleted]"); + } +} diff --git a/crates/session/src/whatsapp/media.rs b/crates/session/src/whatsapp/media.rs index a6fed27d..9d640d22 100644 --- a/crates/session/src/whatsapp/media.rs +++ b/crates/session/src/whatsapp/media.rs @@ -86,6 +86,32 @@ fn effective_sticker(msg: &wa::Message) -> Option<&wa::message::StickerMessage> }) } +fn image_of(msg: &wa::Message) -> Option<&wa::message::ImageMessage> { + msg.image_message + .as_option() + .or_else(|| match oxidezap_chat_store::business_media(msg) { + Some(oxidezap_chat_store::BusinessMedia::Image(m)) => Some(m), + _ => None, + }) +} +fn video_of(msg: &wa::Message) -> Option<&wa::message::VideoMessage> { + msg.ptv_message + .as_option() + .or(msg.video_message.as_option()) + .or_else(|| match oxidezap_chat_store::business_media(msg) { + Some(oxidezap_chat_store::BusinessMedia::Video(m)) => Some(m), + _ => None, + }) +} +fn document_of(msg: &wa::Message) -> Option<&wa::message::DocumentMessage> { + msg.document_message + .as_option() + .or_else(|| match oxidezap_chat_store::business_media(msg) { + Some(oxidezap_chat_store::BusinessMedia::Document(m)) => Some(m), + _ => None, + }) +} + /// What it takes to fetch this media later, or `None` when the message did /// not carry enough to try. /// @@ -165,6 +191,12 @@ pub(super) fn still_preview( /// thumbnail is what shows and the download metadata is what makes the full /// bytes retryable. pub(super) fn media_of(msg: &wa::Message, fetched: Option>) -> Option { + if let Some(oxidezap_chat_store::BusinessMedia::Thumbnail(bytes)) = + oxidezap_chat_store::business_media(msg) + { + return (!bytes.is_empty()) + .then(|| MediaContent::image(Arc::new(bytes.to_vec()), "image/jpeg".into(), false)); + } if let Some(sticker) = effective_sticker(msg) { let mime = sticker .mimetype @@ -204,7 +236,7 @@ pub(super) fn media_of(msg: &wa::Message, fetched: Option>) -> Option>) -> Option>) -> Option Option { let fetched = if let Some(sticker) = effective_sticker(msg) { fetch_now(client, sticker, "sticker", eager, sticker.file_length).await - } else if let Some(image) = msg.image_message.as_option() { - fetch_now(client, image, "image", eager, image.file_length).await + } else if let Some(image) = image_of(msg) { + fetch_now( + client, + image, + "image", + eager && oxidezap_chat_store::business_media(msg).is_none(), + image.file_length, + ) + .await } else { None }; @@ -640,3 +675,23 @@ mod tests { assert_eq!(media.data.as_slice(), [1]); } } + +#[cfg(test)] +mod business_tests { + use super::*; + use whatsapp_rust::buffa::MessageField; + #[test] + fn interactive_header_thumbnail_is_local_and_has_no_fetch_capability() { + let mut header = wa::message::interactive_message::Header::default(); + header.media = + Some(wa::message::interactive_message::header::Media::JpegThumbnail(vec![1, 2, 3])); + let mut interactive = wa::message::InteractiveMessage::default(); + interactive.header = MessageField::some(header); + let mut message = wa::Message::default(); + message.interactive_message = MessageField::some(interactive); + let media = media_of(&message, None).unwrap(); + assert_eq!(media.data.as_slice(), [1, 2, 3]); + assert!(media.downloadable.is_none()); + assert!(!media.data_is_preview); + } +} diff --git a/crates/session/src/whatsapp/mod.rs b/crates/session/src/whatsapp/mod.rs index dadf5cc5..c4d3580c 100644 --- a/crates/session/src/whatsapp/mod.rs +++ b/crates/session/src/whatsapp/mod.rs @@ -2295,11 +2295,13 @@ impl WhatsAppClient { // `[Media]` bubble until the next reload rehydrates it from the // store. Same extraction the stored path uses, so the two agree. let poll = poll_of(base_msg); + let mut business = oxidezap_chat_store::business_content(base_msg); // Extract text content - let content = base_msg - .text_content() - .map(|s| s.to_string()) + let content = business + .as_ref() + .map(|b| b.body.clone()) + .or_else(|| base_msg.text_content().map(str::to_owned)) .or_else(|| base_msg.get_caption().map(|s| s.to_string())) .or_else(|| oxidezap_chat_store::shared_contacts_text(base_msg)) .or_else(|| poll.as_ref().map(|poll| poll.question.clone())) @@ -2322,6 +2324,13 @@ impl WhatsAppClient { ) .await; let content = crate::mentions::apply_to(&mention_names, content); + if let Some(business) = business.as_mut() { + business.body = content.clone(); + business.header = + crate::mentions::apply_to(&mention_names, std::mem::take(&mut business.header)); + business.footer = + crate::mentions::apply_to(&mention_names, std::mem::take(&mut business.footer)); + } let media_result = media_result.map(|mut media| { if let Some(caption) = media.caption.take() { media.caption = Some(crate::mentions::apply_to(&mention_names, caption)); @@ -2352,6 +2361,7 @@ impl WhatsAppClient { edited: false, system: None, poll, + business, }; if let Some(media) = media_result { diff --git a/crates/session/src/whatsapp/tests.rs b/crates/session/src/whatsapp/tests.rs index b029dcbc..1bc96776 100644 --- a/crates/session/src/whatsapp/tests.rs +++ b/crates/session/src/whatsapp/tests.rs @@ -1870,6 +1870,7 @@ fn alias_history_unread_deduplicates_only_matching_messages() { is_text: true, system: None, poll: None, + business: None, }; let mut chat = Chat::new("111222333444555@lid".to_string()); chat.messages = vec![message("MSG-A"), message("MSG-B")]; @@ -1914,6 +1915,7 @@ fn watched_updates_come_back_watched() { is_text: true, system: None, poll: None, + business: None, }; let mut broadcast = Chat::new(oxidezap_core::STATUS_BROADCAST_JID.to_string()); broadcast.messages = vec![ diff --git a/docs/message-coverage.md b/docs/message-coverage.md new file mode 100644 index 00000000..66d7243a --- /dev/null +++ b/docs/message-coverage.md @@ -0,0 +1,61 @@ +# Message display coverage + +Business messages are projected once from decoded protobuf in `chat-store::business`. +Live reception and durable hydration share that projection. `core::BusinessContent` +is serialized with the GUI's daemon events; CLI DTOs receive the same card as a +readable summary. Existing rows with missing template text are rendered from their +stored proto without a migration. Their old FTS entries are not rebuilt. + +| Content | Display | +| --- | --- | +| Hydrated template, direct field or format oneof | Header, formatted body/footer, button labels; image/video/document attachment and location label | +| Interactive template, standalone interactive | Header/subtitle, formatted body/footer, bounded native-flow button labels; header attachments and inline thumbnail | +| Legacy buttons, lists | Header/body/footer, choices and row descriptions | +| Template/button/list/interactive response | Display text, never response IDs or native-flow JSON | +| HSM with hydrated template | Same hydrated card projection | +| Untranslated template | Explanation that translation requires the phone | +| Carousel | Bounded readable card text; attachment/action limitation | + +Business button actions are display-only. Each choice has a visible explanation; +URL buttons, call buttons, replies and native flows are not executed. Ordinary +links in displayed text retain the client's explicit link controls. Rendering +never opens a URL, starts a call, submits a response or executes flow JSON. +Business attachments are lazy and use the existing media cache/download guards, +including cached-only access while offline. Inline thumbnails need no fetch. + +Projection limits are 32 wrapper layers, 16 KiB per text field, 20 choices, +512 bytes per choice label, 16 KiB per button JSON and 10 carousel cards. Text is +truncated at UTF-8 boundaries. Carousel text shares one 16 KiB aggregate budget. +No flow identifiers or unrecognized parameter objects become fallback text. + +Existing dedicated display includes text, photos, video/video notes, audio/voice +notes, stickers, documents, contacts, supported poll creations and system notices. +Location messages outside business headers, products/catalogs, events, group invites, +albums and future message kinds still rely on available textual projections or +kind placeholders. Poll v5/v6 voting, untranslated template localization, carousel +media, shop/collection interactions, Bloks widgets and native-flow execution remain +unsupported. This inventory does not claim support for every protocol type. + +## Protocol evidence + +Checked 2026-10-26 against whatspec +[`c581c20`](https://github.com/oxidezap/whatspec/tree/c581c20a9d0605df49abf297f2e7cfc04ccf1ff7), +schema `4.4.0`, WhatsApp Web `2.3000.1047483476`. All 579 preserved bundles matched +the lock's sizes and SHA-256 values. Bundle-set hash +`05609307e68b0f6ccfd2a121a573049999e38b752cfe7155ebbe5c661805751b`; +[release asset](https://github.com/oxidezap/whatspec/releases/download/bundle-store/bundles-2.3000.1047483476-05609307e68b0f6ccfd2a121a573049999e38b752cfe7155ebbe5c661805751b.tar.xz). + +AST-extracted modules in bundle +`09d91fdc4089c50ab1d35dd6c796ee82ef4c3296710316d20383c85738b871b8`: + +| Module | Byte range | Observed behavior | +| --- | --- | --- | +| `WAWebParseTemplateMessageProto` | 4631735..4635211 | Interactive-template dispatch first, otherwise hydratedTemplate then hydratedFourRowTemplate; content/footer/title and header media | +| `WAWebParseInteractiveMessageProto` | 4475063..4482388 | Body/footer/header fields; media guarded by hasMediaAttachment; type/version/native-flow validation and phone-only fallback | +| `WAWebParseTemplateButtonReplyMessageProto` | 4535672..4536276 | Display text is separate from selected ID/index | + +The official Web parser applies relay/business verification, feature gates and +native-flow validation before actions. This client only reads display fields and +never implements those actions from this projection. These are static observations +of one preserved Web version, not authenticated execution or a server contract. +Committed fixtures are synthetic. From 250be34f47d2586de4f99e58f5ce595de24bce00 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 17:40:57 -0300 Subject: [PATCH 2/5] Expose business card selectors to the native render harness --- .../src/components/message_bubble/business.rs | 29 ++++++++++++------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/crates/gui/src/components/message_bubble/business.rs b/crates/gui/src/components/message_bubble/business.rs index ab1c5bf7..f23909b2 100644 --- a/crates/gui/src/components/message_bubble/business.rs +++ b/crates/gui/src/components/message_bubble/business.rs @@ -50,10 +50,13 @@ pub(super) fn render_header( metrics: Metrics, cx: &App, ) -> impl IntoElement + use<> { + let id = parsed + .map(|p| p.header_id.clone()) + .unwrap_or_else(|| "business-header-unavailable".into()); + let selector = id.clone(); div() - .id(parsed - .map(|p| p.header_id.clone()) - .unwrap_or_else(|| "business-header-unavailable".into())) + .id(id) + .debug_selector(move || selector.to_string()) .min_w_0() .text_size(metrics.text_body()) .text_color(cx.theme().foreground) @@ -77,10 +80,13 @@ pub(super) fn render_footer( metrics: Metrics, cx: &App, ) -> impl IntoElement + use<> { + let id = parsed + .map(|p| p.footer_id.clone()) + .unwrap_or_else(|| "business-footer-unavailable".into()); + let selector = id.clone(); div() - .id(parsed - .map(|p| p.footer_id.clone()) - .unwrap_or_else(|| "business-footer-unavailable".into())) + .id(id) + .debug_selector(move || selector.to_string()) .flex() .flex_col() .min_w_0() @@ -98,11 +104,14 @@ pub(super) fn render_footer( }) }) .children(content.buttons.iter().enumerate().map(|(ix, button)| { + let id = parsed + .and_then(|p| p.button_ids.get(ix)) + .cloned() + .unwrap_or_else(|| format!("business-choice-{ix}").into()); + let selector = id.clone(); div() - .id(parsed - .and_then(|p| p.button_ids.get(ix)) - .cloned() - .unwrap_or_else(|| format!("business-choice-{ix}").into())) + .id(id) + .debug_selector(move || selector.to_string()) .flex() .flex_col() .min_w_0() From ae5d260992c98844a19a08b4fe6d0545fc233fa0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 17:53:05 -0300 Subject: [PATCH 3/5] Preserve business segment selection and account for retained card content --- crates/core/src/business.rs | 25 +++ crates/core/src/chat/message.rs | 10 + crates/gui/src/app/message_actions.rs | 5 +- crates/gui/src/app/mod.rs | 201 +++++++++++++----- .../src/components/message_bubble/business.rs | 25 ++- crates/gui/src/components/mod.rs | 1 + crates/gui/src/components/rich_text.rs | 13 +- .../gui/src/components/rich_text_selection.rs | 66 +++++- crates/session/src/whatsapp/ui_queue.rs | 77 +++++++ 9 files changed, 344 insertions(+), 79 deletions(-) diff --git a/crates/core/src/business.rs b/crates/core/src/business.rs index c1490a0a..40f48ee9 100644 --- a/crates/core/src/business.rs +++ b/crates/core/src/business.rs @@ -105,4 +105,29 @@ mod tests { "a business body must not become a button response or resend" ); } + #[test] + fn accepted_revoke_discards_business_text_and_choices() { + let mut message = + crate::ChatMessage::new_outgoing("synthetic".into(), "original body".into()); + message.business = Some(BusinessContent { + kind: BusinessKind::Template, + header: "original header".into(), + body: "original body".into(), + footer: "original footer".into(), + buttons: vec![BusinessButton { + label: "original choice".into(), + action: BusinessAction::Reply, + }], + notice: None, + }); + message.mark_revoked(); + assert!(message.revoked); + assert!(message.business.is_none()); + assert_eq!(message.preview_text(), "[Message deleted]"); + assert!( + !serde_json::to_string(&message) + .unwrap() + .contains("original") + ); + } } diff --git a/crates/core/src/chat/message.rs b/crates/core/src/chat/message.rs index bd7a76e5..97459ff9 100644 --- a/crates/core/src/chat/message.rs +++ b/crates/core/src/chat/message.rs @@ -199,6 +199,16 @@ impl ChatMessage { self.is_from_me && self.status.is_failed() } + /// Apply an accepted deletion without retaining displayable original content. + pub fn mark_revoked(&mut self) { + self.revoked = true; + self.content = "[Message deleted]".into(); + self.media = None; + self.quoted = None; + self.poll = None; + self.business = None; + } + /// What sending this again would put on the wire, if anything. /// /// One question with one answer, asked by the bubble to decide whether to diff --git a/crates/gui/src/app/message_actions.rs b/crates/gui/src/app/message_actions.rs index 8242d306..5a3f958e 100644 --- a/crates/gui/src/app/message_actions.rs +++ b/crates/gui/src/app/message_actions.rs @@ -87,10 +87,7 @@ impl WhatsAppApp { if let Some(message) = chat.messages.iter_mut().find(|m| m.id == key.1) { - message.revoked = true; - message.content = "[Message deleted]".to_owned(); - message.media = None; - message.quoted = None; + message.mark_revoked(); if newest { chat.last_message = Some("[Message deleted]".to_owned()); } diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index f84d236a..95e605ca 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -1115,10 +1115,6 @@ pub(super) fn clear_window_message_selection(window: &mut Window, cx: &mut App) ); } -fn message_has_selectable_text(message: &ChatMessage) -> bool { - message.system.is_none() && message.poll.is_none() && !message.content.is_empty() -} - impl WhatsAppApp { pub fn media_cache(&self) -> Option> { self.client.as_ref().map(Session::media_cache) @@ -2050,68 +2046,48 @@ impl WhatsAppApp { window.window_id(), cx, ); - let selected_ids: Vec = selected_messages - .iter() - .map(|(message_id, _, _)| message_id.clone()) - .collect(); - let selection_is_stale = if selected_ids.is_empty() { + let selection_is_stale = if selected_messages.is_empty() { false } else { let cache = self.message_list_cache.borrow(); - match (cache.get(chat_jid), self.find_chat(chat_jid)) { - (Some(previous), Some(current)) => { - let previous_by_id: HashMap<&str, (usize, &ChatMessage)> = previous - .messages - .iter() - .enumerate() - .map(|(index, message)| { - (message.id.as_str(), (index, message.as_ref())) - }) - .collect(); - let current_by_id: HashMap<&str, (usize, &ChatMessage)> = current - .messages - .iter() - .enumerate() - .map(|(index, message)| (message.id.as_str(), (index, message))) - .collect(); - selected_ids.iter().any(|message_id| { - let (Some((old_index, old)), Some((new_index, new))) = ( - previous_by_id.get(message_id.as_str()), - current_by_id.get(message_id.as_str()), - ) else { - return true; - }; - old_index != new_index - || !message_has_selectable_text(old) - || !message_has_selectable_text(new) - || old.content != new.content - || old.revoked != new.revoked - }) - } - (None, Some(current)) => { - // The render cache may already be gone, but retained - // participants keep the visible text to compare against. + match self.find_chat(chat_jid) { + Some(current) => { let current_by_id: HashMap<&str, (usize, &ChatMessage)> = current .messages .iter() .enumerate() .map(|(index, message)| (message.id.as_str(), (index, message))) .collect(); + let previous_by_id: Option> = + cache.get(chat_jid).map(|previous| { + previous + .messages + .iter() + .map(|message| (message.id.as_str(), message.as_ref())) + .collect() + }); selected_messages.iter().any( - |(message_id, selected_text, selected_order)| { + |(selection, selected_text, selected_order)| { let Some((current_order, message)) = - current_by_id.get(message_id.as_str()) + current_by_id.get(selection.message_id.as_ref()) else { return true; }; - *current_order as u64 != *selected_order - || !message_has_selectable_text(message) - || crate::components::BubbleText::of(&message.content).text() + let Some(source) = selection.source_text(message) else { + return true; + }; + selection.document_order(*current_order) != *selected_order + || crate::components::BubbleText::of(source).text() != selected_text.as_ref() + || previous_by_id.as_ref().is_some_and(|previous| { + previous.get(selection.message_id.as_ref()).is_none_or( + |old| selection.source_text(old) != Some(source), + ) + }) }, ) } - _ => true, + None => true, } }; if selection_is_stale { @@ -4536,7 +4512,7 @@ mod tests { struct SelectionExitTestView { focus_handle: FocusHandle, - selection_key: Arc, + selection_key: crate::components::rich_text_selection::MessageSelection, } impl Render for SelectionExitTestView { @@ -4547,10 +4523,10 @@ mod tests { .id("message-row") .w(gpui::px(400.)) .h(gpui::px(40.)) - .child(crate::components::render_rich_text( + .child(crate::components::render_message_segment( &text, self.selection_key.clone(), - 0, + self.selection_key.document_order(0), cx, )), ) @@ -4586,7 +4562,7 @@ mod tests { focus_handle = Some(handle.clone()); SelectionExitTestView { focus_handle: handle, - selection_key: Arc::from("test-message"), + selection_key: Arc::::from("test-message").into(), } }); gpui_component::Root::new(view, window, cx) @@ -4636,6 +4612,125 @@ mod tests { ); } + #[gpui::test] + fn business_segment_selection_survives_unrelated_updates_and_clears_when_changed( + cx: &mut gpui::TestAppContext, + ) { + use crate::components::rich_text_selection::{MessageSelection, MessageTextSegment}; + use oxidezap_core::{BusinessContent, BusinessKind}; + cx.update(|cx| { + gpui_component::init(cx); + crate::theme::init(cx); + }); + for segment in [MessageTextSegment::Header, MessageTextSegment::Footer] { + let jid = "peer@example.invalid"; + let mut focus_handle = None; + let mut app_entity = None; + let (_, visual) = cx.add_window_view(|window, cx| { + let app = cx.new(|cx| { + let mut app = WhatsAppApp::new(cx); + let mut chat = Chat::new(jid.into()); + let mut message = ChatMessage::new_incoming( + "test-message".into(), + jid.into(), + "different body".into(), + ); + message.business = Some(BusinessContent { + kind: BusinessKind::Template, + header: "alpha beta".into(), + body: "different body".into(), + footer: "alpha beta".into(), + buttons: vec![], + notice: None, + }); + chat.messages.push(message); + let cache = MessageListCache::new(&chat.messages, false, None); + app.chats.push(Arc::new(chat)); + app.message_list_cache + .borrow_mut() + .insert(jid.into(), cache); + app.selected_chat = Some(jid.into()); + app + }); + app.update(cx, |app, _| app.set_modal_window(window.window_handle())); + app_entity = Some(app); + let view = cx.new(|cx| { + let handle = cx.focus_handle(); + focus_handle = Some(handle.clone()); + SelectionExitTestView { + focus_handle: handle, + selection_key: MessageSelection { + message_id: Arc::from("test-message"), + segment, + }, + } + }); + gpui_component::Root::new(view, window, cx) + }); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + focus_handle.as_ref().unwrap().focus(window, cx); + }); + visual.simulate_mouse_down( + gpui::point(gpui::px(1.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + visual.simulate_mouse_move( + gpui::point(gpui::px(58.), gpui::px(12.)), + Some(gpui::MouseButton::Left), + gpui::Modifiers::default(), + ); + visual.simulate_mouse_up( + gpui::point(gpui::px(58.), gpui::px(12.)), + gpui::MouseButton::Left, + gpui::Modifiers::default(), + ); + assert_eq!( + visual.update(|window, cx| gpui_base::TextSelection::selected_text(window, cx)), + "alpha " + ); + visual.update(|window, cx| { + app_entity.as_ref().unwrap().update(cx, |app, cx| { + app.find_chat_mut(jid) + .unwrap() + .messages + .push(ChatMessage::new_incoming( + "unrelated".into(), + jid.into(), + "new message".into(), + )); + app.invalidate_message_cache(jid, cx); + app.invalidate_message_cache(jid, cx); + }); + window.draw(cx).clear(cx); + }); + assert_eq!( + visual.update(|window, cx| gpui_base::TextSelection::selected_text(window, cx)), + "alpha " + ); + visual.update(|window, cx| { + app_entity.as_ref().unwrap().update(cx, |app, cx| { + let business = app.find_chat_mut(jid).unwrap().messages[0] + .business + .as_mut() + .unwrap(); + match segment { + MessageTextSegment::Header => business.header = "changed".into(), + MessageTextSegment::Footer => business.footer = "changed".into(), + _ => unreachable!(), + } + app.invalidate_message_cache(jid, cx); + }); + window.draw(cx).clear(cx); + }); + assert_eq!( + visual.update(|window, cx| gpui_base::TextSelection::selected_text(window, cx)), + "" + ); + } + } + #[gpui::test] fn leaving_connected_view_clears_selected_message_text(cx: &mut gpui::TestAppContext) { cx.update(|cx| { @@ -4653,7 +4748,7 @@ mod tests { focus_handle = Some(handle.clone()); SelectionExitTestView { focus_handle: handle, - selection_key: Arc::from("test-message"), + selection_key: Arc::::from("test-message").into(), } }); gpui_component::Root::new(view, window, cx) diff --git a/crates/gui/src/components/message_bubble/business.rs b/crates/gui/src/components/message_bubble/business.rs index f23909b2..5376ab72 100644 --- a/crates/gui/src/components/message_bubble/business.rs +++ b/crates/gui/src/components/message_bubble/business.rs @@ -1,5 +1,6 @@ //! Display-only choices: rendering never performs a business action. -use crate::components::{BubbleText, render_rich_text}; +use crate::components::rich_text_selection::{MessageSelection, MessageTextSegment}; +use crate::components::{BubbleText, render_message_segment}; use crate::theme::Metrics; use gpui::{ App, InteractiveElement, IntoElement, ParentElement, SharedString, Styled, div, @@ -13,8 +14,8 @@ use std::sync::Arc; pub(crate) struct BusinessText { header: BubbleText, footer: BubbleText, - header_key: Arc, - footer_key: Arc, + header_key: MessageSelection, + footer_key: MessageSelection, header_id: SharedString, footer_id: SharedString, button_ids: Arc<[SharedString]>, @@ -24,8 +25,14 @@ impl BusinessText { Self { header: BubbleText::of(&content.header), footer: BubbleText::of(&content.footer), - header_key: format!("business-header-{id}").into(), - footer_key: format!("business-footer-{id}").into(), + header_key: MessageSelection { + message_id: Arc::from(id), + segment: MessageTextSegment::Header, + }, + footer_key: MessageSelection { + message_id: Arc::from(id), + segment: MessageTextSegment::Footer, + }, header_id: format!("business-header-{id}").into(), footer_id: format!("business-footer-{id}").into(), button_ids: content @@ -63,9 +70,9 @@ pub(super) fn render_header( .font_weight(gpui::FontWeight::SEMIBOLD) .when_some(parsed, |el, parsed| { el.when(!parsed.header.is_empty(), |el| { - el.child(render_rich_text( + el.child(render_message_segment( &parsed.header, - Arc::clone(&parsed.header_key), + parsed.header_key.clone(), order.saturating_mul(3), cx, )) @@ -95,9 +102,9 @@ pub(super) fn render_footer( .text_color(cx.theme().muted_foreground) .when_some(parsed, |el, parsed| { el.when(!parsed.footer.is_empty(), |el| { - el.child(render_rich_text( + el.child(render_message_segment( &parsed.footer, - Arc::clone(&parsed.footer_key), + parsed.footer_key.clone(), order.saturating_mul(3).saturating_add(2), cx, )) diff --git a/crates/gui/src/components/mod.rs b/crates/gui/src/components/mod.rs index 86d86c87..bd54bb27 100644 --- a/crates/gui/src/components/mod.rs +++ b/crates/gui/src/components/mod.rs @@ -37,6 +37,7 @@ pub use message_list::{new_timeline_state, render_message_list}; pub use nav_rail::render_nav_rail; pub(crate) use paste_preview::{PastePreviewProps, PreviewFile, render_paste_preview}; pub use plugin_ui::PluginContext; +pub(crate) use rich_text::render_message_segment; pub use rich_text::{BubbleText, render_rich_text}; pub use status::{ StatusListProps, StatusSelection, StatusViewProps, render_status_list, render_status_view, diff --git a/crates/gui/src/components/rich_text.rs b/crates/gui/src/components/rich_text.rs index 2d32f7ca..01c4ae1b 100644 --- a/crates/gui/src/components/rich_text.rs +++ b/crates/gui/src/components/rich_text.rs @@ -20,7 +20,7 @@ use gpui::{ }; use gpui_component::ActiveTheme as _; -use crate::components::rich_text_selection::SelectableRichText; +use crate::components::rich_text_selection::{MessageSelection, SelectableRichText}; use crate::theme::ActiveProductTheme as _; use oxidezap_core::{Emphasis, LinkSpan, find_links_in, parse_rich_text}; @@ -145,6 +145,15 @@ pub fn render_rich_text( selection_key: Arc, document_order: u64, cx: &App, +) -> impl IntoElement + use<> { + render_message_segment(parsed, selection_key.into(), document_order, cx) +} + +pub(crate) fn render_message_segment( + parsed: &BubbleText, + selection_key: MessageSelection, + document_order: u64, + cx: &App, ) -> impl IntoElement + use<> { if !parsed.links.is_empty() { return render_with_links(parsed, selection_key, document_order, cx).into_any_element(); @@ -227,7 +236,7 @@ fn style_for(emphasis: Emphasis, metrics: crate::theme::Metrics) -> HighlightSty /// the parent; only the link ink comes from the theme. fn render_with_links( parsed: &BubbleText, - selection_key: Arc, + selection_key: MessageSelection, document_order: u64, cx: &App, ) -> impl IntoElement + use<> { diff --git a/crates/gui/src/components/rich_text_selection.rs b/crates/gui/src/components/rich_text_selection.rs index 9ce5807a..9d66d746 100644 --- a/crates/gui/src/components/rich_text_selection.rs +++ b/crates/gui/src/components/rich_text_selection.rs @@ -23,6 +23,50 @@ use gpui_base::{ }; use gpui_component::ActiveTheme as _; +/// Segment identity is separate from the owning message's durable ID. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum MessageTextSegment { + Header, + Body, + Footer, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct MessageSelection { + pub message_id: Arc, + pub segment: MessageTextSegment, +} +impl From> for MessageSelection { + fn from(message_id: Arc) -> Self { + Self { + message_id, + segment: MessageTextSegment::Body, + } + } +} +impl MessageSelection { + pub fn document_order(&self, message_index: usize) -> u64 { + (message_index as u64) + .saturating_mul(3) + .saturating_add(match self.segment { + MessageTextSegment::Header => 0, + MessageTextSegment::Body => 1, + MessageTextSegment::Footer => 2, + }) + } + pub fn source_text<'a>(&self, message: &'a oxidezap_core::ChatMessage) -> Option<&'a str> { + if message.revoked || message.system.is_some() || message.poll.is_some() { + return None; + } + let text = match self.segment { + MessageTextSegment::Body => &message.content, + MessageTextSegment::Header => &message.business.as_ref()?.header, + MessageTextSegment::Footer => &message.business.as_ref()?.footer, + }; + (!text.is_empty()).then_some(text.as_str()) + } +} + struct RetainedSelection { handle: TextSelectionHandle, text: SharedString, @@ -53,7 +97,7 @@ struct LinkPress { /// Holds participants by message identity until their selection ends. #[derive(Default)] -struct RichTextSelectionRegistry(HashMap<(gpui::WindowId, Arc), RetainedParticipant>); +struct RichTextSelectionRegistry(HashMap<(gpui::WindowId, MessageSelection), RetainedParticipant>); impl Global for RichTextSelectionRegistry {} @@ -74,7 +118,7 @@ fn retained_selection(handle: TextSelectionHandle, text: &SharedString) -> Retai pub(crate) fn active_selection_message_snapshots( window_id: gpui::WindowId, cx: &App, -) -> Vec<(String, SharedString, u64)> { +) -> Vec<(MessageSelection, SharedString, u64)> { if !cx.has_global::() { return Vec::new(); } @@ -86,7 +130,7 @@ pub(crate) fn active_selection_message_snapshots( }) .map(|((_, message_id), participant)| { ( - message_id.to_string(), + message_id.clone(), participant.text.clone(), participant.document_order, ) @@ -96,7 +140,7 @@ pub(crate) fn active_selection_message_snapshots( fn record_selection_bounds( window_id: gpui::WindowId, - message_id: &Arc, + message_id: &MessageSelection, bounds: Bounds, cx: &mut App, ) { @@ -104,7 +148,7 @@ fn record_selection_bounds( && let Some(participant) = cx .global_mut::() .0 - .get_mut(&(window_id, Arc::clone(message_id))) + .get_mut(&(window_id, message_id.clone())) { participant.last_bounds = Some(bounds); } @@ -112,13 +156,13 @@ fn record_selection_bounds( fn track_selection_handle( window_id: gpui::WindowId, - message_id: &Arc, + message_id: &MessageSelection, handle: &TextSelectionHandle, text: &SharedString, document_order: u64, cx: &mut App, ) { - let key = (window_id, Arc::clone(message_id)); + let key = (window_id, message_id.clone()); if handle.snapshot(cx).is_some() { if !cx.has_global::() { cx.set_global(RichTextSelectionRegistry::default()); @@ -302,7 +346,7 @@ pub(super) struct SelectableRichText { styled_text: StyledText, links: Vec>, link_targets: Arc<[SharedString]>, - selection_key: Arc, + selection_key: MessageSelection, document_order: u64, } @@ -313,7 +357,7 @@ impl SelectableRichText { styled_text: StyledText, links: Vec>, link_targets: Arc<[SharedString]>, - selection_key: Arc, + selection_key: impl Into, document_order: u64, ) -> Self { Self { @@ -322,7 +366,7 @@ impl SelectableRichText { styled_text, links, link_targets, - selection_key, + selection_key: selection_key.into(), document_order, } } @@ -688,7 +732,7 @@ impl Element for SelectableRichText { cx: &mut App, ) -> (LayoutId, Self::RequestLayoutState) { let window_id = window.window_handle().window_id(); - let registry_key = (window_id, Arc::clone(&self.selection_key)); + let registry_key = (window_id, self.selection_key.clone()); let registry_participant = cx .has_global::() .then(|| { diff --git a/crates/session/src/whatsapp/ui_queue.rs b/crates/session/src/whatsapp/ui_queue.rs index 03c1c6d7..7fe79f7f 100644 --- a/crates/session/src/whatsapp/ui_queue.rs +++ b/crates/session/src/whatsapp/ui_queue.rs @@ -571,6 +571,22 @@ fn message_bytes(message: &oxidezap_core::ChatMessage) -> usize { .map(string_bytes) .unwrap_or_default() + string_bytes(&message.content) + + message.business.as_ref().map_or(0, |business| { + string_bytes(&business.header) + + string_bytes(&business.body) + + string_bytes(&business.footer) + + business + .notice + .as_deref() + .map(string_bytes) + .unwrap_or_default() + + business.buttons.capacity() * std::mem::size_of::() + + business + .buttons + .iter() + .map(|button| string_bytes(&button.label)) + .sum::() + }) + media + message .reactions @@ -904,4 +920,65 @@ mod tests { Ok(UiEvent::HistoryLoaded { .. }) )); } + #[test] + fn business_allocations_enforce_history_budget_and_reduce_page_size() { + use oxidezap_core::{ + BusinessAction, BusinessButton, BusinessContent, BusinessKind, Chat, ChatMessage, + }; + for field in ["header", "body", "footer", "notice", "buttons"] { + let mut chat = Chat::new("example@lid".into()); + chat.messages.push(ChatMessage::new_incoming( + "synthetic".into(), + "example@lid".into(), + "OK".into(), + )); + let mut event = UiEvent::HistoryLoaded { + chats: vec![chat], + complete: true, + next: None, + }; + assert!( + estimated_bytes(&event) < 2048, + "small body fits before adding {field}" + ); + let mut business = BusinessContent { + kind: BusinessKind::Template, + header: String::new(), + body: String::new(), + footer: String::new(), + buttons: vec![], + notice: None, + }; + match field { + "header" => business.header = "h".repeat(4096), + "body" => business.body = "b".repeat(4096), + "footer" => business.footer = "f".repeat(4096), + "notice" => business.notice = Some("n".repeat(4096)), + "buttons" => { + business.buttons = (0..8) + .map(|_| BusinessButton { + label: "c".repeat(512), + action: BusinessAction::Reply, + }) + .collect() + } + _ => unreachable!(), + } + let UiEvent::HistoryLoaded { chats, .. } = &mut event else { + unreachable!() + }; + chats[0].messages[0].business = Some(business); + let recovery = Arc::new(Notify::new()); + let budget = Arc::new(HistoryBudget::new()); + let (sender, mut receiver) = + channel_with_limits(recovery, budget.clone(), 4, 4, 2048, MAX_DATA_AGE); + assert!( + sender.send(event).is_err(), + "{field} must count towards the queue budget" + ); + assert_eq!(budget.limits(), (50, 25)); + assert_eq!(sender.stats().dropped_recoverable, 1); + assert!(matches!(receiver.try_recv(), Err(TryRecvError::Empty))); + } + } } From 36f4545cc1e98acd2ce55e750011210faa13c924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 17:58:20 -0300 Subject: [PATCH 4/5] Read business mention and quote contexts across wrapped templates --- crates/chat-store/src/business.rs | 44 +++++++++ crates/chat-store/src/lib.rs | 3 +- crates/session/src/mentions.rs | 39 ++++---- crates/session/src/quoting.rs | 13 ++- crates/session/src/whatsapp/mod.rs | 6 +- crates/session/src/whatsapp/tests.rs | 130 +++++++++++++++++++++++++++ 6 files changed, 205 insertions(+), 30 deletions(-) diff --git a/crates/chat-store/src/business.rs b/crates/chat-store/src/business.rs index e5df1a2b..6e773dcb 100644 --- a/crates/chat-store/src/business.rs +++ b/crates/chat-store/src/business.rs @@ -119,6 +119,50 @@ fn finish(message: &wa::Message, mut out: BusinessContent) -> BusinessContent { out } +/// Contexts attached to business content, including nested interactive templates. +/// A template can carry mentions on its outer context and quote linkage inside. +pub fn business_contexts(message: &wa::Message) -> impl Iterator { + let message = crate::normalized_message(message); + let attachment_context = business_media(message).and_then(|media| match media { + BusinessMedia::Image(m) => m.context_info.as_option(), + BusinessMedia::Video(m) => m.context_info.as_option(), + BusinessMedia::Document(m) => m.context_info.as_option(), + BusinessMedia::Location(m) => m.context_info.as_option(), + BusinessMedia::Thumbnail(_) => None, + }); + [ + template_of(message).and_then(|t| t.context_info.as_option()), + interactive_business(message).and_then(|m| m.context_info.as_option()), + message + .template_button_reply_message + .as_option() + .and_then(|m| m.context_info.as_option()), + message + .buttons_message + .as_option() + .and_then(|m| m.context_info.as_option()), + message + .buttons_response_message + .as_option() + .and_then(|m| m.context_info.as_option()), + message + .list_message + .as_option() + .and_then(|m| m.context_info.as_option()), + message + .list_response_message + .as_option() + .and_then(|m| m.context_info.as_option()), + message + .interactive_response_message + .as_option() + .and_then(|m| m.context_info.as_option()), + attachment_context, + ] + .into_iter() + .flatten() +} + pub fn business_content(message: &wa::Message) -> Option { let message = crate::normalized_message(message); if let Some(interactive) = interactive_business(message) { diff --git a/crates/chat-store/src/lib.rs b/crates/chat-store/src/lib.rs index 1008bd01..285d4f26 100644 --- a/crates/chat-store/src/lib.rs +++ b/crates/chat-store/src/lib.rs @@ -63,5 +63,6 @@ pub use types::{ mod business; pub use business::{ - BusinessMedia, business_content, business_media, hydrated_template, interactive_business, + BusinessMedia, business_content, business_contexts, business_media, hydrated_template, + interactive_business, }; diff --git a/crates/session/src/mentions.rs b/crates/session/src/mentions.rs index 1eb372ea..837194d8 100644 --- a/crates/session/src/mentions.rs +++ b/crates/session/src/mentions.rs @@ -17,7 +17,6 @@ use std::sync::Arc; use oxidezap_chat_store::StoredMessage; use oxidezap_core::ChatMessage; use whatsapp_rust::client::Client; -use whatsapp_rust::wacore::proto_helpers::MessageExt; use whatsapp_rust::wacore_binary::jid::Jid; use whatsapp_rust::waproto::whatsapp as wa; @@ -57,9 +56,7 @@ pub(crate) async fn hydrate_mention_lists( } msg.content = apply_to(&pairs, std::mem::take(&mut msg.content)); if let Some(business) = msg.business.as_mut() { - business.body = msg.content.clone(); - business.header = apply_to(&pairs, std::mem::take(&mut business.header)); - business.footer = apply_to(&pairs, std::mem::take(&mut business.footer)); + apply_business_to(&pairs, business, &msg.content); } if let Some(media) = msg.media.as_mut() && let Some(caption) = media.caption.take() @@ -139,6 +136,10 @@ pub(crate) fn mentioned_jids(message: Option<&wa::Message>) -> Vec { document_message, sticker_message, ); + jids.extend( + oxidezap_chat_store::business_contexts(base) + .flat_map(|context| context.mentioned_jid.iter().cloned()), + ); jids.sort(); jids.dedup(); jids @@ -267,6 +268,19 @@ async fn mention_names( out } +pub(crate) fn apply_business_to( + pairs: &[(String, String)], + business: &mut oxidezap_core::BusinessContent, + body: &str, +) { + business.body = body.to_owned(); + business.header = apply_to(pairs, std::mem::take(&mut business.header)); + business.footer = apply_to(pairs, std::mem::take(&mut business.footer)); + for button in &mut business.buttons { + button.label = apply_to(pairs, std::mem::take(&mut button.label)); + } +} + /// The nested original a reply quotes, if it quoted one. /// /// The linkage lives on the reply body's context while the original's text @@ -280,7 +294,7 @@ fn nested_quoted(message: Option<&wa::Message>) -> Option<&wa::Message> { && let Some(context) = body.context_info.as_option() && let Some(quoted) = context.quoted_message.as_option() { - return Some(quoted.get_base_message()); + return Some(oxidezap_chat_store::normalized_message(quoted)); } )+}; } @@ -295,7 +309,9 @@ fn nested_quoted(message: Option<&wa::Message>) -> Option<&wa::Message> { document_message, sticker_message, ); - None + oxidezap_chat_store::business_contexts(base) + .find_map(|context| context.quoted_message.as_option()) + .map(oxidezap_chat_store::normalized_message) } /// The message under its envelopes. @@ -304,16 +320,7 @@ fn nested_quoted(message: Option<&wa::Message>) -> Option<&wa::Message> { /// inside one is still a mention, and the mention list lives on the inner /// body's context rather than the envelope's. fn peel(message: &wa::Message) -> &wa::Message { - for wrapper in [ - &message.group_mentioned_message, - &message.associated_child_message, - &message.poll_creation_message_v4, - ] { - if let Some(inner) = wrapper.as_option().and_then(|w| w.message.as_option()) { - return inner.get_base_message(); - } - } - message.get_base_message() + oxidezap_chat_store::normalized_message(message) } #[cfg(test)] diff --git a/crates/session/src/quoting.rs b/crates/session/src/quoting.rs index 479efa0d..463c4ef5 100644 --- a/crates/session/src/quoting.rs +++ b/crates/session/src/quoting.rs @@ -83,15 +83,12 @@ fn context_info(message: &wa::Message) -> Option<&wa::ContextInfo> { audio_message, document_message, sticker_message, - template_message, - template_button_reply_message, - buttons_message, - buttons_response_message, - list_message, - list_response_message, - interactive_message, - interactive_response_message, ) + .or_else(|| { + oxidezap_chat_store::business_contexts(message) + .find(|context| context.stanza_id.is_some()) + .or_else(|| oxidezap_chat_store::business_contexts(message).next()) + }) } fn quoted_kind(message: &wa::Message) -> Option { diff --git a/crates/session/src/whatsapp/mod.rs b/crates/session/src/whatsapp/mod.rs index c4d3580c..0e2d6851 100644 --- a/crates/session/src/whatsapp/mod.rs +++ b/crates/session/src/whatsapp/mod.rs @@ -2325,11 +2325,7 @@ impl WhatsAppClient { .await; let content = crate::mentions::apply_to(&mention_names, content); if let Some(business) = business.as_mut() { - business.body = content.clone(); - business.header = - crate::mentions::apply_to(&mention_names, std::mem::take(&mut business.header)); - business.footer = - crate::mentions::apply_to(&mention_names, std::mem::take(&mut business.footer)); + crate::mentions::apply_business_to(&mention_names, business, &content); } let media_result = media_result.map(|mut media| { if let Some(caption) = media.caption.take() { diff --git a/crates/session/src/whatsapp/tests.rs b/crates/session/src/whatsapp/tests.rs index 1bc96776..6107ec58 100644 --- a/crates/session/src/whatsapp/tests.rs +++ b/crates/session/src/whatsapp/tests.rs @@ -3125,3 +3125,133 @@ fn read_ranges_skip_invalid_references_and_preserve_boundary() { assert_eq!(outgoing.from_me, Some(true)); assert_eq!(outgoing.participant, None); } + +/// Business mention contexts may be on the template or its interactive body. +/// Both live projection and a durable reload must resolve the same visible text. +#[tokio::test] +async fn business_mentions_resolve_live_and_historical_cards() { + const MENTIONED: &str = "559900000002@s.whatsapp.net"; + for interactive in [false, true] { + let (chat_store, client) = test_session(if interactive { + "business-mention-interactive" + } else { + "business-mention-hydrated" + }) + .await; + feed( + &chat_store, + from( + MENTIONED, + MENTIONED, + Some("Bia"), + wa::Message::text("hello"), + "CONTACT", + 1_700_000_000, + ), + ) + .await; + let mut context = wa::ContextInfo::default(); + context.mentioned_jid = vec![MENTIONED.into()]; + let mut template = wa::message::TemplateMessage::default(); + if interactive { + let mut body = wa::message::interactive_message::Body::default(); + body.text = Some("Hi @559900000002".into()); + let mut header = wa::message::interactive_message::Header::default(); + header.title = Some("For @559900000002".into()); + let mut footer = wa::message::interactive_message::Footer::default(); + footer.text = Some("From @559900000002".into()); + let mut button = + wa::message::interactive_message::native_flow_message::NativeFlowButton::default(); + button.name = Some("quick_reply".into()); + button.button_params_json = Some(r#"{"display_text":"Ask @559900000002"}"#.into()); + let mut flow = wa::message::interactive_message::NativeFlowMessage::default(); + flow.buttons.push(button); + let mut body_message = wa::message::InteractiveMessage::default(); + body_message.body = MessageField::some(body); + body_message.header = MessageField::some(header); + body_message.footer = MessageField::some(footer); + body_message.context_info = MessageField::some(context); + body_message.interactive_message = Some( + wa::message::interactive_message::InteractiveMessage::NativeFlowMessage( + flow.into(), + ), + ); + template.format = Some( + wa::message::template_message::Format::InteractiveMessageTemplate( + body_message.into(), + ), + ); + } else { + let mut hydrated = wa::message::template_message::HydratedFourRowTemplate::default(); + hydrated.hydrated_content_text = Some("Hi @559900000002".into()); + hydrated.hydrated_footer_text = Some("From @559900000002".into()); + hydrated.title = Some( + wa::message::template_message::hydrated_four_row_template::Title::HydratedTitleText( + "For @559900000002".into(), + ), + ); + let mut reply = wa::hydrated_template_button::HydratedQuickReplyButton::default(); + reply.display_text = Some("Ask @559900000002".into()); + let mut button = wa::HydratedTemplateButton::default(); + button.hydrated_button = + Some(wa::hydrated_template_button::HydratedButton::QuickReplyButton(reply.into())); + hydrated.hydrated_buttons.push(button); + template.hydrated_template = MessageField::some(hydrated); + template.context_info = MessageField::some(context); + } + let mut proto = wa::Message::default(); + proto.template_message = MessageField::some(template); + for _ in 0..2 { + let mut wrapper = wa::message::FutureProofMessage::default(); + wrapper.message = MessageField::some(proto); + proto = wa::Message::default(); + proto.ephemeral_message = MessageField::some(wrapper); + } + feed( + &chat_store, + from( + TEST_GROUP, + TEST_PEER, + Some("Ana"), + proto.clone(), + "BUSINESS-MENTION", + 1_700_000_100, + ), + ) + .await; + let names = book_with(&chat_store); + let (ui_tx, mut ui_rx) = super::ui_queue::channel( + Arc::new(tokio::sync::Notify::new()), + Arc::new(super::ui_queue::HistoryBudget::new()), + ); + let info = live_info( + TEST_GROUP, + TEST_PEER, + Some("Ana"), + "BUSINESS-MENTION", + 1_700_000_100, + ); + WhatsAppClient::handle_inbound_message(&proto, &info, &client, &ui_tx, &names, false).await; + let oxidezap_core::UiEvent::MessageReceived { message: live, .. } = + ui_rx.try_recv().unwrap() + else { + panic!("business message must be published") + }; + let live_card = live.business.as_ref().unwrap(); + assert_eq!(live.content, "Hi @Bia"); + assert_eq!(live_card.header, "For @Bia"); + assert_eq!(live_card.footer, "From @Bia"); + assert_eq!(live_card.buttons[0].label, "Ask @Bia"); + let page = + WhatsAppClient::message_page(&chat_store, &client, &names, TEST_GROUP.into(), None, 50) + .await + .unwrap(); + let historical = page + .items + .iter() + .find(|message| message.id == "BUSINESS-MENTION") + .unwrap(); + assert_eq!(historical.content, live.content); + assert_eq!(historical.business, live.business); + } +} From 8a095388499a9e693d0fcd12dc1c6441c02f2180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Lucas=20de=20Oliveira=20Lopes?= Date: Mon, 26 Oct 2026 18:34:42 -0300 Subject: [PATCH 5/5] Exercise business selection invalidation in the daemon event context --- crates/gui/src/app/mod.rs | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/crates/gui/src/app/mod.rs b/crates/gui/src/app/mod.rs index 95e605ca..ca11c404 100644 --- a/crates/gui/src/app/mod.rs +++ b/crates/gui/src/app/mod.rs @@ -4687,10 +4687,22 @@ mod tests { gpui::Modifiers::default(), ); assert_eq!( - visual.update(|window, cx| gpui_base::TextSelection::selected_text(window, cx)), + visual.update(|window, cx| { + window.draw(cx).clear(cx); + let snapshots = + crate::components::rich_text_selection::active_selection_message_snapshots( + window.window_handle().window_id(), + cx, + ); + assert_eq!(snapshots.len(), 1); + assert_eq!(snapshots[0].0.message_id.as_ref(), "test-message"); + assert_eq!(snapshots[0].0.segment, segment); + gpui_base::TextSelection::selected_text(window, cx) + }), "alpha " ); - visual.update(|window, cx| { + // Daemon events invalidate without holding a Window lease. + visual.cx.update(|cx| { app_entity.as_ref().unwrap().update(cx, |app, cx| { app.find_chat_mut(jid) .unwrap() @@ -4703,13 +4715,14 @@ mod tests { app.invalidate_message_cache(jid, cx); app.invalidate_message_cache(jid, cx); }); - window.draw(cx).clear(cx); }); + visual.update(|window, cx| window.draw(cx).clear(cx)); assert_eq!( - visual.update(|window, cx| gpui_base::TextSelection::selected_text(window, cx)), + visual.update(gpui_base::TextSelection::selected_text), "alpha " ); - visual.update(|window, cx| { + // Daemon events invalidate without holding a Window lease. + visual.cx.update(|cx| { app_entity.as_ref().unwrap().update(cx, |app, cx| { let business = app.find_chat_mut(jid).unwrap().messages[0] .business @@ -4722,12 +4735,9 @@ mod tests { } app.invalidate_message_cache(jid, cx); }); - window.draw(cx).clear(cx); }); - assert_eq!( - visual.update(|window, cx| gpui_base::TextSelection::selected_text(window, cx)), - "" - ); + visual.update(|window, cx| window.draw(cx).clear(cx)); + assert_eq!(visual.update(gpui_base::TextSelection::selected_text), ""); } }