1use super::{Bookmark, PostCounts, PostDetails, PostView};
2use crate::db::kv::{ScoreAction, SortOrder};
3use crate::db::{get_neo4j_graph, queries, RedisOps};
4use crate::models::{
5 follow::{Followers, Following, Friends, UserFollows},
6 post::search::PostsByTagSearch,
7};
8use crate::types::{DynError, Pagination, StreamSorting};
9use pubky_app_specs::PubkyAppPostKind;
10use serde::{Deserialize, Serialize};
11use tokio::task::spawn;
12use tokio::time::{timeout, Duration};
13use utoipa::ToSchema;
14
15pub const POST_TIMELINE_KEY_PARTS: [&str; 3] = ["Posts", "Global", "Timeline"];
16pub const POST_TOTAL_ENGAGEMENT_KEY_PARTS: [&str; 3] = ["Posts", "Global", "TotalEngagement"];
17pub const POST_PER_USER_KEY_PARTS: [&str; 2] = ["Posts", "AuthorParents"];
18pub const POST_REPLIES_PER_USER_KEY_PARTS: [&str; 2] = ["Posts", "AuthorReplies"];
19pub const POST_REPLIES_PER_POST_KEY_PARTS: [&str; 2] = ["Posts", "PostReplies"];
20const BOOKMARKS_USER_KEY_PARTS: [&str; 2] = ["Bookmarks", "User"];
21
22#[derive(ToSchema, Deserialize, Debug, Clone, PartialEq, Default)]
23#[serde(tag = "source", rename_all = "snake_case")]
24pub enum StreamSource {
25 PostReplies {
26 post_id: String,
27 author_id: String,
28 },
29 Following {
30 observer_id: String,
31 },
32 Followers {
33 observer_id: String,
34 },
35 Friends {
36 observer_id: String,
37 },
38 Bookmarks {
39 observer_id: String,
40 },
41 Author {
42 author_id: String,
43 },
44 AuthorReplies {
45 author_id: String,
46 },
47 #[default]
48 All,
49}
50
51impl StreamSource {
52 pub fn get_observer(&self) -> Option<&String> {
53 match self {
54 StreamSource::Followers { observer_id }
55 | StreamSource::Following { observer_id }
56 | StreamSource::Friends { observer_id }
57 | StreamSource::Bookmarks { observer_id } => Some(observer_id),
58 _ => None,
59 }
60 }
61
62 pub fn get_author(&self) -> Option<&String> {
63 match self {
64 StreamSource::PostReplies {
65 author_id,
66 post_id: _,
67 } => Some(author_id),
68 StreamSource::Author { author_id } => Some(author_id),
69 StreamSource::AuthorReplies { author_id } => Some(author_id),
70 _ => None,
71 }
72 }
73}
74
75#[derive(Serialize, Deserialize, ToSchema, Debug, Default)]
76pub struct PostStream(pub Vec<PostView>);
77
78impl RedisOps for PostStream {}
79
80impl PostStream {
81 pub fn extend(&mut self, post_stream: PostStream) {
82 self.0.extend(post_stream.0);
83 }
84 pub async fn get_posts(
85 source: StreamSource,
86 pagination: Pagination,
87 order: SortOrder,
88 sorting: StreamSorting,
89 viewer_id: Option<String>,
90 tags: Option<Vec<String>>,
91 kind: Option<PubkyAppPostKind>,
92 ) -> Result<Option<Self>, DynError> {
93 let use_index = Self::can_use_index(&sorting, &source, &tags, &kind);
95
96 let post_keys = match use_index {
97 true => Self::get_from_index(source, sorting, order, &tags, pagination).await?,
98 false => Self::get_from_graph(source, sorting, &tags, pagination, kind).await?,
99 };
100
101 if post_keys.is_empty() {
102 return Ok(None);
103 }
104
105 Self::from_listed_post_ids(viewer_id, &post_keys).await
106 }
107
108 fn can_use_index(
110 sorting: &StreamSorting,
111 source: &StreamSource,
112 tags: &Option<Vec<String>>,
113 kind: &Option<PubkyAppPostKind>,
114 ) -> bool {
115 if kind.is_some() {
116 return false;
117 }
118 match (sorting, source, tags) {
119 (StreamSorting::Timeline, StreamSource::Author { .. }, None) => true,
121 (_, StreamSource::All, None) => true,
123 (_, StreamSource::All, Some(tags)) if tags.len() == 1 => true,
125 (StreamSorting::Timeline, StreamSource::Following { .. }, None) => true,
127 (StreamSorting::Timeline, StreamSource::Followers { .. }, None) => true,
128 (StreamSorting::Timeline, StreamSource::Friends { .. }, None) => true,
129 (StreamSorting::Timeline, StreamSource::Bookmarks { .. }, None) => true,
131 (_, StreamSource::PostReplies { .. }, _) => true,
133 (_, StreamSource::AuthorReplies { .. }, _) => true,
135 _ => false,
137 }
138 }
139
140 async fn get_from_index(
142 source: StreamSource,
143 sorting: StreamSorting,
144 order: SortOrder,
145 tags: &Option<Vec<String>>,
146 pagination: Pagination,
147 ) -> Result<Vec<String>, DynError> {
148 let start = pagination.start;
149 let end = pagination.end;
150 let skip = pagination.skip;
151 let limit = pagination.limit;
152
153 match (source, tags) {
154 (StreamSource::All, None) => {
156 Self::get_global_posts_keys(sorting, order, start, end, skip, limit).await
157 }
158 (StreamSource::All, Some(tags)) if tags.len() == 1 => {
160 Self::get_posts_keys_by_tag(&tags[0], sorting, start, end, skip, limit).await
161 }
162 (StreamSource::Bookmarks { observer_id }, None) => {
164 Self::get_bookmarked_posts(&observer_id, order, start, end, skip, limit).await
165 }
166 (StreamSource::PostReplies { author_id, post_id }, None) => {
168 Self::get_post_replies(&author_id, &post_id, order, start, end, skip, limit).await
169 }
170 (StreamSource::Author { author_id }, None) => {
172 Self::get_author_posts(&author_id, order, start, end, skip, limit, false).await
173 }
174 (StreamSource::AuthorReplies { author_id }, None) => {
176 Self::get_author_posts(&author_id, order, start, end, skip, limit, true).await
177 }
178 (source, None) => {
180 Self::get_posts_by_source(source, order, start, end, skip, limit).await
181 }
182 _ => Ok(vec![]),
183 }
184 }
185
186 async fn get_from_graph(
188 source: StreamSource,
189 sorting: StreamSorting,
190 tags: &Option<Vec<String>>,
191 pagination: Pagination,
192 kind: Option<PubkyAppPostKind>,
193 ) -> Result<Vec<String>, DynError> {
194 let mut result;
195 {
196 let graph = get_neo4j_graph()?;
197 let query = queries::get::post_stream(source, sorting, tags, pagination, kind);
198
199 let graph = graph.lock().await;
200
201 result = match timeout(Duration::from_secs(10), graph.execute(query)).await {
203 Ok(Ok(res)) => res, Ok(Err(e)) => return Err(Box::new(e)), Err(_) => return Err("Query timed out".into()), };
207 }
208
209 let mut post_keys = Vec::new();
210
211 while let Some(row) = result.next().await? {
212 let author_id: String = row.get("author_id")?;
213 let post_id: String = row.get("post_id")?;
214 post_keys.push(format!("{author_id}:{post_id}"));
215 }
216
217 Ok(post_keys)
218 }
219
220 pub async fn get_global_posts_keys(
221 sorting: StreamSorting,
222 order: SortOrder,
223 start: Option<f64>,
224 end: Option<f64>,
225 skip: Option<usize>,
226 limit: Option<usize>,
227 ) -> Result<Vec<String>, DynError> {
228 let sorted_set = match sorting {
229 StreamSorting::TotalEngagement => {
230 Self::try_from_index_sorted_set(
231 &POST_TOTAL_ENGAGEMENT_KEY_PARTS,
232 start,
233 end,
234 skip,
235 limit,
236 order,
237 None,
238 )
239 .await?
240 }
241 StreamSorting::Timeline => {
242 Self::try_from_index_sorted_set(
243 &POST_TIMELINE_KEY_PARTS,
244 start,
245 end,
246 skip,
247 limit,
248 order,
249 None,
250 )
251 .await?
252 }
253 };
254 match sorted_set {
255 Some(post_keys) => Ok(post_keys.into_iter().map(|(key, _)| key).collect()),
256 None => Ok(vec![]),
258 }
259 }
260
261 pub async fn get_posts_keys_by_tag(
262 label: &str,
263 sorting: StreamSorting,
264 start: Option<f64>,
265 end: Option<f64>,
266 skip: Option<usize>,
267 limit: Option<usize>,
268 ) -> Result<Vec<String>, DynError> {
269 let skip = skip.unwrap_or(0);
270 let limit = limit.unwrap_or(10);
271
272 let pag = Pagination {
273 start,
274 end,
275 skip: Some(skip),
276 limit: Some(limit),
277 };
278
279 let post_search_result = PostsByTagSearch::get_by_label(label, Some(sorting), pag).await?;
280
281 match post_search_result {
282 Some(post_keys) => Ok(post_keys
283 .into_iter()
284 .map(|post_score| post_score.post_key)
285 .collect()),
286 None => Ok(vec![]),
287 }
288 }
289
290 pub async fn get_author_posts(
291 user_id: &str,
292 order: SortOrder,
293 start: Option<f64>,
294 end: Option<f64>,
295 skip: Option<usize>,
296 limit: Option<usize>,
297 replies: bool,
298 ) -> Result<Vec<String>, DynError> {
299 let key_parts = match replies {
301 true => POST_REPLIES_PER_USER_KEY_PARTS,
302 false => POST_PER_USER_KEY_PARTS,
303 };
304
305 let key_parts = [&key_parts[..], &[user_id]].concat();
306 let post_ids =
307 Self::try_from_index_sorted_set(&key_parts, start, end, skip, limit, order, None)
308 .await?;
309
310 if let Some(post_ids) = post_ids {
311 let post_keys = post_ids
312 .into_iter()
313 .map(|(post_id, _)| format!("{user_id}:{post_id}"))
314 .collect();
315 Ok(post_keys)
316 } else {
317 Ok(vec![])
318 }
319 }
320
321 pub async fn get_posts_by_source(
322 source: StreamSource,
323 order: SortOrder,
324 start: Option<f64>,
325 end: Option<f64>,
326 skip: Option<usize>,
327 limit: Option<usize>,
328 ) -> Result<Vec<String>, DynError> {
329 let custom_limit = Some(200);
330 let mut user_ids = match &source {
331 StreamSource::Following { observer_id } => {
332 Following::get_by_id(observer_id, None, custom_limit)
333 .await?
334 .unwrap_or_default()
335 .0
336 }
337 StreamSource::Followers { observer_id } => {
338 Followers::get_by_id(observer_id, None, custom_limit)
339 .await?
340 .unwrap_or_default()
341 .0
342 }
343 StreamSource::Friends { observer_id } => {
344 Friends::get_by_id(observer_id, None, custom_limit)
345 .await?
346 .unwrap_or_default()
347 .0
348 }
349 _ => vec![],
350 };
351
352 if !user_ids.is_empty() {
353 if let Some(observer_id) = source.get_observer() {
355 user_ids.push(observer_id.to_string());
356 }
357
358 let post_keys = Self::get_posts_for_user_ids(
359 &user_ids.iter().map(AsRef::as_ref).collect::<Vec<_>>(),
360 order,
361 start,
362 end,
363 skip,
364 limit,
365 )
366 .await?;
367 Ok(post_keys)
368 } else {
369 Ok(vec![])
370 }
371 }
372
373 pub async fn get_bookmarked_posts(
374 user_id: &str,
375 order: SortOrder,
376 start: Option<f64>,
377 end: Option<f64>,
378 skip: Option<usize>,
379 limit: Option<usize>,
380 ) -> Result<Vec<String>, DynError> {
381 let key_parts = [&BOOKMARKS_USER_KEY_PARTS[..], &[user_id]].concat();
382 let post_keys =
383 Self::try_from_index_sorted_set(&key_parts, start, end, skip, limit, order, None)
384 .await?;
385
386 if let Some(post_keys) = post_keys {
387 Ok(post_keys.into_iter().map(|(key, _)| key).collect())
388 } else {
389 Ok(vec![])
390 }
391 }
392
393 pub async fn get_post_replies(
394 author_id: &str,
395 post_id: &str,
396 order: SortOrder,
397 start: Option<f64>,
398 end: Option<f64>,
399 skip: Option<usize>,
400 limit: Option<usize>,
401 ) -> Result<Vec<String>, DynError> {
402 let key_parts = [&POST_REPLIES_PER_POST_KEY_PARTS[..], &[author_id, post_id]].concat();
403 let post_replies =
404 Self::try_from_index_sorted_set(&key_parts, start, end, skip, limit, order, None)
405 .await?;
406 let replies_keys = post_replies.map_or(Vec::new(), |post_entry| {
407 post_entry.into_iter().map(|(post_id, _)| post_id).collect()
408 });
409 Ok(replies_keys)
410 }
411
412 async fn get_posts_for_user_ids(
416 user_ids: &[&str],
417 order: SortOrder,
418 start: Option<f64>,
419 end: Option<f64>,
420 skip: Option<usize>,
421 limit: Option<usize>,
422 ) -> Result<Vec<String>, DynError> {
423 let mut post_keys = Vec::new();
424 let max_user_ids = 200;
426 let truncated_user_ids: Vec<&str> = user_ids.iter().take(max_user_ids).cloned().collect();
427
428 for user_id in &truncated_user_ids {
430 let key_parts = [&POST_PER_USER_KEY_PARTS[..], &[user_id]].concat();
431 if let Some(post_ids) = Self::try_from_index_sorted_set(
432 &key_parts,
433 start,
434 end,
435 None, None,
437 order.clone(),
438 None,
439 )
440 .await?
441 {
442 let user_post_keys: Vec<(f64, String)> = post_ids
443 .into_iter()
444 .map(|(post_id, score)| (score, format!("{user_id}:{post_id}")))
445 .collect();
446 post_keys.extend(user_post_keys);
447 }
448 }
449
450 if post_keys.is_empty() {
452 return Ok(Vec::new());
453 }
454
455 post_keys.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
457
458 let start_index = skip.unwrap_or(0).clamp(0, post_keys.len());
460 let end_index = limit
461 .map(|l| (start_index + l).min(post_keys.len()))
462 .unwrap_or(post_keys.len());
463
464 if start_index >= end_index {
466 return Ok(Vec::new());
467 }
468
469 let selected_post_keys = post_keys[start_index..end_index]
470 .iter()
471 .map(|(_, post_key)| post_key.clone())
472 .collect();
473
474 Ok(selected_post_keys)
475 }
476
477 pub async fn from_listed_post_ids(
478 viewer_id: Option<String>,
479 post_keys: &[String],
480 ) -> Result<Option<Self>, DynError> {
481 let viewer_id = viewer_id.map(|id| id.to_string());
482 let mut handles = Vec::with_capacity(post_keys.len());
483
484 for post_key in post_keys {
485 let (author_id, post_id) = post_key.split_once(':').unwrap_or_default();
486 let author_id = author_id.to_string();
487 let viewer_id = viewer_id.clone();
488 let post_id = post_id.to_string();
489 let handle = spawn(async move {
490 PostView::get_by_id(&author_id, &post_id, viewer_id.as_deref(), None, None).await
491 });
492 handles.push(handle);
493 }
494
495 let mut post_views = Vec::with_capacity(post_keys.len());
496
497 for handle in handles {
498 if let Some(post_view) = handle.await?? {
499 post_views.push(post_view);
500 }
501 }
502
503 Ok(Some(Self(post_views)))
504 }
505
506 pub async fn add_to_timeline_sorted_set(details: &PostDetails) -> Result<(), DynError> {
508 let element = format!("{}:{}", details.author, details.id);
509 let score = details.indexed_at as f64;
510 Self::put_index_sorted_set(
511 &POST_TIMELINE_KEY_PARTS,
512 &[(score, element.as_str())],
513 None,
514 None,
515 )
516 .await
517 }
518
519 pub async fn remove_from_timeline_sorted_set(
521 author_id: &str,
522 post_id: &str,
523 ) -> Result<(), DynError> {
524 let element = format!("{author_id}:{post_id}");
525 Self::remove_from_index_sorted_set(None, &POST_TIMELINE_KEY_PARTS, &[element.as_str()])
526 .await
527 }
528
529 pub async fn add_to_per_user_sorted_set(details: &PostDetails) -> Result<(), DynError> {
531 let key_parts = [&POST_PER_USER_KEY_PARTS[..], &[details.author.as_str()]].concat();
532 let score = details.indexed_at as f64;
533 Self::put_index_sorted_set(&key_parts, &[(score, details.id.as_str())], None, None).await
534 }
535
536 pub async fn remove_from_per_user_sorted_set(
538 author_id: &str,
539 post_id: &str,
540 ) -> Result<(), DynError> {
541 let key_parts = [&POST_PER_USER_KEY_PARTS[..], &[author_id]].concat();
542 Self::remove_from_index_sorted_set(None, &key_parts, &[post_id]).await
543 }
544
545 pub async fn add_to_post_reply_sorted_set(
547 parent_post_key_parts: &[&str; 2],
550 author_id: &str,
551 reply_id: &str,
552 indexed_at: i64,
553 ) -> Result<(), DynError> {
554 let key_parts = [&POST_REPLIES_PER_POST_KEY_PARTS[..], parent_post_key_parts].concat();
555 let score = indexed_at as f64;
556 let element = format!("{author_id}:{reply_id}");
557 Self::put_index_sorted_set(&key_parts, &[(score, element.as_str())], None, None).await
558 }
559
560 pub async fn remove_from_post_reply_sorted_set(
562 parent_post_key_parts: &[&str; 2],
563 author_id: &str,
564 reply_id: &str,
565 ) -> Result<(), DynError> {
566 let key_parts = [&POST_REPLIES_PER_POST_KEY_PARTS[..], parent_post_key_parts].concat();
567 let element = format!("{author_id}:{reply_id}");
568 Self::remove_from_index_sorted_set(None, &key_parts, &[element.as_str()]).await
569 }
570
571 pub async fn add_to_replies_per_user_sorted_set(details: &PostDetails) -> Result<(), DynError> {
573 let key_parts = [
574 &POST_REPLIES_PER_USER_KEY_PARTS[..],
575 &[details.author.as_str()],
576 ]
577 .concat();
578 let score = details.indexed_at as f64;
579 Self::put_index_sorted_set(&key_parts, &[(score, details.id.as_str())], None, None).await
580 }
581
582 pub async fn remove_from_replies_per_user_sorted_set(
584 author_id: &str,
585 post_id: &str,
586 ) -> Result<(), DynError> {
587 let key_parts = [&POST_REPLIES_PER_USER_KEY_PARTS[..], &[author_id]].concat();
588 Self::remove_from_index_sorted_set(None, &key_parts, &[post_id]).await
589 }
590
591 pub async fn add_to_bookmarks_sorted_set(
593 bookmark: &Bookmark,
594 bookmarker_id: &str,
595 post_id: &str,
596 author_id: &str,
597 ) -> Result<(), DynError> {
598 let key_parts = [&BOOKMARKS_USER_KEY_PARTS[..], &[bookmarker_id]].concat();
599 let post_key = format!("{author_id}:{post_id}");
600 let score = bookmark.indexed_at as f64;
601 Self::put_index_sorted_set(&key_parts, &[(score, post_key.as_str())], None, None).await
602 }
603
604 pub async fn remove_from_bookmarks_sorted_set(
606 bookmarker_id: &str,
607 post_id: &str,
608 author_id: &str,
609 ) -> Result<(), DynError> {
610 let key_parts = [&BOOKMARKS_USER_KEY_PARTS[..], &[bookmarker_id]].concat();
611 let post_key = format!("{author_id}:{post_id}");
612 Self::remove_from_index_sorted_set(None, &key_parts, &[&post_key]).await
613 }
614
615 pub async fn add_to_engagement_sorted_set(
617 counts: &PostCounts,
618 author_id: &str,
619 post_id: &str,
620 ) -> Result<(), DynError> {
621 let element = format!("{author_id}:{post_id}");
622 let score = counts.tags + counts.replies + counts.reposts;
623 let score = score as f64;
624
625 Self::put_index_sorted_set(
626 &POST_TOTAL_ENGAGEMENT_KEY_PARTS,
627 &[(score, element.as_str())],
628 None,
629 None,
630 )
631 .await
632 }
633
634 pub async fn delete_from_engagement_sorted_set(
635 author_id: &str,
636 post_id: &str,
637 ) -> Result<(), DynError> {
638 let post_key = format!("{author_id}:{post_id}");
639 Self::remove_from_index_sorted_set(None, &POST_TOTAL_ENGAGEMENT_KEY_PARTS, &[&post_key])
640 .await
641 }
642
643 pub async fn update_index_score(
644 author_id: &str,
645 post_id: &str,
646 score_action: ScoreAction,
647 ) -> Result<(), DynError> {
648 let post_key_slice = &[author_id, post_id];
649 Self::put_score_index_sorted_set(
650 &POST_TOTAL_ENGAGEMENT_KEY_PARTS,
651 post_key_slice,
652 score_action,
653 )
654 .await
655 }
656}