Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions home-mixer/candidate_pipeline/phoenix_candidate_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ use crate::filters::retweet_deduplication_filter::RetweetDeduplicationFilter;
use crate::filters::self_tweet_filter::SelfTweetFilter;
use crate::filters::topic_ids_filter::TopicIdsFilter;
use crate::filters::vf_filter::VFFilter;
use crate::filters::following_content_controls_filter::FollowingContentControlsFilter;
use crate::filters::video_filter::VideoFilter;
use crate::filters::viewer_muted_keyword_filter::ViewerMutedKeywordFilter;
use crate::models::candidate::PostCandidate;
Expand Down Expand Up @@ -368,6 +369,7 @@ impl PhoenixCandidatePipeline {
// OmarAzizSenador deleted his account at the time this code was written.
Box::new(Brazil2026ElectionFilter),
Box::new(VideoFilter),
Box::new(FollowingContentControlsFilter),
Box::new(TopicIdsFilter),
Box::new(NewUserMinEngagementFilter),
Box::new(InventoryHoldoutFilter),
Expand Down
13 changes: 13 additions & 0 deletions home-mixer/candidate_pipeline/reverse_chron_posts_pipeline.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ use crate::clients::s2s::{S2S_CHAIN_PATH, S2S_CRT_PATH, S2S_KEY_PATH};
use crate::clients::tweet_entity_service_client::{MockTESClient, ProdTESClient, TESClient};
use crate::filters::ancillary_vf_filter::AncillaryVFFilter;
use crate::filters::author_socialgraph_filter::AuthorSocialgraphFilter;
use crate::filters::following_content_controls_filter::FollowingContentControlsFilter;
use crate::filters::following_retweet_deduplication_filter::FollowingRetweetDeduplicationFilter;
use crate::filters::following_viewer_muted_keyword_filter::FollowingViewerMutedKeywordFilter;
use crate::filters::self_reply_chain_filter::SelfReplyChainFilter;
Expand Down Expand Up @@ -162,6 +163,7 @@ impl ReverseChronPostsPipeline {
Box::new(FollowingRetweetDeduplicationFilter),
Box::new(FollowingViewerMutedKeywordFilter::new()),
Box::new(SelfReplyChainFilter),
Box::new(FollowingContentControlsFilter),
];

let post_selection_hydrators: Vec<Box<dyn Hydrator<ScoredPostsQuery, PostCandidate>>> = vec![
Expand Down Expand Up @@ -236,3 +238,14 @@ impl CandidatePipeline<ScoredPostsQuery, PostCandidate> for ReverseChronPostsPip
FOLLOWING_POST_FETCH_SIZE
}
}

#[cfg(test)]
mod tests {
use super::*;

#[tokio::test]
async fn mock_pipeline_wires_content_controls_filter() {
let pipeline = ReverseChronPostsPipeline::mock().await;
assert_eq!(pipeline.filters().len(), 4);
}
}
214 changes: 214 additions & 0 deletions home-mixer/filters/following_content_controls_filter.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
use crate::models::candidate::PostCandidate;
use crate::models::query::ScoredPostsQuery;
use xai_candidate_pipeline::filter::{Filter, FilterResult};

pub struct FollowingContentControlsFilter;

impl Filter<ScoredPostsQuery, PostCandidate> for FollowingContentControlsFilter {
fn enable(&self, query: &ScoredPostsQuery) -> bool {
query.hides_replies() || query.hides_links() || query.hides_retweets()
}

fn filter(
&self,
query: &ScoredPostsQuery,
candidates: Vec<PostCandidate>,
) -> FilterResult<PostCandidate> {
let hide_replies = query.hides_replies();
let hide_links = query.hides_links();
let hide_retweets = query.hides_retweets();

let (removed, kept): (Vec<_>, Vec<_>) = candidates.into_iter().partition(|c| {
(hide_replies && c.in_reply_to_tweet_id.is_some())
|| (hide_retweets && c.retweeted_tweet_id.is_some())
|| (hide_links && candidate_has_link(c))
});

FilterResult { kept, removed }
}
}

