Skip to main content

nexus_common/models/
bootstrap.rs

1use std::collections::HashSet;
2
3use crate::db::kv::SortOrder;
4use crate::types::{DynError, Pagination, StreamSorting, Timeframe};
5
6use crate::models::{
7    post::{PostStream, StreamSource},
8    tag::TagDetails,
9    user::{Influencers, UserStream},
10};
11use serde::{Deserialize, Serialize};
12use utoipa::ToSchema;
13
14use super::user::UserDetails;
15
16#[derive(PartialEq, Deserialize)]
17pub enum ViewType {
18    Full,
19    Partial,
20}
21
22#[derive(Serialize, ToSchema, Deserialize, Default, Debug)]
23pub struct Bootstrap {
24    pub users: UserStream,
25    pub posts: PostStream,
26    pub list: BootstrapList,
27}
28
29#[derive(Serialize, ToSchema, Deserialize, Default, Debug)]
30pub struct BootstrapList {
31    pub stream: Vec<String>,
32    pub influencers: Vec<String>,
33    pub recommended: Vec<String>,
34}
35
36impl Bootstrap {
37    /// Builds an pubky.app bootstrap summary for the specified `user_id`, fetching posts, replies,
38    /// active influencers, and personalized suggestions
39    ///
40    /// # Parameters
41    /// - `user_id: &str`  
42    ///   The ID of the user whose “ImAlive” stream is being built
43    /// - `view_type: ViewType`  
44    ///   Controls whether to fetch replies and include full stream entries (`Full`)
45    ///   or only base posts (`Partial`)
46    pub async fn get_by_id(user_id: &str, view_type: ViewType) -> Result<Option<Self>, DynError> {
47        let mut bootstrap = Self::default();
48        let mut user_ids = HashSet::new();
49
50        // Boostrap guard: Early return if the user lookup fails, avoiding unnecessary work
51        let Some(_) = UserDetails::get_by_id(user_id).await? else {
52            return Ok(None);
53        };
54        user_ids.insert(user_id.to_string());
55
56        let is_full_view_type = view_type == ViewType::Full;
57
58        let post_stream_by_timeline =
59            get_post_stream_timeline(user_id, StreamSource::All, 20).await?;
60
61        let post_replies =
62            bootstrap.handle_post_stream(post_stream_by_timeline, &mut user_ids, view_type);
63
64        bootstrap.add_influencers(&mut user_ids).await?;
65        bootstrap
66            .add_recommended_users(&mut user_ids, user_id)
67            .await?;
68        // // TODO: Missing hot tags
69        // HotTags::get_hot_tags(None, None, &hot_tag_filter).await?;
70
71        if is_full_view_type {
72            bootstrap
73                .fetch_and_handle_replies(post_replies, &mut user_ids, user_id)
74                .await?;
75        }
76
77        // Merge all the users related with posts, post replies, influencers and recommended
78        bootstrap
79            .fetch_and_merge_users(&user_ids, Some(user_id))
80            .await?;
81
82        // UserViews has also taggers, fetch the missing users UserViews
83        if is_full_view_type {
84            let missing_taggers = bootstrap.collect_missing_taggers(&user_ids);
85            if !missing_taggers.is_empty() {
86                bootstrap
87                    .fetch_and_merge_users(&missing_taggers, Some(user_id))
88                    .await?;
89            }
90        }
91        Ok(Some(bootstrap))
92    }
93
94    /// Processes a stream of posts, collecting reply references, adding post taggers and populating the post stream
95    /// in the response object
96    ///
97    /// # Parameters
98    /// - `post_stream`: The `PostStream` whose contained posts will be processed
99    /// - `user_ids`: A mutable set of user IDs; authors and taggers encountered will be inserted
100    /// - `view_type`: Indicates whether to operate in `Full` mode (recording stream entries and replies)
101    fn handle_post_stream(
102        &mut self,
103        post_stream: PostStream,
104        user_ids: &mut HashSet<String>,
105        view_type: ViewType,
106    ) -> Vec<(String, String)> {
107        let is_full_view_type = view_type == ViewType::Full;
108        let mut post_replies = Vec::with_capacity(post_stream.0.len());
109
110        for post_view in post_stream.0.iter() {
111            let author_id = post_view.details.author.clone();
112            let post_id = post_view.details.id.clone();
113
114            if is_full_view_type && post_view.counts.replies > 0 {
115                post_replies.push((author_id.clone(), post_id.clone()))
116            }
117            // Add the author of the post
118            user_ids.insert(author_id.clone());
119            // Get all the taggers related with the post
120            Self::insert_taggers_id(&post_view.tags, user_ids);
121            // Include the post in the stream list
122            if is_full_view_type {
123                self.list.stream.push(format!("{author_id}:{post_id}"));
124            }
125        }
126        // After analyse the posts, authors and tags, push the stream
127        self.posts.extend(post_stream);
128        post_replies
129    }
130
131    /// Collects all tagger IDs from the current `users` view that are not yet present
132    /// in the given `user_ids` set
133    ///
134    /// # Parameters
135    ///
136    /// - `user_ids`: A set of user IDs that have already been fetched or seen
137    fn collect_missing_taggers(&self, user_ids: &HashSet<String>) -> HashSet<String> {
138        let mut missing_taggers = HashSet::new();
139        for user in self.users.0.iter() {
140            user.tags
141                .iter()
142                .flat_map(|tags| tags.taggers.iter())
143                .for_each(|tagger| {
144                    if !user_ids.contains(tagger) {
145                        missing_taggers.insert(tagger.clone());
146                    }
147                });
148        }
149        missing_taggers
150    }
151
152    /// Appends each tagger’s user ID from the given post tag details into the provided set
153    ///
154    /// # Parameters
155    /// - `tag_details_list: &Vec<TagDetails>`  
156    ///   A reference to a vector of `TagDetails`, each containing a list of tagger IDs
157    /// - `users_list: &mut HashSet<String>`  
158    ///   A mutable reference to a set of user IDs; each tagger ID will be inserted here
159    fn insert_taggers_id(tag_details_list: &[TagDetails], users_list: &mut HashSet<String>) {
160        for tag_details in tag_details_list.iter() {
161            for tagger_pk in tag_details.taggers.iter() {
162                users_list.insert(tagger_pk.to_string());
163            }
164        }
165    }
166
167    /// Fetches and appends user views for the given set of `user_ids`
168    ///
169    /// # Parameters
170    /// - `user_ids: HashSet<String>`  
171    ///   A set of unique user IDs to fetch views for
172    /// - `viewer_id: Option<&str>`  
173    ///   Optional context user ID for personalized view generation
174    async fn fetch_and_merge_users(
175        &mut self,
176        user_ids: &HashSet<String>,
177        viewer_id: Option<&str>,
178    ) -> Result<(), DynError> {
179        if user_ids.is_empty() {
180            return Ok(());
181        }
182        let user_ids_vec: Vec<String> = user_ids.iter().cloned().collect();
183        // TODO: If the user list is too big, we could do in batches
184        // for batch in user_ids.chunks(BATCH_SIZE) { ...
185        if let Some(user_stream) =
186            UserStream::from_listed_user_ids(&user_ids_vec, viewer_id, None).await?
187        {
188            self.users.extend(user_stream);
189        }
190        Ok(())
191    }
192
193    /// Fetches up to three replies for each post in `post_replies` and integrates their authors (and any taggers)
194    /// into both the internal user list
195    ///
196    /// # Parameters
197    /// - `post_replies: Vec<(String, String)>`  
198    ///   A list of `(author_id, post_id)` tuples indicating which post replies to fetch
199    /// - `user_ids: &mut HashSet<String>`  
200    ///   A mutable reference to a set where each reply’s author ID (and any taggers) will be appended
201    /// - `viewer_id: &str`  
202    ///   The ID of the current viewer
203    async fn fetch_and_handle_replies(
204        &mut self,
205        post_replies: Vec<(String, String)>,
206        user_ids: &mut HashSet<String>,
207        viewer_id: &str,
208    ) -> Result<(), DynError> {
209        // TODO: Might consider in the future to do in all the requests in parallel
210        // tokio::task::JoinSet or tokio::spawn(async move {...
211        for (author_id, post_id) in post_replies {
212            let reply_stream = get_post_stream_timeline(
213                viewer_id,
214                StreamSource::PostReplies { author_id, post_id },
215                3,
216            )
217            .await?;
218            self.handle_post_stream(reply_stream, user_ids, ViewType::Partial);
219        }
220        Ok(())
221    }
222
223    /// Fetches today’s active influencers and appends their IDs to both the internal `influencers` list
224    /// and the provided `user_ids` set
225    ///
226    /// # Parameters
227    /// - `user_ids: &mut HashSet<String>` A mutable reference to a set of user IDs
228    ///
229    async fn add_influencers(&mut self, user_ids: &mut HashSet<String>) -> Result<(), DynError> {
230        if let Some(influencers) =
231            Influencers::get_influencers(None, None, 0, 0, Timeframe::Today, true).await?
232        {
233            influencers.0.into_iter().for_each(|(id, _)| {
234                self.list.influencers.push(id.clone());
235                user_ids.insert(id);
236            });
237        }
238        Ok(())
239    }
240
241    /// Fetches recommended user IDs for the given `viewer_id` and appends them to both
242    /// the internal `active_users` list and the provided `user_ids` set
243    ///
244    /// # Parameters
245    /// - `user_ids: &mut HashSet<String>` A mutable reference to a set of user IDs
246    /// - `viewer_id: &str` The ID of the user for whom recommended are being generated
247    async fn add_recommended_users(
248        &mut self,
249        user_ids: &mut HashSet<String>,
250        viewer_id: &str,
251    ) -> Result<(), DynError> {
252        if let Some(recommended_users) = UserStream::get_recommended_ids(viewer_id, None).await? {
253            recommended_users.into_iter().for_each(|id| {
254                self.list.recommended.push(id.clone());
255                user_ids.insert(id);
256            });
257        }
258        Ok(())
259    }
260}
261
262async fn get_post_stream_timeline(
263    viewer_id: &str,
264    source: StreamSource,
265    limit: usize,
266) -> Result<PostStream, DynError> {
267    let pagination = Pagination {
268        skip: Some(0),
269        limit: Some(limit),
270        start: None,
271        end: None,
272    };
273    Ok(PostStream::get_posts(
274        source,
275        pagination,
276        SortOrder::default(),
277        StreamSorting::Timeline,
278        Some(viewer_id.to_string()),
279        None,
280        None,
281    )
282    .await?
283    .unwrap_or_default())
284}