use helix_core::EffectSink;
use crate::error::ImError;
use crate::state::ChannelId;
use crate::sync_session::EventKind;
use crate::ws::parser::extract_post_fields;
use super::super::{ImWsContext, WsFrame, WsHandlerRegistration, WsMessageHandler};
const POST_UPDATE_ACTION: &str = "post_update";
struct PostUpdateHandler;
impl WsMessageHandler for PostUpdateHandler {
fn action(&self) -> &'static str {
POST_UPDATE_ACTION
}
fn handle(
&self,
ctx: &mut ImWsContext<'_>,
frame: &WsFrame,
out: &mut EffectSink,
) -> Result<(), ImError> {
let Ok(data) = frame.data_required() else {
return Ok(());
};
apply_post_update(ctx, frame, data, out);
Ok(())
}
}
pub(super) fn apply_post_update(
ctx: &mut ImWsContext<'_>,
frame: &WsFrame,
data: &serde_json::Value,
out: &mut EffectSink,
) {
let post = data.get("post").unwrap_or(data);
let Some(channel_id) = post
.get("channelId")
.or_else(|| post.get("channel_id"))
.or_else(|| data.get("channelId"))
.or_else(|| data.get("channel_id"))
.and_then(serde_json::Value::as_str)
.and_then(ChannelId::from_str)
else {
return;
};
let msg_id = post
.get("id")
.or_else(|| post.get("postId"))
.or_else(|| post.get("post_id"))
.and_then(serde_json::Value::as_str)
.filter(|s| !s.is_empty())
.map(str::to_string);
let fields = extract_post_fields(post);
if crate::category_chain::post::validate_fields(&fields).is_err() {
return;
}
if matches!(fields.msg_type.as_str(), "VOTE" | "AVERAGE_SCORE") {
let props = serde_json::from_str::<serde_json::Value>(&fields.props).unwrap_or_default();
let entity_key = if fields.msg_type == "VOTE" {
"vote"
} else {
"averageScore"
};
let entity = props.get(entity_key).and_then(serde_json::Value::as_object);
let collection_len = |key: &str| {
entity
.and_then(|value| value.get(key))
.and_then(serde_json::Value::as_array)
.map_or(0, Vec::len)
};
tracing::info!(
target: "helix_im::engagement_projection",
hop = "post_update.parsed",
msg_id = fields.id.as_str(),
post_type = fields.msg_type.as_str(),
props_bytes = fields.props.len(),
props_has_entity = entity.is_some(),
items_or_participants = collection_len(if fields.msg_type == "VOTE" { "items" } else { "participants" }),
options_or_members = collection_len(if fields.msg_type == "VOTE" { "options" } else { "members" }),
state = entity
.and_then(|value| value.get("state"))
.and_then(serde_json::Value::as_i64)
.unwrap_or_default(),
"engagement post_update shape parsed by Helix"
);
}
let Some(target_seq) = frame.event_seq() else {
return;
};
if ctx.auth_user_id.is_empty() || i64::try_from(target_seq.0).is_err() {
return;
}
if fields.msg_type == "CATEGORY_CHAIN" {
let event = crate::sync_session::EventEnvelope::new(
channel_id,
target_seq,
EventKind::PostEdit,
fields,
)
.with_msg_id(msg_id)
.with_viewer_user_id(ctx.auth_user_id);
let Some(channel) = ctx.state.channels.get_mut(&channel_id) else {
return;
};
if let Ok(Some(event)) = channel.admit_message_v3_post(event, out) {
let corr = ctx.alloc_corr();
crate::category_chain::post::queue_edit(ctx.state, corr, event, out);
}
super::gate::trigger_backfill_if_large_gap(ctx, channel_id, target_seq, out);
return;
}
let has_template_confirmation =
crate::event::post::has_template_confirmation(fields.props.as_str());
if should_use_message_v3_compact_update(&fields, has_template_confirmation) {
let event = crate::sync_session::EventEnvelope::new(
channel_id,
target_seq,
EventKind::PostEdit,
fields,
)
.with_msg_id(msg_id)
.with_viewer_user_id(ctx.auth_user_id);
let Some(channel) = ctx.state.channels.get_mut(&channel_id) else {
return;
};
let Ok(admitted) = channel.admit_message_v3_post(event, out) else {
return;
};
if let Some(event) = admitted {
let corr = ctx.alloc_corr();
if has_quick_reply_items(event.fields.quick_reply.as_str()) {
crate::port_reply::message_v3_reaction::queue_commit(ctx.state, corr, event, out);
} else if !event.fields.expedite_map.is_empty() {
crate::port_reply::message_v3_urgent::queue_commit(ctx.state, corr, event, out);
} else {
crate::port_reply::message_v3_template::queue_commit(ctx.state, corr, event, out);
}
}
super::gate::trigger_backfill_if_large_gap(ctx, channel_id, target_seq, out);
return;
}
let event = crate::sync_session::EventEnvelope::new(
channel_id,
target_seq,
EventKind::PostEdit,
fields,
)
.with_msg_id(msg_id)
.with_viewer_user_id(ctx.auth_user_id);
let Some(channel) = ctx.state.channels.get_mut(&channel_id) else {
return;
};
let Ok(admitted) = channel.admit_message_v3_post(event, out) else {
return;
};
if let Some(event) = admitted {
let corr = ctx.alloc_corr();
crate::port_reply::message_v3_post::queue_post_update_commit(
ctx.state,
ctx.auth_user_id,
corr,
event,
out,
);
}
super::gate::trigger_backfill_if_large_gap(ctx, channel_id, target_seq, out);
}
fn should_use_message_v3_compact_update(
fields: &crate::sync_session::PostFields,
has_template_confirmation: bool,
) -> bool {
let is_engagement = fields.msg_type.eq_ignore_ascii_case("VOTE")
|| fields.msg_type.eq_ignore_ascii_case("AVERAGE_SCORE");
!is_engagement
&& (has_quick_reply_items(fields.quick_reply.as_str())
|| !fields.expedite_map.is_empty()
|| has_template_confirmation)
}
#[cfg(test)]
mod tests {
use super::should_use_message_v3_compact_update;
use crate::ws::parser::extract_post_fields;
use serde_json::json;
#[test]
fn vote_update_with_empty_quick_reply_keeps_full_snapshot_path() {
let fields = extract_post_fields(&json!({
"type": "VOTE",
"quickReply": [],
"props": {"vote": {"items": [{"userId": "member"}]}}
}));
assert!(!should_use_message_v3_compact_update(&fields, false));
}
#[test]
fn average_score_update_with_empty_quick_reply_keeps_full_snapshot_path() {
let fields = extract_post_fields(&json!({
"type": "AVERAGE_SCORE",
"quickReply": [],
"props": {"averageScore": {"participants": [{"userId": "member"}]}}
}));
assert!(!should_use_message_v3_compact_update(&fields, false));
}
}
static POST_UPDATE_HANDLER: PostUpdateHandler = PostUpdateHandler;
#[cfg(target_arch = "wasm32")]
pub(super) fn inventory_link_anchor() {
std::hint::black_box(&POST_UPDATE_HANDLER);
}
inventory::submit! {
WsHandlerRegistration {
action: POST_UPDATE_ACTION,
handler: &POST_UPDATE_HANDLER,
}
}
pub(crate) fn has_quick_reply_items(raw: &str) -> bool {
let raw = raw.trim();
if raw.is_empty() {
return false;
}
match serde_json::from_str::<serde_json::Value>(raw) {
Ok(serde_json::Value::Array(items)) => !items.is_empty(),
Ok(serde_json::Value::Null) => false,
Ok(_) | Err(_) => true,
}
}