use helix_core::effect::{BatchUpdateSpec, Effect, GetSpec, SqlValue, StorageOp};
use helix_core::tick::PortOutcome;
use helix_core::EffectSink;
use serde_json::{json, Value};
use crate::channel_update::PendingChannelUpdate;
use crate::error::ImError;
use crate::module::ImModule;
use crate::state::{ChannelId, CorrelationContext};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TopicActivityProjection {
pub(crate) topic_channel_id: ChannelId,
pub(crate) root_message_id: String,
pub(crate) title: String,
pub(crate) post_id: String,
pub(crate) author_user_id: String,
pub(crate) message_type: String,
pub(crate) preview_text: String,
pub(crate) created_at: i64,
pub(crate) topic_message_count: u64,
}
impl ImModule {
pub(crate) fn schedule_topic_activity_projection(
&mut self,
pending: &PendingChannelUpdate,
channel_reply: &[u8],
out: &mut EffectSink,
) {
if !pending.update.visible {
return;
}
let channel = serde_json::from_slice::<Value>(channel_reply)
.ok()
.and_then(|value| value.as_array()?.first().cloned());
let relation = channel.as_ref().and_then(|row| {
let root_message_id = text(
row,
&[
"root_post_id",
"rootPostId",
"root_message_id",
"rootMessageId",
],
)?;
let title = text(row, &["display_name", "displayName", "name"])?;
let topic_message_count = integer(
row,
&[
"total_msg_count",
"totalMsgCount",
"topic_msg_count",
"topicMsgCount",
],
)
.and_then(|value| u64::try_from(value).ok())
.unwrap_or_default()
.saturating_add(1);
Some((
root_message_id.to_string(),
title.to_string(),
topic_message_count,
))
});
let Some((root_message_id, title, topic_message_count)) = relation else {
return;
};
let projection = TopicActivityProjection {
topic_channel_id: pending.channel_id,
root_message_id: root_message_id.clone(),
title,
post_id: pending.msg_id.clone(),
author_user_id: pending.fields.user_id.clone(),
message_type: pending.fields.msg_type.clone(),
preview_text: if pending.fields.simple_message.is_empty() {
pending.fields.message.clone()
} else {
pending.fields.simple_message.clone()
},
created_at: pending.fields.create_at,
topic_message_count,
};
let corr = self.alloc_corr_internal();
out.push(Effect::Persist {
corr,
ops: vec![StorageOp::Get(GetSpec {
table: "message",
key_col: "id",
key_val: SqlValue::Text(root_message_id),
})],
});
self.state.corr_map.insert(
corr,
CorrelationContext::TopicActivityRootRead { projection },
);
}
pub(crate) fn handle_topic_activity_root_read(
&mut self,
projection: TopicActivityProjection,
outcome: &PortOutcome,
out: &mut EffectSink,
) -> Result<(), ImError> {
let Some(root) = storage_rows(outcome).into_iter().next() else {
return Ok(());
};
let parent_channel_id = text(&root, &["channel_id", "channelId"])
.and_then(ChannelId::from_str)
.ok_or_else(|| ImError::Parse("topic root omitted parent channel".to_string()))?;
let existing = json_value(&root, &["topic"]).unwrap_or_else(|| json!({}));
if existing.get("latestReplyId").and_then(Value::as_str)
== Some(projection.post_id.as_str())
|| existing
.get("lastActivityAt")
.and_then(Value::as_i64)
.is_some_and(|latest| latest > projection.created_at)
{
return Ok(());
}
let next_local_count = existing
.get("replyCount")
.and_then(Value::as_u64)
.unwrap_or_default()
.saturating_add(1);
let reply_count = integer(&root, &["reply_count", "replyCount"])
.and_then(|value| u64::try_from(value).ok())
.unwrap_or_default()
.max(next_local_count)
.max(projection.topic_message_count);
let parent_topic_message_count = reply_count;
let mut topic = json!({
"parentChannelId": parent_channel_id.as_str(),
"topicChannelId": projection.topic_channel_id.as_str(),
"channelId": projection.topic_channel_id.as_str(),
"rootMessageId": projection.root_message_id.clone(),
"title": projection.title.clone(),
"replyCount": reply_count,
"lastActivityAt": projection.created_at,
"latestReplyId": projection.post_id,
});
if !projection.author_user_id.is_empty() {
topic["latestReply"] = json!({
"author": {"userId": projection.author_user_id},
"preview": reply_preview(&projection.message_type, &projection.preview_text),
"createdAt": projection.created_at,
});
}
let corr = self.alloc_corr_internal();
out.push(Effect::Persist {
corr,
ops: vec![
StorageOp::BatchUpdate(BatchUpdateSpec {
table: "message",
key_col: "id",
key_vals: vec![SqlValue::Text(projection.root_message_id)],
patch: vec![("topic".to_string(), SqlValue::Text(topic.to_string()))],
}),
StorageOp::BatchUpdate(BatchUpdateSpec {
table: "channel",
key_col: "id",
key_vals: vec![SqlValue::Text(parent_channel_id.as_str().to_string())],
patch: vec![(
"topic_msg_count".to_string(),
SqlValue::Integer(parent_topic_message_count.min(i64::MAX as u64) as i64),
)],
}),
],
});
self.state.corr_map.insert(
corr,
CorrelationContext::TopicActivityPersist {
parent_channel_id,
parent_topic_message_count,
},
);
Ok(())
}
pub(crate) fn handle_topic_activity_persist(
&mut self,
parent_channel_id: ChannelId,
parent_topic_message_count: u64,
outcome: &PortOutcome,
out: &mut EffectSink,
) -> Result<(), ImError> {
if matches!(outcome, PortOutcome::Ok(_)) {
self.refresh_attached_latest_timeline(parent_channel_id, None, out)?;
out.push(
crate::event::channel::update(json!({
"channelId": parent_channel_id.as_str(),
"topicMsgCount": parent_topic_message_count,
}))?
.into_effect(),
);
}
Ok(())
}
}
fn reply_preview(message_type: &str, text: &str) -> Value {
match message_type.to_ascii_uppercase().as_str() {
"DOCUMENT" => json!({"kind":"document","title":text}),
"RICH" | "IMAGE" | "VIDEO" | "AUDIO" | "FILE" | "MEDIA" => {
json!({"kind":"media","mediaType":message_type.to_ascii_lowercase(),"fileName":text})
}
"NOTICE" | "SYSTEM" | "SYSTEN" => json!({"kind":"system","text":text}),
_ => json!({"kind":"text","text":text}),
}
}
fn storage_rows(outcome: &PortOutcome) -> Vec<Value> {
let PortOutcome::Ok(reply) = outcome else {
return Vec::new();
};
serde_json::from_slice::<Value>(reply.0.as_ref())
.ok()
.and_then(|value| value.as_array().cloned())
.unwrap_or_default()
}
fn text<'a>(row: &'a Value, keys: &[&str]) -> Option<&'a str> {
keys.iter()
.find_map(|key| row.get(*key).and_then(Value::as_str))
.filter(|value| !value.is_empty())
}
fn integer(row: &Value, keys: &[&str]) -> Option<i64> {
keys.iter()
.find_map(|key| row.get(*key).and_then(Value::as_i64))
}
fn json_value(row: &Value, keys: &[&str]) -> Option<Value> {
keys.iter().find_map(|key| match row.get(*key) {
Some(Value::String(raw)) => serde_json::from_str(raw).ok(),
Some(value @ Value::Object(_)) => Some(value.clone()),
_ => None,
})
}