1use helix_core::effect::Effect;
7use helix_core::tick::PortOutcome;
8use helix_core::EffectSink;
9use serde_json::Value;
10
11use crate::error::ImError;
12use crate::module::ImModule;
13use crate::state::{ChannelId, CorrelationContext};
14
15use super::{MessageQueryRequest, SubtopicsQueryRequest};
16
17mod data;
18
19use data::{
20 classify_local_read, dedup_recent_rows, message_key, parse_latest_posts_reply, server_id,
21 stale_local_server_rows_delete_op,
22};
23pub(crate) use data::{parse_local_rows, sort_recent_rows_desc, visible_remote_rows_and_cache_ops};
24
25pub(crate) fn parse_initial_window_posts(
27 raw_body: &[u8],
28 target_post_id: &str,
29) -> Result<Vec<Value>, ImError> {
30 let root: Value = serde_json::from_slice(raw_body)
31 .map_err(|error| ImError::Parse(format!("getPostsAfterIndex body: {error}")))?;
32 let status = root
33 .get("status")
34 .and_then(Value::as_str)
35 .ok_or_else(|| ImError::Parse("getPostsAfterIndex body missing string status".into()))?;
36 if !status.eq_ignore_ascii_case("SUCCESS") {
37 return Err(ImError::Parse(format!(
38 "getPostsAfterIndex backend status {status}"
39 )));
40 }
41 let payload = root
42 .pointer("/data/posts")
43 .or_else(|| root.get("data"))
44 .ok_or_else(|| {
45 ImError::Parse("getPostsAfterIndex response missing posts array".to_string())
46 })?;
47 if payload.is_null() {
48 return Ok(Vec::new());
49 }
50 let rows = payload.as_array().ok_or_else(|| {
51 ImError::Parse("getPostsAfterIndex response posts must be array".to_string())
52 })?;
53 if rows.iter().any(|row| !row.is_object()) {
54 return Err(ImError::Parse(
55 "getPostsAfterIndex posts must be objects".to_string(),
56 ));
57 }
58 let rows = rows.clone();
59 if rows.is_empty() {
60 return Ok(rows);
61 }
62 if !post_matches_identity(&rows[0], target_post_id) {
63 return Err(ImError::Parse(
64 "getPostsAfterIndex target must be first row".to_string(),
65 ));
66 }
67 Ok(rows)
68}
69
70pub(crate) fn post_matches_identity(row: &Value, target_post_id: &str) -> bool {
72 ["id", "postId", "temporaryId", "temporary_id"]
73 .iter()
74 .filter_map(|key| row.get(*key).and_then(Value::as_str))
75 .any(|value| value == target_post_id)
76}
77
78#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
80pub enum LocalStoreMode {
81 #[default]
82 Durable,
83 Session,
84 Disabled,
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
89pub enum LocalReadCoverage {
90 Complete,
91 Partial,
92 Miss,
93 Unsupported,
94}
95
96const REMOTE_RECENT_WINDOW: usize = 20;
98
99#[doc(hidden)]
103#[derive(Debug, Clone, PartialEq)]
104pub struct RecentMessageCoverage {
105 remote_keys_desc: Vec<String>,
106 remote_exhausted: bool,
107}
108
109impl RecentMessageCoverage {
110 fn from_remote(rows_desc: &[Value], remote_exhausted: bool) -> Option<Self> {
112 let proven_len = if remote_exhausted {
115 rows_desc.len()
116 } else {
117 rows_desc.len().min(REMOTE_RECENT_WINDOW)
118 };
119 let remote_keys_desc: Vec<String> = rows_desc
120 .iter()
121 .take(proven_len)
122 .filter_map(message_key)
123 .collect();
124 if remote_keys_desc.is_empty() {
125 return None;
126 }
127 Some(Self {
128 remote_keys_desc,
129 remote_exhausted,
130 })
131 }
132}
133
134fn recent_reply_proves_history_exhausted(
136 local_rows_desc: &[Value],
137 remote_rows_desc: &[Value],
138 received_count: usize,
139) -> bool {
140 if received_count >= REMOTE_RECENT_WINDOW {
141 return false;
142 }
143 let oldest_remote = remote_rows_desc.iter().filter_map(message_create_at).min();
144 !oldest_remote.is_some_and(|oldest| {
145 local_rows_desc.iter().any(|row| {
146 !server_id(row).is_empty()
147 && message_create_at(row).is_some_and(|create_at| create_at < oldest)
148 })
149 })
150}
151
152fn message_create_at(row: &Value) -> Option<i64> {
154 row.get("create_at")
155 .or_else(|| row.get("createAt"))
156 .or_else(|| row.get("createdAt"))
157 .and_then(Value::as_i64)
158}
159
160impl ImModule {
161 pub(crate) fn build_dialog_list_query_for_runtime(
163 &self,
164 payload: &[u8],
165 corr: helix_core::Correlation,
166 ) -> Result<Effect, ImError> {
167 let scope = super::DialogListScope::new(
168 self.config.auth_user_id.as_str(),
169 self.config.company_id.as_str(),
170 );
171 super::build_dialog_list_query_for_scope(payload, corr, &scope)
172 }
173
174 pub(crate) fn emit_dialog_list_result_for_runtime(
176 &self,
177 req_id: Option<&str>,
178 reply_bytes: &[u8],
179 ) -> Effect {
180 let scope = super::DialogListScope::new(
181 self.config.auth_user_id.as_str(),
182 self.config.company_id.as_str(),
183 );
184 super::emit_dialog_list_result(req_id.unwrap_or_default(), reply_bytes, &scope)
185 }
186
187 pub(crate) fn build_subtopics_query_for_runtime(
189 &self,
190 request: &SubtopicsQueryRequest,
191 corr: helix_core::Correlation,
192 ) -> Result<Effect, ImError> {
193 let scope = super::DialogListScope::new(
194 self.config.auth_user_id.as_str(),
195 self.config.company_id.as_str(),
196 );
197 super::build_subtopics_query_for_scope(request, corr, &scope)
198 }
199
200 pub(crate) fn emit_subtopics_result_for_runtime(
202 &self,
203 req_id: Option<&str>,
204 parent_channel_id: Option<&str>,
205 reply_bytes: &[u8],
206 ) -> Effect {
207 let scope = super::DialogListScope::new(
208 self.config.auth_user_id.as_str(),
209 self.config.company_id.as_str(),
210 );
211 super::emit_subtopics_result(
212 req_id.unwrap_or_default(),
213 reply_bytes,
214 &scope,
215 parent_channel_id,
216 )
217 }
218
219 pub(crate) fn refresh_attached_latest_timeline(
224 &mut self,
225 channel_id: ChannelId,
226 causation_id: Option<String>,
227 out: &mut EffectSink,
228 ) -> Result<(), ImError> {
229 self.refresh_attached_latest_timeline_with_deferred_send(
230 channel_id,
231 causation_id,
232 None,
233 out,
234 )
235 .map(|_| ())
236 }
237
238 pub(crate) fn refresh_attached_timeline(
240 &mut self,
241 channel_id: ChannelId,
242 window_token: &str,
243 causation_id: Option<String>,
244 out: &mut EffectSink,
245 ) -> Result<(), ImError> {
246 self.refresh_attached_timeline_window_with_deferred_send(
247 channel_id,
248 window_token,
249 causation_id,
250 None,
251 out,
252 )
253 .map(|_| ())
254 }
255
256 pub(crate) fn refresh_attached_latest_timeline_with_deferred_send(
258 &mut self,
259 channel_id: ChannelId,
260 causation_id: Option<String>,
261 deferred_send_http: Option<crate::state::TemporaryId>,
262 out: &mut EffectSink,
263 ) -> Result<bool, ImError> {
264 let Some((window_token, _)) = self
265 .state
266 .timeline_state
267 .unique_attached_window_for_channel(channel_id.as_str())
268 else {
269 return Ok(false);
270 };
271 self.refresh_attached_timeline_window_with_deferred_send(
272 channel_id,
273 window_token.as_str(),
274 causation_id,
275 deferred_send_http,
276 out,
277 )
278 }
279
280 fn refresh_attached_timeline_window_with_deferred_send(
282 &mut self,
283 channel_id: ChannelId,
284 window_token: &str,
285 causation_id: Option<String>,
286 deferred_send_http: Option<crate::state::TemporaryId>,
287 out: &mut EffectSink,
288 ) -> Result<bool, ImError> {
289 let scope = crate::timeline_state::TimelineScope {
290 channel_id: channel_id.as_str().to_string(),
291 window_token: window_token.to_string(),
292 };
293 if !self.state.timeline_state.is_attached(&scope) {
294 return Ok(false);
295 }
296 let visible_limit = self
297 .state
298 .timeline_state
299 .current_view(&scope)
300 .map(|view| view.items.len())
301 .filter(|visible| *visible > 0)
302 .and_then(|visible| u32::try_from(visible).ok())
303 .map(|visible| {
304 visible
305 .saturating_add(1)
306 .min(crate::timeline_state::MAX_TIMELINE_WINDOW_ITEMS as u32)
307 })
308 .unwrap_or(super::QUERY_MESSAGES_DEFAULT);
309 let payload = serde_json::json!({
310 "channel_id": channel_id.as_str(),
311 "window_token": window_token,
312 "limit": visible_limit,
313 });
314 let bytes =
315 serde_json::to_vec(&payload) .map_err(|error| {
317 ImError::Serialize(format!("attached timeline refresh: {error}"))
318 })?;
319 self.dispatch_message_query_with_causation_and_deferred_send(
320 &bytes,
321 false,
322 causation_id,
323 deferred_send_http,
324 out,
325 )?;
326 Ok(true)
327 }
328
329 pub(crate) fn dispatch_message_query(
331 &mut self,
332 payload: &[u8],
333 out: &mut EffectSink,
334 ) -> Result<(), ImError> {
335 self.dispatch_message_query_with_causation_and_deferred_send(payload, true, None, None, out)
336 }
337
338 fn dispatch_message_query_with_causation_and_deferred_send(
340 &mut self,
341 payload: &[u8],
342 allow_remote_fallback: bool,
343 causation_id: Option<String>,
344 deferred_send_http: Option<crate::state::TemporaryId>,
345 out: &mut EffectSink,
346 ) -> Result<(), ImError> {
347 let request = super::parse_message_query(payload)?;
348 let query_generation = self
349 .state
350 .begin_message_query_generation(request.channel_id, request.window_token.as_str());
351 let corr = self.alloc_corr_internal();
352 out.push(super::build_message_query_from_request(&request, corr));
353 self.state.corr_map.insert(
354 corr,
355 CorrelationContext::MessageQueryLocal {
356 request: Box::new(request),
357 query_session_epoch: self.state.query_session_epoch,
358 query_generation,
359 allow_remote_fallback,
360 causation_id,
361 deferred_send_http,
362 },
363 );
364 Ok(())
365 }
366
367 pub(crate) fn handle_message_query_local_reply(
369 &mut self,
370 request: MessageQueryRequest,
371 query_session_epoch: u64,
372 query_generation: u64,
373 allow_remote_fallback: bool,
374 causation_id: Option<String>,
375 deferred_send_http: Option<crate::state::TemporaryId>,
376 now_ms: u64,
377 outcome: &PortOutcome,
378 out: &mut EffectSink,
379 ) -> Result<(), ImError> {
380 if !self.is_current_message_query(
381 request.channel_id,
382 &request.window_token,
383 query_session_epoch,
384 query_generation,
385 ) {
386 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
389 return Ok(());
390 }
391 let mut local_rows_desc = match outcome {
392 PortOutcome::Ok(reply) => match parse_local_rows(reply.0.as_ref()) {
393 Ok(rows) => rows,
394 Err(error) => {
395 tracing::warn!(
396 channel_id = request.channel_id.as_str(),
397 error = ?error,
398 allow_remote_fallback,
399 "message query local scan reply malformed"
400 );
401 if !allow_remote_fallback {
402 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
403 self.emit_deferred_posts_create_after_timeline_event(
404 deferred_send_http,
405 out,
406 )?;
407 return Ok(());
408 }
409 Vec::new()
410 }
411 },
412 PortOutcome::Err(error) => {
413 tracing::warn!(
414 channel_id = request.channel_id.as_str(),
415 error = ?error,
416 allow_remote_fallback,
417 "message query local scan failed"
418 );
419 if !allow_remote_fallback {
420 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
421 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
422 return Ok(());
423 }
424 Vec::new()
425 }
426 };
427 sort_recent_rows_desc(&mut local_rows_desc);
428
429 if !allow_remote_fallback {
430 out.push(self.emit_timeline_snapshot_with_causation(
431 &request,
432 &local_rows_desc,
433 now_ms,
434 causation_id,
435 None,
436 )?);
437 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
438 return Ok(());
439 }
440
441 let coverage = classify_local_read(
442 self.local_store_mode,
443 &request,
444 &local_rows_desc,
445 self.state.recent_message_coverage.get(&request.channel_id),
446 self.message_query_has_known_gap(request.channel_id),
447 );
448 tracing::debug!(
449 channel_id = request.channel_id.as_str(),
450 ?coverage,
451 local_rows = local_rows_desc.len(),
452 "message query local coverage classified"
453 );
454
455 if coverage == LocalReadCoverage::Complete {
456 out.push(self.emit_timeline_snapshot_with_causation(
457 &request,
458 &local_rows_desc,
459 now_ms,
460 causation_id,
461 None,
462 )?);
463 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
464 return Ok(());
465 }
466 self.start_remote_message_query(
467 request,
468 local_rows_desc,
469 query_generation,
470 causation_id,
471 deferred_send_http,
472 out,
473 )
474 }
475
476 pub(crate) fn handle_message_query_remote_reply(
478 &mut self,
479 request: MessageQueryRequest,
480 local_rows_desc: Vec<Value>,
481 query_session_epoch: u64,
482 query_generation: u64,
483 causation_id: Option<String>,
484 deferred_send_http: Option<crate::state::TemporaryId>,
485 now_ms: u64,
486 outcome: &PortOutcome,
487 out: &mut EffectSink,
488 ) -> Result<(), ImError> {
489 let current_query = self.is_current_message_query(
490 request.channel_id,
491 &request.window_token,
492 query_session_epoch,
493 query_generation,
494 );
495 if !current_query {
496 return Ok(());
497 }
498 let reply = match outcome {
499 PortOutcome::Ok(reply) => reply,
500 PortOutcome::Err(error) => {
501 tracing::warn!(
502 channel_id = request.channel_id.as_str(),
503 error = ?error,
504 "message query remote fallback failed"
505 );
506 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
507 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
508 return Ok(());
509 }
510 };
511
512 let remote_posts = match parse_latest_posts_reply(reply) {
513 Ok(posts) => posts,
514 Err(error) => {
515 tracing::warn!(
516 channel_id = request.channel_id.as_str(),
517 error = ?error,
518 "message query remote fallback returned invalid response"
519 );
520 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
521 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
522 return Ok(());
523 }
524 };
525 let received_count = remote_posts.len();
526 let (mut remote_rows_desc, mut cache_ops) = match visible_remote_rows_and_cache_ops(
527 request.channel_id,
528 remote_posts,
529 &local_rows_desc,
530 self.config.auth_user_id.as_str(),
531 ) {
532 Ok(result) => result,
533 Err(error) => {
534 tracing::warn!(
535 channel_id = request.channel_id.as_str(),
536 error = ?error,
537 "message query remote fallback contained invalid posts"
538 );
539 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
540 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
541 return Ok(());
542 }
543 };
544 dedup_recent_rows(&mut remote_rows_desc);
545 sort_recent_rows_desc(&mut remote_rows_desc);
546
547 let remote_exhausted = recent_reply_proves_history_exhausted(
548 &local_rows_desc,
549 &remote_rows_desc,
550 received_count,
551 );
552 let coverage = RecentMessageCoverage::from_remote(&remote_rows_desc, remote_exhausted);
553 if remote_exhausted {
554 if let Some(delete) = stale_local_server_rows_delete_op(
555 request.channel_id,
556 &local_rows_desc,
557 &remote_rows_desc,
558 ) {
559 cache_ops.push(delete);
560 }
561 }
562 let corr = self.alloc_corr_internal();
563 out.push(Effect::Persist {
564 corr,
565 ops: cache_ops,
566 });
567 self.state.corr_map.insert(
568 corr,
569 CorrelationContext::MessageQueryCache {
570 request: Box::new(request),
571 coverage,
572 query_session_epoch,
573 query_generation,
574 causation_id,
575 deferred_send_http,
576 },
577 );
578 Ok(())
579 }
580
581 pub(crate) fn handle_message_query_cache_reply(
583 &mut self,
584 request: MessageQueryRequest,
585 coverage: Option<RecentMessageCoverage>,
586 query_session_epoch: u64,
587 query_generation: u64,
588 causation_id: Option<String>,
589 deferred_send_http: Option<crate::state::TemporaryId>,
590 now_ms: u64,
591 outcome: &PortOutcome,
592 out: &mut EffectSink,
593 ) -> Result<(), ImError> {
594 if !self.is_current_message_query(
595 request.channel_id,
596 &request.window_token,
597 query_session_epoch,
598 query_generation,
599 ) {
600 return Ok(());
601 }
602 if let PortOutcome::Err(error) = outcome {
603 tracing::warn!(
604 channel_id = request.channel_id.as_str(),
605 error = ?error,
606 "message query remote cache failed; preserving previous timeline"
607 );
608 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
609 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
610 return Ok(());
611 }
612
613 let corr = self.alloc_corr_internal();
615 out.push(Effect::Persist {
616 corr,
617 ops: vec![helix_core::effect::StorageOp::Scan(
618 super::message_scan_spec(&request),
619 )],
620 });
621 self.state.corr_map.insert(
622 corr,
623 CorrelationContext::MessageQueryReadback {
624 request: Box::new(request),
625 coverage,
626 query_session_epoch,
627 query_generation,
628 causation_id,
629 deferred_send_http,
630 },
631 );
632 Ok(())
633 }
634
635 pub(crate) fn handle_message_query_readback_reply(
637 &mut self,
638 request: MessageQueryRequest,
639 coverage: Option<RecentMessageCoverage>,
640 query_session_epoch: u64,
641 query_generation: u64,
642 causation_id: Option<String>,
643 deferred_send_http: Option<crate::state::TemporaryId>,
644 now_ms: u64,
645 outcome: &PortOutcome,
646 out: &mut EffectSink,
647 ) -> Result<(), ImError> {
648 if !self.is_current_message_query(
649 request.channel_id,
650 &request.window_token,
651 query_session_epoch,
652 query_generation,
653 ) {
654 return Ok(());
655 }
656 let mut rows_desc = match outcome {
657 PortOutcome::Ok(reply) => match parse_local_rows(reply.0.as_ref()) {
658 Ok(rows) => rows,
659 Err(error) => {
660 tracing::warn!(
661 channel_id = request.channel_id.as_str(),
662 error = ?error,
663 "message query durable read-back malformed"
664 );
665 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
666 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
667 return Ok(());
668 }
669 },
670 PortOutcome::Err(error) => {
671 tracing::warn!(
672 channel_id = request.channel_id.as_str(),
673 error = ?error,
674 "message query durable read-back failed; preserving previous timeline"
675 );
676 out.push(self.emit_timeline_failed(&request, now_ms, causation_id)?);
677 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
678 return Ok(());
679 }
680 };
681 sort_recent_rows_desc(&mut rows_desc);
682 if let Some(coverage) = coverage {
683 self.state
684 .recent_message_coverage
685 .insert(request.channel_id, coverage);
686 }
687 out.push(self.emit_timeline_snapshot_with_causation(
688 &request,
689 &rows_desc,
690 now_ms,
691 causation_id,
692 None,
693 )?);
694 self.emit_deferred_posts_create_after_timeline_event(deferred_send_http, out)?;
695 Ok(())
696 }
697
698 fn start_remote_message_query(
700 &mut self,
701 request: MessageQueryRequest,
702 local_rows_desc: Vec<Value>,
703 query_generation: u64,
704 causation_id: Option<String>,
705 deferred_send_http: Option<crate::state::TemporaryId>,
706 out: &mut EffectSink,
707 ) -> Result<(), ImError> {
708 let corr = self.alloc_corr_internal();
709 let payload = serde_json::to_vec(&serde_json::json!({
710 "channel_id": request.channel_id.as_str(),
711 "timestamp": 0,
712 "cursor_version": 1,
713 "page_size": request.limit,
714 }))
715 .map_err(|error| ImError::Serialize(error.to_string()))?;
716 let mut effects = crate::commands::handle_outbound(
717 "im_get_latest_post",
718 &payload,
719 self.config.api_base_url.as_str(),
720 self.config.default_api_base_url.as_str(),
721 self.state.connection_id.as_deref(),
722 corr,
723 )?;
724 if effects.len() != 1 {
725 return Err(ImError::Parse(format!(
726 "im_get_latest_post expected one HTTP effect, got {}",
727 effects.len()
728 )));
729 }
730 let effect = effects
731 .pop()
732 .ok_or_else(|| ImError::Parse("im_get_latest_post produced no effect".to_string()))?;
733 if !matches!(&effect, Effect::Http { .. }) {
734 return Err(ImError::Parse(
735 "im_get_latest_post did not produce Effect::Http".to_string(),
736 ));
737 }
738 out.push(effect);
739 self.state.corr_map.insert(
740 corr,
741 CorrelationContext::MessageQueryRemote {
742 request: Box::new(request),
743 local_rows_desc: Box::new(local_rows_desc),
744 query_session_epoch: self.state.query_session_epoch,
745 query_generation,
746 causation_id,
747 deferred_send_http,
748 },
749 );
750 Ok(())
751 }
752
753 fn emit_deferred_posts_create_after_timeline_event(
754 &mut self,
755 deferred_send_http: Option<crate::state::TemporaryId>,
756 out: &mut EffectSink,
757 ) -> Result<(), ImError> {
758 let Some(temporary_id) = deferred_send_http else {
759 return Ok(());
760 };
761 let (channel_id, body) = self
762 .state
763 .pending_sends
764 .get(&temporary_id)
765 .and_then(|pending| {
766 pending.body.as_ref().and_then(|body| {
767 body.get("channelId")
768 .and_then(serde_json::Value::as_str)
769 .and_then(ChannelId::from_str)
770 .map(|channel_id| (channel_id, body.clone()))
771 })
772 })
773 .ok_or_else(|| {
774 ImError::Parse(format!(
775 "deferred posts/create missing pending send body: {}",
776 temporary_id.0
777 ))
778 })?;
779 self.emit_posts_create_http(channel_id, temporary_id, &body, out)
780 }
781
782 fn message_query_has_known_gap(&self, channel_id: ChannelId) -> bool {
783 let Some(channel) = self.state.channels.get(&channel_id) else {
784 return false;
785 };
786 let behind_target = self
787 .state
788 .increment_target
789 .get(&channel_id)
790 .is_some_and(|target| channel.cursor.value() < *target);
791 behind_target || channel.inflight_sync.is_some() || !channel.buffer.is_empty()
792 }
793
794 fn is_current_message_query(
795 &self,
796 channel_id: ChannelId,
797 window_token: &str,
798 query_session_epoch: u64,
799 query_generation: u64,
800 ) -> bool {
801 query_session_epoch == self.state.query_session_epoch
802 && self.state.is_current_message_query_generation(
803 channel_id,
804 window_token,
805 query_generation,
806 )
807 }
808
809 fn observe_rows_create_at(&mut self, channel_id: ChannelId, rows: &[Value]) {
814 let newest = rows
815 .iter()
816 .filter(|row| {
817 ["id", "msgId", "postId", "post_id"].iter().any(|key| {
818 row.get(*key)
819 .and_then(Value::as_str)
820 .is_some_and(|value| !value.is_empty())
821 })
822 })
823 .filter_map(|row| {
824 row.get("create_at")
825 .or_else(|| row.get("createAt"))
826 .or_else(|| row.get("createdAt"))
827 .and_then(Value::as_i64)
828 })
829 .max();
830 if let Some(create_at) = newest {
831 self.state.observe_channel_create_at(channel_id, create_at);
832 }
833 }
834
835 pub(crate) fn emit_timeline_snapshot_with_causation(
838 &mut self,
839 request: &MessageQueryRequest,
840 rows_desc: &[Value],
841 _now_ms: u64,
842 causation_id: Option<String>,
843 page_override: Option<crate::timeline_state::WindowPage>,
844 ) -> Result<Effect, ImError> {
845 self.observe_rows_create_at(request.channel_id, rows_desc);
848 let scope = crate::timeline_state::TimelineScope {
849 channel_id: request.channel_id.as_str().to_string(),
850 window_token: request.window_token.to_string(),
851 };
852 let current_view = self.state.timeline_state.current_view(&scope);
853 let anchored_window = current_view.is_some_and(|view| {
854 view.anchor.mode == crate::timeline_state::TimelineAnchorMode::Locate
855 });
856 let anchored_create_at = current_view.and_then(|view| {
857 let anchor_id = view.anchor.message_id.as_deref()?;
858 view.items
859 .iter()
860 .find(|item| item.id == anchor_id)
861 .map(|item| item.created_at)
862 });
863 let anchored_page_bounds = current_view
864 .filter(|_| anchored_window)
865 .map(|view| (view.page.has_older, view.page.has_newer, view.page.has_more));
866 let had_attached_window = current_view.is_some();
867 let visible_len = current_view
868 .filter(|view| view.page.has_older)
869 .map_or(request.limit as usize, |view| view.items.len());
870 let has_local_older = rows_desc.len() > visible_len;
871 let rows_asc = Value::Array(rows_desc.iter().take(visible_len).rev().cloned().collect());
872 let shaped = crate::render_ready::shape_message_rows_for_viewer(
873 &rows_asc,
874 self.config.auth_user_id.as_str(),
875 );
876 let rows = shaped.as_array().ok_or_else(|| {
877 ImError::Parse("render-ready timeline rows must be an array".to_string())
878 })?;
879 let mut timeline_request =
880 crate::timeline_state::TimelineWindowRequest::latest_with_window_token(
881 request.channel_id.as_str(),
882 request.window_token.as_str(),
883 );
884 timeline_request.page_size = visible_len as u32;
885 if let Some(page) = page_override {
886 timeline_request.page = page;
887 } else if let Some((has_older, has_newer, has_more)) = anchored_page_bounds {
888 timeline_request.page.has_older = has_older;
889 timeline_request.page.has_newer = has_newer;
890 timeline_request.page.has_more = has_more;
891 } else if has_local_older
892 || self
893 .state
894 .recent_message_coverage
895 .get(&request.channel_id)
896 .is_some_and(|coverage| !coverage.remote_exhausted)
897 {
898 timeline_request.page.has_older = true;
903 timeline_request.page.has_more = true;
904 }
905 let has_older = timeline_request.page.has_older;
906 let has_newer = timeline_request.page.has_newer;
907 if anchored_window && timeline_request.target_message_id.is_none() {
908 self.state.timeline_state.patch_page_from_render_ready(
910 timeline_request,
911 rows,
912 crate::timeline_state::TimelinePageMutation::Newer,
913 causation_id,
914 )
915 } else if self.state.timeline_state.current_view(&scope).is_some() {
916 self.state
917 .timeline_state
918 .patch_from_render_ready(timeline_request, rows, causation_id)
919 } else {
920 self.state
921 .timeline_state
922 .snapshot_from_render_ready_with_causation(timeline_request, rows, causation_id)
923 }
924 .map_err(|error| ImError::Parse(format!("timeline state: {error}")))?;
925 let event_rows = rows.to_vec();
927 let anchor_post_id = self
928 .state
929 .timeline_state
930 .current_view(&scope)
931 .and_then(|view| view.anchor.message_id.as_deref());
932 let effect = if !had_attached_window {
933 crate::event::timeline::window(
934 request.channel_id.as_str(),
935 request.window_token.as_str(),
936 "ready",
937 event_rows,
938 has_older,
939 has_newer,
940 None,
941 )?
942 } else if anchored_window {
943 let anchor_create_at = event_rows
944 .iter()
945 .find(|row| {
946 row.get("id")
947 .or_else(|| row.get("msgId"))
948 .or_else(|| row.get("temporaryId"))
949 .and_then(Value::as_str)
950 == anchor_post_id
951 })
952 .and_then(|row| {
953 row.get("createAt")
954 .or_else(|| row.get("createdAt"))
955 .or_else(|| row.get("create_at"))
956 })
957 .and_then(Value::as_i64)
958 .or(anchored_create_at);
959 let newer_count = event_rows
960 .iter()
961 .filter(|row| {
962 let create_at = row
963 .get("createAt")
964 .or_else(|| row.get("createdAt"))
965 .or_else(|| row.get("create_at"))
966 .and_then(Value::as_i64);
967 create_at.zip(anchor_create_at).is_some_and(
968 |(message_create_at, anchor_create_at)| {
969 message_create_at > anchor_create_at
970 },
971 )
972 })
973 .count();
974 crate::event::timeline::anchored_update(
975 request.channel_id.as_str(),
976 request.window_token.as_str(),
977 "ready",
978 event_rows,
979 has_older,
980 has_newer,
981 anchor_post_id,
982 newer_count,
983 )?
984 } else {
985 crate::event::timeline::page(
986 request.channel_id.as_str(),
987 request.window_token.as_str(),
988 "append",
989 "ready",
990 event_rows,
991 has_older,
992 has_newer,
993 anchor_post_id,
994 )?
995 }
996 .into_effect();
997 Ok(effect)
998 }
999
1000 pub(crate) fn emit_timeline_navigation_page(
1002 &mut self,
1003 state: &crate::timeline_navigation::TimelineNavigationState,
1004 _now_ms: u64,
1005 ) -> Result<Effect, ImError> {
1006 self.observe_rows_create_at(state.channel_id(), state.rows());
1007 let rows = Value::Array(state.rows().to_vec());
1008 let shaped = crate::render_ready::shape_message_rows_for_viewer(
1009 &rows,
1010 self.config.auth_user_id.as_str(),
1011 );
1012 let rows = shaped.as_array().ok_or_else(|| {
1013 ImError::Parse("timeline navigation rows must shape to array".to_string())
1014 })?;
1015 let render_rows = rows.to_vec();
1017 let mut request = crate::timeline_state::TimelineWindowRequest::latest_with_window_token(
1018 state.channel_id().as_str(),
1019 state.window_token(),
1020 );
1021 request.page_size = state.page_size();
1022 request.page = state.page();
1023 let locate_is_current = match state.kind() {
1024 crate::timeline_navigation::TimelineNavigationKind::Locate {
1025 navigation_token, ..
1026 } => self.state.timeline_state.is_current_locate_navigation(
1027 state.channel_id().as_str(),
1028 state.window_token(),
1029 navigation_token,
1030 ),
1031 _ => true,
1032 };
1033 let page_mutation = match state.kind() {
1034 crate::timeline_navigation::TimelineNavigationKind::Older { .. } => {
1035 crate::timeline_state::TimelinePageMutation::Older
1036 }
1037 crate::timeline_navigation::TimelineNavigationKind::Newer { .. } => {
1038 crate::timeline_state::TimelinePageMutation::Newer
1039 }
1040 crate::timeline_navigation::TimelineNavigationKind::Locate {
1041 target_message_id,
1042 navigation_token,
1043 } => crate::timeline_state::TimelinePageMutation::Locate {
1044 target_message_id: target_message_id.to_string(),
1045 navigation_token: navigation_token.to_string(),
1046 activate: locate_is_current,
1047 },
1048 };
1049 self.state
1050 .timeline_state
1051 .patch_page_from_render_ready(
1052 request,
1053 rows,
1054 page_mutation,
1055 state.request_id().map(str::to_string),
1056 )
1057 .map_err(|error| ImError::Parse(format!("timeline navigation projection: {error}")))?;
1058 let page_direction = match state.kind() {
1059 crate::timeline_navigation::TimelineNavigationKind::Older {
1060 anchor_post_id, ..
1061 } => Some(("older", anchor_post_id.as_str())),
1062 crate::timeline_navigation::TimelineNavigationKind::Newer {
1063 anchor_post_id, ..
1064 } => Some(("newer", anchor_post_id.as_str())),
1065 crate::timeline_navigation::TimelineNavigationKind::Locate { .. } => None,
1066 };
1067 if let Some((direction, anchor_post_id)) = page_direction {
1068 let page = state.page();
1069 let messages = render_rows
1070 .iter()
1071 .filter(|row| {
1072 row.get("id")
1073 .or_else(|| row.get("msgId"))
1074 .or_else(|| row.get("temporaryId"))
1075 .and_then(Value::as_str)
1076 != Some(anchor_post_id)
1077 })
1078 .cloned()
1079 .collect();
1080 return Ok(crate::event::timeline::page(
1081 state.channel_id().as_str(),
1082 state.window_token(),
1083 direction,
1084 "ready",
1085 messages,
1086 page.has_older,
1087 page.has_newer,
1088 Some(anchor_post_id),
1089 )?
1090 .into_effect());
1091 }
1092 let crate::timeline_navigation::TimelineNavigationKind::Locate {
1093 target_message_id,
1094 navigation_token,
1095 } = state.kind()
1096 else {
1097 return Err(ImError::Parse(
1098 "timeline navigation kind changed after page dispatch".to_string(),
1099 ));
1100 };
1101 let page = state.page();
1102 if !locate_is_current {
1103 return Ok(crate::event::timeline::page(
1104 state.channel_id().as_str(),
1105 state.window_token(),
1106 "merge",
1107 "stale",
1108 render_rows,
1109 page.has_older,
1110 page.has_newer,
1111 Some(target_message_id),
1112 )?
1113 .into_effect());
1114 }
1115 Ok(crate::event::timeline::located(serde_json::json!({
1116 "channelId": state.channel_id().as_str(),
1117 "windowToken": state.window_token(),
1118 "state": "ready",
1119 "messages": render_rows,
1120 "hasOlder": page.has_older,
1121 "hasNewer": page.has_newer,
1122 "targetMessageId": target_message_id,
1123 "anchorPostId": target_message_id,
1124 "revealPostId": target_message_id,
1125 "navigationToken": navigation_token,
1126 }))?
1127 .into_effect())
1128 }
1129
1130 fn emit_timeline_failed(
1131 &mut self,
1132 request: &MessageQueryRequest,
1133 _now_ms: u64,
1134 _causation_id: Option<String>,
1135 ) -> Result<Effect, ImError> {
1136 Ok(crate::event::timeline::window(
1137 request.channel_id.as_str(),
1138 request.window_token.as_str(),
1139 "failed",
1140 Vec::new(),
1141 false,
1142 false,
1143 None,
1144 )?
1145 .into_effect())
1146 }
1147}