fn candidate_has_link(candidate: &PostCandidate) -> bool {
text_has_link(&candidate.tweet_text)
|| candidate
.quoted_tweet_text
.as_deref()
.is_some_and(text_has_link)
|| candidate.ancestor_texts.values().any(|t| text_has_link(t))
}

fn text_has_link(text: &str) -> bool {
text.contains("https://")
|| text.contains("http://")
|| text.contains("t.co/")
|| contains_www_host(text)
}

fn contains_www_host(text: &str) -> bool {
text.match_indices("www.")
.any(|(idx, _)| idx == 0 || !text.as_bytes()[idx - 1].is_ascii_alphanumeric())
}

#[cfg(test)]
mod tests {
use super::*;

fn candidate(
tweet_id: u64,
in_reply_to_tweet_id: Option<u64>,
retweeted_tweet_id: Option<u64>,
tweet_text: &str,
) -> PostCandidate {
PostCandidate {
tweet_id,
in_reply_to_tweet_id,
retweeted_tweet_id,
tweet_text: tweet_text.to_string(),
..Default::default()
}
}

#[test]
fn disabled_when_no_content_controls() {
let query = ScoredPostsQuery::default();
assert!(!FollowingContentControlsFilter.enable(&query));
}

#[test]
fn enabled_for_each_viewer_preference() {
for query in [
ScoredPostsQuery {
hide_replies: true,
..Default::default()
},
ScoredPostsQuery {
exclude_replies: true,
..Default::default()
},
ScoredPostsQuery {
hide_links: true,
..Default::default()
},
ScoredPostsQuery {
exclude_retweets: true,
..Default::default()
},
] {
assert!(FollowingContentControlsFilter.enable(&query));
}
}

#[test]
fn hide_replies_drops_replies_keeps_originals() {
let query = ScoredPostsQuery {
hide_replies: true,
..Default::default()
};
let result = FollowingContentControlsFilter.filter(
&query,
vec![
candidate(1, Some(10), None, "reply"),
candidate(2, None, None, "original"),
candidate(3, None, Some(30), "retweet"),
],
);
assert_eq!(
result.kept.iter().map(|c| c.tweet_id).collect::<Vec<_>>(),
vec![2, 3]
);
assert_eq!(
result
.removed
.iter()
.map(|c| c.tweet_id)
.collect::<Vec<_>>(),
vec![1]
);
}

#[test]
fn exclude_replies_alias_drops_replies() {
let query = ScoredPostsQuery {
exclude_replies: true,
..Default::default()
};
let result = FollowingContentControlsFilter.filter(
&query,
vec![candidate(1, Some(10), None, "reply")],
);
assert_eq!(result.removed[0].tweet_id, 1);
assert!(result.kept.is_empty());
}

#[test]
fn hide_links_drops_url_cards_and_quoted_links() {
let query = ScoredPostsQuery {
hide_links: true,
..Default::default()
};
let mut quoted = candidate(2, None, None, "look");
quoted.quoted_tweet_text = Some("see https://example.com".to_string());
let result = FollowingContentControlsFilter.filter(
&query,
vec![
candidate(1, None, None, "plain text"),
candidate(3, None, None, "watch https://t.co/abc"),
quoted,
candidate(4, None, None, "www.example.com"),
candidate(5, None, None, "notwww.example"),
],
);
assert_eq!(
result.kept.iter().map(|c| c.tweet_id).collect::<Vec<_>>(),
vec![1, 5]
);
assert_eq!(
result
.removed
.iter()
.map(|c| c.tweet_id)
.collect::<Vec<_>>(),
vec![3, 2, 4]
);
}

#[test]
fn exclude_retweets_drops_retweets_keeps_replies() {
let query = ScoredPostsQuery {
exclude_retweets: true,
..Default::default()
};
let result = FollowingContentControlsFilter.filter(
&query,
vec![
candidate(1, None, Some(10), "rt"),
candidate(2, Some(20), None, "reply"),
candidate(3, None, None, "original"),
],
);
assert_eq!(
result.kept.iter().map(|c| c.tweet_id).collect::<Vec<_>>(),
vec![2, 3]
);
assert_eq!(
result
.removed
.iter()
.map(|c| c.tweet_id)
.collect::<Vec<_>>(),
vec![1]
);
}

#[test]
fn off_flags_keep_every_type() {
let query = ScoredPostsQuery::default();
let candidates = vec![
candidate(1, Some(10), None, "reply https://x.com"),
candidate(2, None, Some(20), "rt"),
];
let result = FollowingContentControlsFilter.filter(&query, candidates);
assert_eq!(result.kept.len(), 2);
assert!(result.removed.is_empty());
}
}
1 change: 1 addition & 0 deletions home-mixer/filters/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ pub mod core_data_hydration_filter;
pub mod dedup_conversation_filter;
pub mod drop_duplicates_filter;

