diff --git a/lexica/src/app_bsky/feed.rs b/lexica/src/app_bsky/feed.rs index 203668f7..17f190eb 100644 --- a/lexica/src/app_bsky/feed.rs +++ b/lexica/src/app_bsky/feed.rs @@ -19,7 +19,6 @@ pub struct PostViewerState { pub thread_muted: bool, pub reply_disabled: bool, pub embedding_disabled: bool, - pub pinned: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/parakeet/src/entities/converters/post.rs b/parakeet/src/entities/converters/post.rs index c4c845c7..efb86f23 100644 --- a/parakeet/src/entities/converters/post.rs +++ b/parakeet/src/entities/converters/post.rs @@ -42,29 +42,29 @@ impl PostEntity { } /// Get PostView by composite key (direct conversion, no hydration) - pub async fn get_post_view(&self, actor_id: i32, rkey: i64) -> Option { + pub async fn get_post_view(&self, actor_id: i32, rkey: i64, viewer_did: Option<&str>) -> Option { let post_data = self.get_post_by_key(actor_id, rkey).await.ok()?; // Get author profile using ProfileEntity let author_profile = self.profile_entity - .get_profile_view_basic(post_data.author.id) + .get_profile_view_basic(post_data.author.id, viewer_did) .await?; Some(self.post_to_post_view(&post_data, author_profile)) } /// Get PostView by URI - pub async fn get_post_view_by_uri(&self, uri: &str) -> Option { + pub async fn get_post_view_by_uri(&self, uri: &str, viewer_did: Option<&str>) -> Option { let (actor_id, rkey) = self.resolve_uri(uri).await.ok()?; - self.get_post_view(actor_id, rkey).await + self.get_post_view(actor_id, rkey, viewer_did).await } /// Get multiple PostViews by URIs - pub async fn get_post_views_by_uris(&self, uris: &[String]) -> Vec { + pub async fn get_post_views_by_uris(&self, uris: &[String], viewer_did: Option<&str>) -> Vec { let mut posts = Vec::new(); for uri in uris { - if let Some(post) = self.get_post_view_by_uri(uri).await { + if let Some(post) = self.get_post_view_by_uri(uri, viewer_did).await { posts.push(post); } } diff --git a/parakeet/src/entities/converters/profile.rs b/parakeet/src/entities/converters/profile.rs index deeb95a7..4aacd932 100644 --- a/parakeet/src/entities/converters/profile.rs +++ b/parakeet/src/entities/converters/profile.rs @@ -7,7 +7,8 @@ use parakeet_db::models::Actor; use lexica::app_bsky::actor::{ ProfileView, ProfileViewDetailed, ProfileViewBasic, ProfileAssociated, ProfileAssociatedChat, ChatAllowIncoming, - ProfileAssociatedActivitySubscription, ProfileAllowSubscriptions + ProfileAssociatedActivitySubscription, ProfileAllowSubscriptions, + ProfileViewerState }; use std::str::FromStr; use std::sync::Arc; @@ -36,6 +37,68 @@ pub fn actor_to_profile_view(actor: &Actor) -> ProfileView { } impl ProfileEntity { + /// Build viewer state for an actor from the perspective of a viewer + async fn build_viewer_state(&self, actor: &Actor, viewer_did: &str) -> Option { + // Get viewer's actor_id + let viewer_actor_id = self.resolve_identifier(viewer_did).await.ok()?; + + // Get viewer's full actor data to check relationships + let viewer_actor = self.get_profile_by_id(viewer_actor_id).await.ok()?; + + // Check if viewer follows this actor + // viewer's following list contains people viewer follows - check if actor.id is in that list + let following = viewer_actor.following.as_ref() + .and_then(|follows| { + follows.iter().flatten().find(|f| f.subject_actor_id == actor.id) + .map(|f| format!("at://{}/app.bsky.graph.follow/{}", viewer_did, + parakeet_db::tid_util::encode_tid(f.rkey))) + }); + + // Check if this actor follows viewer + // actor's following list contains people actor follows - check if viewer_actor_id is in that list + let followed_by = actor.following.as_ref() + .and_then(|follows| { + follows.iter().flatten().find(|f| f.subject_actor_id == viewer_actor_id) + .map(|f| format!("at://{}/app.bsky.graph.follow/{}", actor.did, + parakeet_db::tid_util::encode_tid(f.rkey))) + }); + + // Check if viewer has muted this actor + // viewer's mutes list contains actors viewer has muted + let muted = viewer_actor.mutes.as_ref() + .map(|mutes| { + mutes.iter().flatten().any(|m| m.subject_actor_id == actor.id) + }) + .unwrap_or(false); + + // Check if viewer is blocked by this actor + // actor's blocks list contains actors that actor has blocked + let blocked_by = actor.blocks.as_ref() + .map(|blocks| { + blocks.iter().flatten().any(|b| b.subject_actor_id == viewer_actor_id) + }) + .unwrap_or(false); + + // Check if viewer is blocking this actor + // viewer's blocks list contains actors viewer has blocked + let blocking = viewer_actor.blocks.as_ref() + .and_then(|blocks| { + blocks.iter().flatten().find(|b| b.subject_actor_id == actor.id) + .map(|b| format!("at://{}/app.bsky.graph.block/{}", viewer_did, + parakeet_db::tid_util::encode_tid(b.rkey))) + }); + + Some(ProfileViewerState { + muted, + muted_by_list: None, + blocked_by, + blocking, + blocking_by_list: None, + following, + followed_by, + known_followers: None, + }) + } /// Convert an Actor directly to ProfileViewDetailed /// /// This is much simpler than the old hydration system - we just map the fields directly @@ -59,10 +122,10 @@ impl ProfileEntity { // Associated data associated: Some(ProfileAssociated { - lists: actor.lists_count.map(|c| c as i64), - feedgens: actor.feeds_count.map(|c| c as i64), - starter_packs: actor.starterpacks_count.map(|c| c as i64), - labeler: Some(actor.labeler_cid.is_some()), + lists: None, + feedgens: None, + starter_packs: None, + labeler: None, chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| { ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| { ProfileAssociatedChat { allow_incoming: allow } @@ -134,10 +197,10 @@ impl ProfileEntity { status: None, verification: None, associated: Some(ProfileAssociated { - lists: actor.lists_count.map(|c| c as i64), - feedgens: actor.feeds_count.map(|c| c as i64), - starter_packs: actor.starterpacks_count.map(|c| c as i64), - labeler: Some(actor.labeler_cid.is_some()), + lists: None, + feedgens: None, + starter_packs: None, + labeler: None, chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| { ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| { ProfileAssociatedChat { allow_incoming: allow } @@ -153,7 +216,17 @@ impl ProfileEntity { } /// Convert an Actor to ProfileViewBasic (minimal variant) - pub fn actor_to_profile_view_basic(&self, actor: &Actor) -> ProfileViewBasic { + pub async fn actor_to_profile_view_basic(&self, actor: &Actor, viewer_did: Option<&str>) -> ProfileViewBasic { + let viewer = if let Some(did) = viewer_did { + if did != actor.did.as_str() { + self.build_viewer_state(actor, did).await + } else { + None + } + } else { + None + }; + ProfileViewBasic { did: actor.did.clone(), handle: actor.handle.clone().unwrap_or_else(|| "handle.invalid".to_string()), @@ -161,17 +234,17 @@ impl ProfileEntity { avatar: actor.profile_avatar_cid.as_ref() .and_then(|cid| parakeet_db::cid_util::digest_to_blob_cid_string(cid) .map(|cid_str| format!("https://cdn.bsky.social/img/avatar/plain/{}/{}@jpeg", actor.did, cid_str))), - viewer: None, + viewer, labels: vec![], created_at: actor.account_created_at.unwrap_or_else(|| chrono::Utc::now()), pronouns: actor.profile_pronouns.clone(), status: None, verification: None, associated: Some(ProfileAssociated { - lists: actor.lists_count.map(|c| c as i64), - feedgens: actor.feeds_count.map(|c| c as i64), - starter_packs: actor.starterpacks_count.map(|c| c as i64), - labeler: Some(actor.labeler_cid.is_some()), + lists: None, + feedgens: None, + starter_packs: None, + labeler: None, chat: actor.chat_allow_incoming.as_ref().and_then(|chat_type| { ChatAllowIncoming::from_str(&chat_type.to_string()).ok().map(|allow| { ProfileAssociatedChat { allow_incoming: allow } @@ -315,14 +388,18 @@ impl ProfileEntity { } /// Get ProfileViewBasic by actor_id - pub async fn get_profile_view_basic(&self, actor_id: i32) -> Option { + pub async fn get_profile_view_basic(&self, actor_id: i32, viewer_did: Option<&str>) -> Option { let actor = self.get_profile_by_id(actor_id).await.ok()?; - Some(self.actor_to_profile_view_basic(&actor)) + Some(self.actor_to_profile_view_basic(&actor, viewer_did).await) } /// Get multiple ProfileViewBasic by actor_ids - pub async fn get_profile_views_basic(&self, actor_ids: &[i32]) -> Vec { + pub async fn get_profile_views_basic(&self, actor_ids: &[i32], viewer_did: Option<&str>) -> Vec { let actors = self.get_profiles_by_ids(actor_ids).await.unwrap_or_default(); - actors.iter().map(|actor| self.actor_to_profile_view_basic(actor)).collect() + let mut views = Vec::with_capacity(actors.len()); + for actor in actors { + views.push(self.actor_to_profile_view_basic(&actor, viewer_did).await); + } + views } } \ No newline at end of file diff --git a/parakeet/src/entities/core/post.rs b/parakeet/src/entities/core/post.rs index f81cc985..f18e8754 100644 --- a/parakeet/src/entities/core/post.rs +++ b/parakeet/src/entities/core/post.rs @@ -420,7 +420,7 @@ impl PostEntity { }; // Convert to PostView with viewer state - let post_view = self.post_data_to_view(post_data, viewer_actor_id).await; + let post_view = self.post_data_to_view(post_data, viewer_actor_id, viewer_did).await; Ok(Some(post_view)) } @@ -450,7 +450,7 @@ impl PostEntity { }; // Convert to PostView with viewer state - let post_view = self.post_data_to_view(post_data.clone(), viewer_actor_id).await; + let post_view = self.post_data_to_view(post_data.clone(), viewer_actor_id, viewer_did).await; // Build reply context let reply_context = self.build_reply_context(&post_data, viewer_did).await; @@ -502,13 +502,13 @@ impl PostEntity { let mut views = Vec::with_capacity(posts.len()); for post_data in posts { - views.push(self.post_data_to_view(post_data, viewer_actor_id).await); + views.push(self.post_data_to_view(post_data, viewer_actor_id, viewer_did).await); } views } /// Convert PostData to PostView - async fn post_data_to_view(&self, data: PostData, viewer_actor_id: Option) -> PostView { + async fn post_data_to_view(&self, data: PostData, viewer_actor_id: Option, viewer_did: Option<&str>) -> PostView { let uri = format!( "at://{}/app.bsky.feed.post/{}", data.author.did, @@ -567,13 +567,6 @@ impl PostEntity { false }; - // Check if post is pinned by the viewer (if viewing own posts) - let pinned = if let Some(ref viewer) = viewer_actor { - viewer.id == data.post.actor_id && - viewer.profile_pinned_post_rkey == Some(data.post.rkey) - } else { - false - }; // Check threadgate for reply_disabled let reply_disabled = data.post.threadgate_allow.is_some(); @@ -601,10 +594,9 @@ impl PostEntity { repost: repost_uri, like: like_uri, bookmarked, - thread_muted: false, // Thread muting not yet implemented in schema + thread_muted: false, reply_disabled, - embedding_disabled: false, // Embedding preferences not yet implemented - pinned, + embedding_disabled: false, }) } else { None @@ -613,7 +605,7 @@ impl PostEntity { PostView { uri, cid, - author: self.profile_entity.actor_to_profile_view_basic(&data.author), + author: self.profile_entity.actor_to_profile_view_basic(&data.author, viewer_did).await, record: serde_json::json!({ "$type": "app.bsky.feed.post", "text": text, @@ -771,7 +763,7 @@ impl PostEntity { } else { None }; - let parent_view = self.post_data_to_view(parent_data, viewer_actor_id).await; + let parent_view = self.post_data_to_view(parent_data, viewer_actor_id, viewer_did).await; ReplyRefPost::Post(Box::new(parent_view)) } Err(_) => { @@ -813,7 +805,7 @@ impl PostEntity { } else { None }; - let root_view = self.post_data_to_view(root_data, viewer_actor_id).await; + let root_view = self.post_data_to_view(root_data, viewer_actor_id, viewer_did).await; ReplyRefPost::Post(Box::new(root_view)) } Err(_) => { diff --git a/parakeet/src/xrpc/app_bsky/actor.rs b/parakeet/src/xrpc/app_bsky/actor.rs index fdc5ac46..d31e0287 100644 --- a/parakeet/src/xrpc/app_bsky/actor.rs +++ b/parakeet/src/xrpc/app_bsky/actor.rs @@ -158,7 +158,7 @@ pub async fn search_actors( pub async fn search_actors_typeahead( State(state): State, AtpAcceptLabelers(_labelers): AtpAcceptLabelers, - _maybe_auth: Option, + maybe_auth: Option, Query(query): Query, ) -> XrpcResult { // Get search term @@ -195,8 +195,9 @@ pub async fn search_actors_typeahead( })?; // Get ProfileViewBasic for each actor_id (typeahead returns basic views) + let viewer_did = maybe_auth.as_ref().map(|auth| auth.0.as_str()); let actors = state.profile_entity - .get_profile_views_basic(&results) + .get_profile_views_basic(&results, viewer_did) .await; Ok(Json(SearchActorsTypeaheadResponse { actors }).into_response()) diff --git a/parakeet/src/xrpc/app_bsky/feed/get_timeline.rs b/parakeet/src/xrpc/app_bsky/feed/get_timeline.rs index 6926ecda..07fc9305 100644 --- a/parakeet/src/xrpc/app_bsky/feed/get_timeline.rs +++ b/parakeet/src/xrpc/app_bsky/feed/get_timeline.rs @@ -170,7 +170,7 @@ pub async fn get_timeline( // Get reposter's profile for the reason if let Ok(reposter) = state.profile_entity.get_profile_by_id(reposter_id).await { // Convert to ProfileViewBasic - let reposter_basic = state.profile_entity.actor_to_profile_view_basic(&reposter); + let reposter_basic = state.profile_entity.actor_to_profile_view_basic(&reposter, Some(&user_did)).await; // Build the repost reason let reason = Some(lexica::app_bsky::feed::FeedViewPostReason::Repost(Box::new(