diff --git a/parakeet/src/loaders/embed.rs b/parakeet/src/loaders/embed.rs index 109d647e..291de91c 100644 --- a/parakeet/src/loaders/embed.rs +++ b/parakeet/src/loaders/embed.rs @@ -83,15 +83,17 @@ pub fn build_embeds_batch_query() -> &'static str { p.image_3, p.image_4, -- For record embeds, get embedded post info + -- Join to actors using embedded_post_actor_id directly (not through embedded post) + -- This allows us to resolve the DID even if the quoted post is deleted/not indexed emb_a.did as embedded_did, - emb_p.rkey as embedded_rkey, + p.embedded_post_rkey as embedded_rkey, 'app.bsky.feed.post' as embedded_collection FROM posts p INNER JOIN actors a ON p.actor_id = a.id INNER JOIN unnest($1::text[], $2::bigint[]) AS lookup(lookup_did, lookup_rkey) ON a.did = lookup.lookup_did AND p.rkey = lookup.lookup_rkey LEFT JOIN posts emb_p ON p.embedded_post_actor_id = emb_p.actor_id AND p.embedded_post_rkey = emb_p.rkey - LEFT JOIN actors emb_a ON emb_p.actor_id = emb_a.id + LEFT JOIN actors emb_a ON p.embedded_post_actor_id = emb_a.id WHERE p.embed_type IS NOT NULL AND tid_timestamp(p.rkey) >= tid_timestamp($3::bigint) AND tid_timestamp(p.rkey) <= tid_timestamp($4::bigint)" diff --git a/parakeet/src/loaders/post.rs b/parakeet/src/loaders/post.rs index 50d300ee..c67d6622 100644 --- a/parakeet/src/loaders/post.rs +++ b/parakeet/src/loaders/post.rs @@ -657,6 +657,7 @@ impl BatchFn for PostLoader { let unique_actor_ids: Vec = { let mut ids: Vec = fetched_rows.iter().map(|r| r.actor_id).collect(); // Add all actor_ids from post_keys_to_lookup so we can construct URIs for deleted posts + // This includes parent/root/embedded post authors (already collected above) for (actor_id, _rkey) in &post_keys_to_lookup { ids.push(*actor_id); } @@ -869,12 +870,20 @@ impl BatchFn for PostLoader { p.root_post_actor_id.and_then(|actor_id| p.root_post_rkey.map(|rkey| (actor_id, rkey))) ); - // Reconstruct embedded post data from post_data_map (using id_cache) + // Reconstruct embedded post data from post_data_map or actor_did_map let (embedded_did, embedded_rkey) = if let (Some(emb_actor_id), Some(emb_rkey)) = (p.embedded_post_actor_id, p.embedded_post_rkey) { if let Some(data) = post_data_map.get(&(emb_actor_id, emb_rkey)) { + // Post exists - use real DID and rkey (Some(data.did.clone()), Some(data.rkey)) } else { - (None, None) + // Post not found (deleted or not indexed) - try to get DID from actor_did_map + // This allows us to construct URI even if quoted post is deleted + if let Some(did) = actor_did_map.get(&emb_actor_id) { + (Some(did.clone()), Some(emb_rkey)) + } else { + // Can't even resolve DID - this shouldn't happen in practice + (None, None) + } } } else { (None, None) diff --git a/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/other_replies.rs b/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/other_replies.rs index 99af95f0..ed466cf4 100644 --- a/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/other_replies.rs +++ b/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/other_replies.rs @@ -13,6 +13,39 @@ use super::models::{GetPostThreadOtherV2Res, ThreadItemPost, ThreadV2Item, Threa use super::query::GetPostThreadOtherV2Req; use super::sorting::{sort_other_replies, sort_thread_items}; +/// Convert a PostView into the appropriate ThreadV2ItemType based on viewer state and labels +fn post_to_thread_item_type(post: lexica::app_bsky::feed::PostView, is_authenticated: bool) -> ThreadV2ItemType { + // Check if author is blocked by viewer or viewer is blocked by author + if let Some(viewer) = &post.author.viewer { + if viewer.blocked_by || viewer.blocking.is_some() { + return ThreadV2ItemType::Blocked { + author: Box::new(lexica::app_bsky::feed::BlockedAuthor { + did: post.author.did.clone(), + viewer: Some(viewer.clone()), + }), + }; + } + } + + // Check if unauthenticated viewer trying to view post from author with !no-unauthenticated label + if !is_authenticated { + let has_no_unauth_label = post.author.labels.iter().any(|label| label.val == "!no-unauthenticated"); + if has_no_unauth_label { + return ThreadV2ItemType::NoUnauthenticated {}; + } + } + + // Return normal post + ThreadV2ItemType::Post(Box::new(ThreadItemPost { + post, + more_parents: false, + more_replies: 0, + op_thread: false, + hidden_by_threadgate: false, + muted_by_viewer: false, + })) +} + /// Implementation of the app.bsky.unspecced.getPostThreadOtherV2 endpoint /// /// This endpoint retrieves additional replies that weren't included in the main thread view. @@ -23,6 +56,7 @@ pub async fn get_post_thread_other_v2( Query(query): Query, ) -> XrpcResult { let mut conn = state.pool.get().await?; + let is_authenticated = maybe_auth.is_some(); let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, maybe_auth); let uri = normalise_at_uri(&state.dataloaders, &query.anchor).await?; @@ -69,14 +103,7 @@ pub async fn get_post_thread_other_v2( thread_items.push(ThreadV2Item { uri: reply_uri.clone(), depth: 1, // All direct replies have depth 1 - value: ThreadV2ItemType::Post(Box::new(ThreadItemPost { - post: post_view.clone(), - more_parents: false, - more_replies: 0, - op_thread: false, - hidden_by_threadgate: false, - muted_by_viewer: false, - })), + value: post_to_thread_item_type(post_view.clone(), is_authenticated), }); } else { // Post not found or couldn't be hydrated diff --git a/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/post_thread.rs b/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/post_thread.rs index d1a6c0df..654f8955 100644 --- a/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/post_thread.rs +++ b/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/post_thread.rs @@ -25,6 +25,7 @@ pub async fn get_post_thread_v2( const DEFAULT_BRANCHING: u32 = 10; let mut conn = state.pool.get().await?; + let is_authenticated = maybe_auth.is_some(); let hyd = StatefulHydrator::new(&state.dataloaders, &state.cdn, &labelers, maybe_auth); let uri = normalise_at_uri(&state.dataloaders, &query.anchor).await?; @@ -57,6 +58,7 @@ pub async fn get_post_thread_v2( branching_factor, sort: query.sort, prioritize_followed_users: query.prioritize_followed_users, + is_authenticated, }; // Build the thread diff --git a/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/thread_builder.rs b/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/thread_builder.rs index 9682277e..20274aff 100644 --- a/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/thread_builder.rs +++ b/parakeet/src/xrpc/app_bsky/unspecced/thread_v2/thread_builder.rs @@ -17,9 +17,43 @@ pub struct ThreadBuilder<'a> { pub branching_factor: i32, pub sort: PostThreadSort, pub prioritize_followed_users: bool, + pub is_authenticated: bool, } impl ThreadBuilder<'_> { + /// Convert a PostView into the appropriate ThreadV2ItemType based on viewer state and labels + fn post_to_thread_item_type(&self, post: PostView, op_thread: bool) -> ThreadV2ItemType { + // Check if author is blocked by viewer or viewer is blocked by author + if let Some(viewer) = &post.author.viewer { + if viewer.blocked_by || viewer.blocking.is_some() { + return ThreadV2ItemType::Blocked { + author: Box::new(lexica::app_bsky::feed::BlockedAuthor { + did: post.author.did.clone(), + viewer: Some(viewer.clone()), + }), + }; + } + } + + // Check if unauthenticated viewer trying to view post from author with !no-unauthenticated label + if !self.is_authenticated { + let has_no_unauth_label = post.author.labels.iter().any(|label| label.val == "!no-unauthenticated"); + if has_no_unauth_label { + return ThreadV2ItemType::NoUnauthenticated {}; + } + } + + // Return normal post + ThreadV2ItemType::Post(Box::new(ThreadItemPost { + post, + more_parents: false, + more_replies: 0, + op_thread, + hidden_by_threadgate: false, + muted_by_viewer: false, + })) + } + /// Build a complete thread structure based on the configured parameters pub async fn build_thread( &self, @@ -37,14 +71,7 @@ impl ThreadBuilder<'_> { thread_items.push(ThreadV2Item { uri: self.anchor_post.uri.clone(), depth: 0, - value: ThreadV2ItemType::Post(Box::new(ThreadItemPost { - post: self.anchor_post.clone(), - more_parents: false, - more_replies: 0, - op_thread: true, - hidden_by_threadgate: false, - muted_by_viewer: false, - })), + value: self.post_to_thread_item_type(self.anchor_post.clone(), true), }); // Step 3: Add child posts if requested (below > 0) @@ -98,14 +125,7 @@ impl ThreadBuilder<'_> { thread_items.push(ThreadV2Item { uri: parent.at_uri.clone(), depth, - value: ThreadV2ItemType::Post(Box::new(ThreadItemPost { - post: post_view.clone(), - more_parents: false, - more_replies: 0, - op_thread: true, - hidden_by_threadgate: false, - muted_by_viewer: false, - })), + value: self.post_to_thread_item_type(post_view.clone(), true), }); } else { thread_items.push(ThreadV2Item { @@ -161,14 +181,7 @@ impl ThreadBuilder<'_> { thread_items.push(ThreadV2Item { uri: child_uri.clone(), depth: 1, // Direct children are always depth 1 - value: ThreadV2ItemType::Post(Box::new(ThreadItemPost { - post: post_view.clone(), - more_parents: false, - more_replies: 0, - op_thread: false, - hidden_by_threadgate: false, - muted_by_viewer: false, - })), + value: self.post_to_thread_item_type(post_view.clone(), false), }); // Recursively add ALL descendants of this child before moving to next sibling @@ -222,14 +235,7 @@ impl ThreadBuilder<'_> { thread_items.push(ThreadV2Item { uri: child_uri.clone(), depth: child_depth, - value: ThreadV2ItemType::Post(Box::new(ThreadItemPost { - post: post_view.clone(), - more_parents: false, - more_replies: 0, - op_thread: false, - hidden_by_threadgate: false, - muted_by_viewer: false, - })), + value: self.post_to_thread_item_type(post_view.clone(), false), }); // Continue recursively if we haven't reached max depth