use crate::error::ImError;
use bytes::Bytes;
use helix_core::effect::{DomainEventBytes, Effect};
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ReplyProjectionMode {
Snapshot,
Append,
}
impl ReplyProjectionMode {
fn as_str(self) -> &'static str {
match self {
Self::Snapshot => "snapshot",
Self::Append => "append",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReplyProjectionRequest {
pub req_id: String,
pub channel_id: String,
pub root_hint: String,
pub mode: ReplyProjectionMode,
pub revision: u64,
pub viewer_user_id: String,
pub page_number: u32,
pub page_size: u32,
}
impl ReplyProjectionRequest {
pub fn from_command(name: &str, payload: &[u8], revision: u64, viewer: &str) -> Option<Self> {
let value: serde_json::Value = serde_json::from_slice(payload).ok()?;
let req_id = value
.get("req_id")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())?
.to_string();
let (mode, root_key) = match name {
"im_get_replies" => (ReplyProjectionMode::Snapshot, "reply_id"),
"im_get_reply_branch" => (ReplyProjectionMode::Append, "reply_first_level_id"),
_ => return None,
};
let root_hint = match mode {
ReplyProjectionMode::Append => {
value.get(root_key).or_else(|| value.get("root_message_id"))
}
ReplyProjectionMode::Snapshot => {
value.get("root_message_id").or_else(|| value.get(root_key))
}
}
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())?
.to_string();
let page_number = parse_page_number(value.get("page_number")).ok()?;
let page_size = crate::timeline_state::TimelinePageSize::parse(value.get("page_size"))
.ok()?
.get();
Some(Self {
req_id,
channel_id: value
.get("channel_id")
.and_then(serde_json::Value::as_str)
.unwrap_or("")
.to_string(),
root_hint,
mode,
revision,
viewer_user_id: viewer.to_string(),
page_number,
page_size,
})
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct RepliesPageResult {
pub req_id: String,
pub root_post: serde_json::Value,
pub replies: Vec<serde_json::Value>,
pub has_more: bool,
pub page_number: u32,
pub page_size: u32,
}
impl RepliesPageResult {
pub fn to_value(&self) -> serde_json::Value {
serde_json::json!({
"reqId": self.req_id,
"rootPost": self.root_post,
"replies": self.replies,
"hasMore": self.has_more,
"page": {
"pageNumber": self.page_number,
"pageSize": self.page_size,
},
})
}
}
fn parse_page_number(value: Option<&serde_json::Value>) -> Result<u32, ImError> {
let Some(value) = value else {
return Ok(1);
};
let raw = value
.as_u64()
.ok_or_else(|| ImError::Parse("reply pageNumber must be a positive integer".to_string()))?;
let page = u32::try_from(raw)
.map_err(|_| ImError::Parse("reply pageNumber is out of range".to_string()))?;
if page == 0 {
return Err(ImError::Parse(
"reply pageNumber must be a positive integer".to_string(),
));
}
Ok(page)
}
pub fn project_replies_page(
request: &ReplyProjectionRequest,
body: &serde_json::Value,
viewer_user_id: &str,
) -> Result<RepliesPageResult, ImError> {
let payload = reply_payload(body)?;
let has_more = payload
.get("hasMore")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| ImError::Parse("reply result missing boolean hasMore".to_string()))?;
let (page_number, page_size) = match payload.get("page") {
Some(page) => {
let page = page
.as_object()
.ok_or_else(|| ImError::Parse("reply result page must be an object".to_string()))?;
let page_number = parse_page_number(page.get("pageNumber"))?;
let page_size = crate::timeline_state::TimelinePageSize::parse(page.get("pageSize"))
.map_err(|error| ImError::Parse(format!("reply result pageSize: {error}")))?
.get();
if page_number != request.page_number || page_size != request.page_size {
return Err(ImError::Parse(
"reply result page does not match request".to_string(),
));
}
(page_number, page_size)
}
None if request.mode == ReplyProjectionMode::Append => {
(request.page_number, request.page_size)
}
None => return Err(ImError::Parse("reply result missing page".to_string())),
};
let replies = payload
.get(if request.mode == ReplyProjectionMode::Append {
"data"
} else {
"replies"
})
.and_then(serde_json::Value::as_array)
.or_else(|| {
(request.mode == ReplyProjectionMode::Append)
.then(|| payload.get("replies").and_then(serde_json::Value::as_array))
.flatten()
})
.ok_or_else(|| ImError::Parse("reply result missing reply data array".to_string()))?;
let root_post = payload
.get("rootPost")
.filter(|_| request.mode == ReplyProjectionMode::Snapshot)
.filter(|post| {
is_visible_to_viewer(post, viewer_user_id)
&& post_identity_matches(post, request.root_hint.as_str())
})
.cloned()
.unwrap_or(serde_json::Value::Null);
let mut seen_ids = HashSet::with_capacity(replies.len());
let mut seen_temporary_ids = HashSet::with_capacity(replies.len());
let mut projected_replies = Vec::with_capacity(replies.len());
for reply in replies {
if !is_visible_to_viewer(reply, viewer_user_id)
|| !reply_belongs_to_root_for_mode(reply, request.root_hint.as_str(), request.mode)
{
continue;
}
let Some((id, temporary_id)) = reply_identity(reply) else {
continue;
};
if seen_ids.contains(&id) || seen_temporary_ids.contains(&temporary_id) {
continue;
}
seen_ids.insert(id);
seen_temporary_ids.insert(temporary_id);
projected_replies.push(reply.clone());
}
Ok(RepliesPageResult {
req_id: request.req_id.clone(),
root_post,
replies: projected_replies,
has_more,
page_number,
page_size,
})
}
fn reply_payload(body: &serde_json::Value) -> Result<&serde_json::Value, ImError> {
let payload = body
.get("data")
.filter(|value| value.is_object())
.unwrap_or(body);
if !payload.is_object() {
return Err(ImError::Parse("reply result must be an object".to_string()));
}
Ok(payload)
}
fn is_visible_to_viewer(post: &serde_json::Value, viewer_user_id: &str) -> bool {
if post.get("type").and_then(serde_json::Value::as_str) == Some("NOTICE")
|| viewer_user_id.is_empty()
{
return true;
}
let Some(viewers) = post.get("viewers") else {
return true;
};
if let Some(items) = viewers.as_array() {
return items
.iter()
.filter_map(serde_json::Value::as_str)
.any(|id| id == "all" || id == viewer_user_id);
}
viewers
.as_str()
.and_then(|raw| serde_json::from_str::<Vec<String>>(raw).ok())
.is_some_and(|items| items.iter().any(|id| id == "all" || id == viewer_user_id))
}
fn reply_identity(post: &serde_json::Value) -> Option<(String, String)> {
let id = post
.get("id")
.or_else(|| post.get("msgId"))
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string);
let temporary_id = post
.get("temporaryId")
.or_else(|| post.get("temporary_id"))
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
.map(str::to_string);
match (id, temporary_id) {
(Some(id), Some(temporary_id)) => Some((id, temporary_id)),
(Some(id), None) => Some((id.clone(), id)),
(None, Some(temporary_id)) => Some((temporary_id.clone(), temporary_id)),
(None, None) => None,
}
}
fn post_identity_matches(post: &serde_json::Value, root_hint: &str) -> bool {
reply_identity(post)
.is_some_and(|(id, temporary_id)| id == root_hint || temporary_id == root_hint)
}
fn reply_belongs_to_root_for_mode(
post: &serde_json::Value,
root_hint: &str,
mode: ReplyProjectionMode,
) -> bool {
let keys: &[&str] = match mode {
ReplyProjectionMode::Snapshot => &[
"rootId",
"root_id",
"replyRootId",
"reply_root_id",
"replyId",
"reply_id",
"parentId",
"parent_id",
],
ReplyProjectionMode::Append => &[
"replyFirstLevelId",
"reply_first_level_id",
"parentId",
"parent_id",
],
};
keys.iter()
.find_map(|key| {
post.get(*key)
.and_then(serde_json::Value::as_str)
.filter(|relation| !relation.is_empty())
})
.is_some_and(|relation| relation == root_hint)
}
pub fn emit_replies_page_result(result: &RepliesPageResult) -> Effect {
crate::read_relay::emit_read_body(&result.req_id, result.to_value())
}
#[derive(Debug, Clone, PartialEq)]
pub struct FlatReplyProjection {
pub channel_id: String,
pub root_message_id: String,
pub projection_mode: ReplyProjectionMode,
pub revision: u64,
pub reply_ids: Vec<String>,
pub nodes: Vec<serde_json::Value>,
pub reply_count: usize,
pub has_more: bool,
}
impl FlatReplyProjection {
pub fn empty() -> Self {
Self {
channel_id: String::new(),
root_message_id: String::new(),
projection_mode: ReplyProjectionMode::Snapshot,
revision: 0,
reply_ids: Vec::new(),
nodes: Vec::new(),
reply_count: 0,
has_more: false,
}
}
}
pub fn extract_flat_replies(body: &serde_json::Value, viewer_user_id: &str) -> FlatReplyProjection {
let raw_body = body;
let body = raw_body
.get("data")
.filter(|value| value.is_object() || value.is_array())
.unwrap_or(raw_body);
let mut projection = FlatReplyProjection::empty();
let mut seen: HashSet<String> = HashSet::new();
let mut push = |post: &serde_json::Value, is_root: bool| {
if !is_visible_to_viewer(post, viewer_user_id) {
return;
}
let id = post
.get("id")
.or_else(|| post.get("msgId"))
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if id.is_empty() || !seen.insert(id.to_string()) {
return;
}
if is_root {
projection.root_message_id = id.to_string();
} else {
projection.reply_ids.push(id.to_string());
}
projection
.nodes
.push(super::core::shape_row(post, viewer_user_id));
};
if let Some(obj) = body.as_object() {
if let Some(root) = obj.get("rootPost") {
push(root, true);
}
if let Some(arr) = ["replies", "data", "list"]
.iter()
.find_map(|key| obj.get(*key).and_then(serde_json::Value::as_array))
{
for post in arr {
push(post, false);
}
}
} else if let Some(arr) = body.as_array() {
for post in arr {
push(post, false);
}
}
drop(push);
if projection.root_message_id.is_empty() {
projection.root_message_id = projection
.nodes
.first()
.and_then(|node| {
["replyRootId", "replyId", "msgId"]
.iter()
.find_map(|key| node.get(*key).and_then(serde_json::Value::as_str))
})
.unwrap_or("")
.to_string();
}
let server_count = projection
.nodes
.first()
.and_then(|node| node.get("replyCount"))
.and_then(serde_json::Value::as_u64)
.unwrap_or_default() as usize;
projection.reply_count = server_count.max(projection.reply_ids.len());
projection.channel_id = projection
.nodes
.iter()
.find_map(|node| {
node.get("channelId")
.and_then(serde_json::Value::as_str)
.filter(|value| !value.is_empty())
})
.unwrap_or("")
.to_string();
projection.has_more = raw_body
.get("hasMore")
.and_then(serde_json::Value::as_bool)
.or_else(|| body.get("hasMore").and_then(serde_json::Value::as_bool))
.unwrap_or(false);
projection
}
pub fn finalize_projection(
request: &ReplyProjectionRequest,
mut projection: FlatReplyProjection,
) -> FlatReplyProjection {
projection.projection_mode = request.mode;
projection.revision = request.revision;
if projection.channel_id.is_empty() {
projection.channel_id.clone_from(&request.channel_id);
}
if request.mode == ReplyProjectionMode::Append && !request.root_hint.is_empty() {
projection.root_message_id.clone_from(&request.root_hint);
projection.nodes.retain(|node| {
reply_belongs_to_root_for_mode(node, request.root_hint.as_str(), request.mode)
});
let mut seen = HashSet::with_capacity(projection.nodes.len());
projection.reply_ids = projection
.nodes
.iter()
.filter_map(reply_identity)
.map(|(id, _)| id)
.filter(|id| seen.insert(id.clone()))
.collect();
projection.reply_count = projection.reply_ids.len();
} else if projection.root_message_id.is_empty() {
projection.root_message_id.clone_from(&request.root_hint);
}
if !projection_matches_request(request, &projection) {
projection.channel_id.clone_from(&request.channel_id);
projection.root_message_id.clone_from(&request.root_hint);
projection.reply_ids.clear();
projection.nodes.clear();
projection.reply_count = 0;
projection.has_more = false;
}
projection
}
pub fn projection_matches_request(
request: &ReplyProjectionRequest,
projection: &FlatReplyProjection,
) -> bool {
if !request.channel_id.is_empty()
&& !projection.channel_id.is_empty()
&& projection.channel_id != request.channel_id
{
return false;
}
if request.mode == ReplyProjectionMode::Snapshot
&& !request.root_hint.is_empty()
&& !projection.root_message_id.is_empty()
&& projection.root_message_id != request.root_hint
{
return false;
}
projection.nodes.iter().all(|node| {
let channel = node
.get("channelId")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if !request.channel_id.is_empty() && !channel.is_empty() && channel != request.channel_id {
return false;
}
let id = node
.get("msgId")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
if id == projection.root_message_id {
return true;
}
let relation = reply_relation(node, request.mode).unwrap_or("");
relation.is_empty()
|| relation == projection.root_message_id.as_str()
|| (request.mode == ReplyProjectionMode::Append
&& relation == request.root_hint.as_str())
})
}
fn reply_relation(post: &serde_json::Value, mode: ReplyProjectionMode) -> Option<&str> {
let keys: &[&str] = match mode {
ReplyProjectionMode::Snapshot => &["replyRootId", "reply_root_id", "replyId", "reply_id"],
ReplyProjectionMode::Append => &[
"replyFirstLevelId",
"reply_first_level_id",
"parentId",
"parent_id",
],
};
keys.iter()
.find_map(|key| post.get(*key).and_then(serde_json::Value::as_str))
.filter(|value| !value.is_empty())
}
pub fn accept_projection(
projection: &mut FlatReplyProjection,
revisions: &mut HashMap<String, u64>,
seen_ids: &mut HashMap<String, HashSet<String>>,
) -> bool {
let root = projection.root_message_id.as_str();
if root.is_empty() {
return true;
}
if revisions
.get(root)
.is_some_and(|latest| *latest > projection.revision)
{
return false;
}
revisions.insert(root.to_string(), projection.revision);
match projection.projection_mode {
ReplyProjectionMode::Snapshot => {
seen_ids.insert(
root.to_string(),
projection.reply_ids.iter().cloned().collect(),
);
}
ReplyProjectionMode::Append => {
let seen = seen_ids.entry(root.to_string()).or_default();
let accepted: HashSet<String> = projection
.reply_ids
.iter()
.filter(|id| !seen.contains(*id))
.cloned()
.collect();
projection.reply_ids.retain(|id| accepted.contains(id));
projection.nodes.retain(|node| {
let id = node
.get("msgId")
.and_then(serde_json::Value::as_str)
.unwrap_or("");
id.is_empty() || id == root || accepted.contains(id)
});
seen.extend(accepted);
}
}
true
}
pub fn emit_channel_replies(req_id: &str, projection: FlatReplyProjection) -> Effect {
let payload = serde_json::json!({
"event": "im:channel:replies",
"data": {
"reqId": req_id,
"channelId": projection.channel_id,
"rootMessageId": projection.root_message_id,
"projectionMode": projection.projection_mode.as_str(),
"revision": projection.revision,
"replyIds": projection.reply_ids,
"nodes": projection.nodes,
"replyCount": projection.reply_count,
"cursor": serde_json::Value::Null,
"hasMore": projection.has_more,
},
});
let bytes = Bytes::from(
serde_json::to_vec(&payload)
.expect("emit_channel_replies: static JSON shape must serialize"),
);
Effect::Emit {
event: DomainEventBytes(bytes),
}
}