pub mod following_content_controls_filter;
pub mod following_retweet_deduplication_filter;
pub mod following_viewer_muted_keyword_filter;
pub mod ineligible_subscription_filter;
Expand Down
24 changes: 24 additions & 0 deletions home-mixer/models/query.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,14 @@ pub struct ScoredPostsQuery {
pub topic_ids: Vec<i64>,
pub excluded_topic_ids: Vec<i64>,
pub exclude_videos: bool,
/// Viewer content-control: hide replies on Following / Ranked Following / For You.
pub hide_replies: bool,
/// Viewer content-control: hide posts that contain links.
pub hide_links: bool,
/// Alternate proto name for hide-replies (Twitter API `exclude_replies`).
pub exclude_replies: bool,
/// Viewer content-control: hide retweets. Night Owl used to `include:retweets` always.
pub exclude_retweets: bool,
#[serde(serialize_with = "serialize_in_network_replies")]
pub in_network_replies: InNetworkReplies,
pub viewer_minhash: Option<Vec<i64>>,
Expand Down Expand Up @@ -192,6 +200,10 @@ impl ScoredPostsQuery {
topic_ids,
excluded_topic_ids,
exclude_videos,
hide_replies: false,
hide_links: false,
exclude_replies: false,
exclude_retweets: false,
in_network_replies: Default::default(),
viewer_minhash: None,
ip_address,
Expand Down Expand Up @@ -242,6 +254,18 @@ impl ScoredPostsQuery {
pub fn has_excluded_topics(&self) -> bool {
!self.excluded_topic_ids.is_empty()
}

pub fn hides_replies(&self) -> bool {
self.hide_replies || self.exclude_replies
}

pub fn hides_links(&self) -> bool {
self.hide_links
}

pub fn hides_retweets(&self) -> bool {
self.exclude_retweets
}
}

impl GetTwitterContextViewer for ScoredPostsQuery {
Expand Down
8 changes: 8 additions & 0 deletions home-mixer/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,7 @@ impl QueryBuilder {
query.resurrection_time_ms = resurrection_time_ms;

query.dsp_client_context = proto_query.dsp_client_context;
apply_content_controls(&mut query, &proto_query);

let root_span = b3_info.root_span(info_span!(
"request",
Expand Down Expand Up @@ -265,6 +266,13 @@ impl QueryBuilder {
}
}

fn apply_content_controls(query: &mut ScoredPostsQuery, proto_query: &pb::ScoredPostsQuery) {
query.hide_replies = proto_query.hide_replies;
query.hide_links = proto_query.hide_links;
query.exclude_replies = proto_query.exclude_replies;
query.exclude_retweets = proto_query.exclude_retweets;
}

pub struct HomeMixerServer {
scored_posts: Arc<ScoredPostsServer>,
for_you: Arc<ForYouFeedServer>,
Expand Down
Loading