From ab8ac3657f4e8de778183bf99b41a5bb8c70326b Mon Sep 17 00:00:00 2001 From: 4cuck <40539855+4cuck@users.noreply.github.com> Date: Tue, 3 Mar 2026 20:09:00 -0500 Subject: [PATCH] tweets: render content disclosure labels and mixed media in quoted tweets --- flake.nix | 10 ++++++++++ public/css/tweet/base.css | 6 ++++++ src/api/endpoints.rs | 6 +++--- src/api/parser/tweet.rs | 12 ++++++++++++ src/api/schema.rs | 20 ++++++++++++++++++++ src/config.rs | 12 +++++++++++- src/types/tweet.rs | 3 +++ src/views/tweet.rs | 23 ++++++++++++++++++++++- 8 files changed, 87 insertions(+), 5 deletions(-) diff --git a/flake.nix b/flake.nix index 09ec190..633ac99 100644 --- a/flake.nix +++ b/flake.nix @@ -244,6 +244,16 @@ default = 2; description = "Max concurrent requests per session."; }; + paidEmoji = lib.mkOption { + type = lib.types.str; + default = "๐Ÿค"; + description = "Emoji for paid promotion disclosure labels."; + }; + aiEmoji = lib.mkOption { + type = lib.types.str; + default = "๐Ÿค–"; + description = "Emoji for AI-generated content disclosure labels."; + }; }; preferences = { diff --git a/public/css/tweet/base.css b/public/css/tweet/base.css index e99380f..3f370a9 100644 --- a/public/css/tweet/base.css +++ b/public/css/tweet/base.css @@ -243,6 +243,12 @@ a.tweet-stat { z-index: 2; } +.disclosure-label { + font-size: 0.82em; + color: var(--fg_faded); + margin-top: 6px; +} + .community-note { margin-top: 10px; border: solid 1px var(--dark_grey); diff --git a/src/api/endpoints.rs b/src/api/endpoints.rs index da3d172..d9ca97a 100644 --- a/src/api/endpoints.rs +++ b/src/api/endpoints.rs @@ -17,10 +17,10 @@ pub const BEARER_TOKEN_NO_TID: &str = // GraphQL endpoints pub const GRAPH_USER: &str = "-oaLodhGbbnzJBACb1kk2Q/UserByScreenName"; pub const GRAPH_USER_BY_ID: &str = "VN33vKXrPT7p35DgNR27aw/UserResultByIdQuery"; -pub const GRAPH_USER_TWEETS: &str = "oRJs8SLCRNRbQzuZG93_oA/UserTweets"; +pub const GRAPH_USER_TWEETS: &str = "N9_71NodX1yntoC5pa4IFw/UserTweets"; pub const GRAPH_USER_MEDIA: &str = "36oKqyQ7E_9CmtONGjJRsA/UserMedia"; pub const GRAPH_USER_MEDIA_V2: &str = "bp0e_WdXqgNBIwlLukzyYA/MediaTimelineV2"; -pub const GRAPH_TWEET_DETAIL: &str = "YVyS4SfwYW7Uw5qwy0mQCA/TweetDetail"; +pub const GRAPH_TWEET_DETAIL: &str = "flqCy6kvOMolEquuRpOaHQ/TweetDetail"; pub const GRAPH_SEARCH_TIMELINE: &str = "bshMIjqDk8LTXTq4w91WKw/SearchTimeline"; pub const GRAPH_LIST_BY_ID: &str = "cIUpT1UjuGgl_oWiY7Snhg/ListByRestId"; pub const GRAPH_LIST_BY_SLUG: &str = "K6wihoTiTrzNzSF8y1aeKQ/ListBySlug"; @@ -39,7 +39,7 @@ pub const GRAPHQL_URL: &str = "https://x.com/i/api/graphql"; pub const API_URL: &str = "https://api.x.com/graphql"; /// GraphQL features to include in requests. -pub const GQL_FEATURES: &str = r#"{"android_ad_formats_media_component_render_overlay_enabled":false,"android_graphql_skip_api_media_color_palette":false,"android_professional_link_spotlight_display_enabled":false,"blue_business_profile_image_shape_enabled":false,"commerce_android_shop_module_enabled":false,"creator_subscriptions_subscription_count_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"hidden_profile_likes_enabled":false,"highlights_tweets_tab_ui_enabled":false,"interactive_text_enabled":false,"longform_notetweets_consumption_enabled":true,"longform_notetweets_inline_media_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_richtext_consumption_enabled":true,"mobile_app_spotlight_module_enabled":false,"responsive_web_edit_tweet_api_enabled":true,"responsive_web_enhance_cards_enabled":false,"responsive_web_graphql_exclude_directive_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_media_download_video_enabled":false,"responsive_web_text_conversations_enabled":false,"responsive_web_twitter_article_tweet_consumption_enabled":true,"unified_cards_destination_url_params_enabled":false,"responsive_web_twitter_blue_verified_badge_is_enabled":true,"rweb_lists_timeline_redesign_enabled":true,"spaces_2022_h2_clipping":true,"spaces_2022_h2_spaces_communities":true,"standardized_nudges_misinfo":true,"subscriptions_verification_info_enabled":true,"subscriptions_verification_info_reason_enabled":true,"subscriptions_verification_info_verified_since_enabled":true,"super_follow_badge_privacy_enabled":false,"super_follow_exclusive_tweet_notifications_enabled":false,"super_follow_tweet_api_enabled":false,"super_follow_user_api_enabled":false,"tweet_awards_web_tipping_enabled":false,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"tweetypie_unmention_optimization_enabled":false,"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled":false,"verified_phone_label_enabled":false,"vibe_api_enabled":false,"view_counts_everywhere_api_enabled":true,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"rweb_tipjar_consumption_enabled":true,"profile_label_improvements_pcf_label_in_post_enabled":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_post_followups_enabled":true,"rweb_video_timestamps_enabled":false,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"immersive_video_status_linkable_timestamps":false,"articles_api_enabled":false,"responsive_web_grok_analysis_button_from_backend":true,"rweb_video_screen_enabled":false,"payments_enabled":false,"responsive_web_profile_redirect_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"profile_label_improvements_pcf_label_in_profile_enabled":false,"grok_android_analyze_trend_fetch_enabled":false,"grok_translations_community_note_auto_translation_is_enabled":false,"grok_translations_post_auto_translation_is_enabled":false,"grok_translations_community_note_translation_is_enabled":false,"grok_translations_timeline_user_bio_auto_translation_is_enabled":false,"subscriptions_feature_can_gift_premium":false,"responsive_web_twitter_article_notes_tab_enabled":false,"subscriptions_verification_info_is_identity_verified_enabled":false,"hidden_profile_subscriptions_enabled":false}"#; +pub const GQL_FEATURES: &str = r#"{"android_ad_formats_media_component_render_overlay_enabled":false,"android_graphql_skip_api_media_color_palette":false,"android_professional_link_spotlight_display_enabled":false,"blue_business_profile_image_shape_enabled":false,"commerce_android_shop_module_enabled":false,"creator_subscriptions_subscription_count_enabled":false,"creator_subscriptions_tweet_preview_api_enabled":true,"freedom_of_speech_not_reach_fetch_enabled":true,"graphql_is_translatable_rweb_tweet_is_translatable_enabled":true,"hidden_profile_likes_enabled":false,"highlights_tweets_tab_ui_enabled":false,"interactive_text_enabled":false,"longform_notetweets_consumption_enabled":true,"longform_notetweets_inline_media_enabled":true,"longform_notetweets_rich_text_read_enabled":true,"longform_notetweets_richtext_consumption_enabled":true,"mobile_app_spotlight_module_enabled":false,"responsive_web_edit_tweet_api_enabled":true,"responsive_web_enhance_cards_enabled":false,"responsive_web_graphql_exclude_directive_enabled":true,"responsive_web_graphql_skip_user_profile_image_extensions_enabled":false,"responsive_web_graphql_timeline_navigation_enabled":true,"responsive_web_media_download_video_enabled":false,"responsive_web_text_conversations_enabled":false,"responsive_web_twitter_article_tweet_consumption_enabled":true,"unified_cards_destination_url_params_enabled":false,"responsive_web_twitter_blue_verified_badge_is_enabled":true,"rweb_lists_timeline_redesign_enabled":true,"spaces_2022_h2_clipping":true,"spaces_2022_h2_spaces_communities":true,"standardized_nudges_misinfo":true,"subscriptions_verification_info_enabled":true,"subscriptions_verification_info_reason_enabled":true,"subscriptions_verification_info_verified_since_enabled":true,"super_follow_badge_privacy_enabled":false,"super_follow_exclusive_tweet_notifications_enabled":false,"super_follow_tweet_api_enabled":false,"super_follow_user_api_enabled":false,"tweet_awards_web_tipping_enabled":false,"tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled":true,"tweetypie_unmention_optimization_enabled":false,"unified_cards_ad_metadata_container_dynamic_card_content_query_enabled":false,"verified_phone_label_enabled":false,"vibe_api_enabled":false,"view_counts_everywhere_api_enabled":true,"premium_content_api_read_enabled":false,"communities_web_enable_tweet_community_results_fetch":true,"responsive_web_jetfuel_frame":true,"responsive_web_grok_analyze_button_fetch_trends_enabled":false,"responsive_web_grok_image_annotation_enabled":true,"responsive_web_grok_imagine_annotation_enabled":true,"rweb_tipjar_consumption_enabled":true,"profile_label_improvements_pcf_label_in_post_enabled":true,"creator_subscriptions_quote_tweet_preview_enabled":false,"c9s_tweet_anatomy_moderator_badge_enabled":true,"responsive_web_grok_analyze_post_followups_enabled":true,"rweb_video_timestamps_enabled":false,"responsive_web_grok_share_attachment_enabled":true,"articles_preview_enabled":true,"immersive_video_status_linkable_timestamps":false,"articles_api_enabled":false,"responsive_web_grok_analysis_button_from_backend":true,"rweb_video_screen_enabled":false,"payments_enabled":false,"responsive_web_profile_redirect_enabled":false,"responsive_web_grok_show_grok_translated_post":false,"responsive_web_grok_community_note_auto_translation_is_enabled":false,"profile_label_improvements_pcf_label_in_profile_enabled":false,"grok_android_analyze_trend_fetch_enabled":false,"grok_translations_community_note_auto_translation_is_enabled":false,"grok_translations_post_auto_translation_is_enabled":false,"grok_translations_community_note_translation_is_enabled":false,"grok_translations_timeline_user_bio_auto_translation_is_enabled":false,"subscriptions_feature_can_gift_premium":false,"responsive_web_twitter_article_notes_tab_enabled":false,"subscriptions_verification_info_is_identity_verified_enabled":false,"hidden_profile_subscriptions_enabled":false,"content_disclosure_indicator_enabled":true,"content_disclosure_ai_generated_indicator_enabled":true}"#; pub const USER_FIELD_TOGGLES: &str = r#"{"withPayments":false,"withAuxiliaryUserLabels":true}"#; pub const USER_TWEETS_FIELD_TOGGLES: &str = r#"{"withArticlePlainText":false}"#; diff --git a/src/api/parser/tweet.rs b/src/api/parser/tweet.rs index 309355e..7728aac 100644 --- a/src/api/parser/tweet.rs +++ b/src/api/parser/tweet.rs @@ -349,6 +349,16 @@ impl TryFrom<&TweetData> for Tweet { .as_ref() .and_then(super::super::schema::BirdwatchPivot::to_note); + // Parse content disclosure labels + let (paid_promotion, ai_generated) = raw + .content_disclosure + .as_ref() + .map_or((false, false), |cd| { + let paid = cd.advertising_disclosure.as_ref().is_some_and(|ad| ad.is_paid_promotion); + let ai = cd.ai_generated_disclosure.as_ref().is_some_and(|ag| ag.has_ai_generated_media); + (paid, ai) + }); + // Parse edit history IDs let history = raw .edit_control @@ -503,6 +513,8 @@ impl TryFrom<&TweetData> for Tweet { video, photos, note, + paid_promotion, + ai_generated, history, entities, }) diff --git a/src/api/schema.rs b/src/api/schema.rs index 82941db..d0a07b9 100644 --- a/src/api/schema.rs +++ b/src/api/schema.rs @@ -437,6 +437,25 @@ pub struct ListData { // โ”€โ”€ Tweet types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ +#[derive(Deserialize, Default)] +#[serde(default)] +pub struct ContentDisclosure { + pub advertising_disclosure: Option, + pub ai_generated_disclosure: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +pub struct AdvertisingDisclosure { + pub is_paid_promotion: bool, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +pub struct AiGeneratedDisclosure { + pub has_ai_generated_media: bool, +} + #[derive(Deserialize, Default)] pub struct TweetData { #[expect(clippy::pub_underscore_fields, reason = "serde field name matches API")] @@ -465,6 +484,7 @@ pub struct TweetData { pub birdwatch_pivot: Option, pub edit_control: Option, pub article: Option, + pub content_disclosure: Option, } #[derive(Deserialize, Default)] diff --git a/src/config.rs b/src/config.rs index dda6f31..a672d3c 100644 --- a/src/config.rs +++ b/src/config.rs @@ -75,7 +75,11 @@ pub struct AppConfig { #[serde(default, rename = "disableTid")] pub disable_tid: bool, #[serde(default = "default_max_concurrent_reqs", rename = "maxConcurrentReqs")] - pub max_concurrent_reqs: u32, + pub max_concurrent_reqs: u32, + #[serde(default = "default_paid_emoji", rename = "paidEmoji")] + pub paid_emoji: String, + #[serde(default = "default_ai_emoji", rename = "aiEmoji")] + pub ai_emoji: String, } #[derive(Debug, Clone, Deserialize, Default)] @@ -126,6 +130,12 @@ const fn default_max_concurrent_reqs() -> u32 { fn default_theme() -> String { "teapot".to_owned() } +fn default_paid_emoji() -> String { + "๐Ÿค".to_owned() +} +fn default_ai_emoji() -> String { + "๐Ÿค–".to_owned() +} const fn default_true() -> bool { true } diff --git a/src/types/tweet.rs b/src/types/tweet.rs index c9600df..5aa4e0d 100644 --- a/src/types/tweet.rs +++ b/src/types/tweet.rs @@ -287,6 +287,9 @@ pub struct Tweet { pub photos: Vec, /// Community note (Birdwatch) -- structured data, rendered in views. pub note: Option, + /// Content disclosure labels (paid promotion, AI-generated media). + pub paid_promotion: bool, + pub ai_generated: bool, /// Edit history tweet IDs. pub history: Vec, /// Text entities for expansion (mentions, hashtags, URLs). diff --git a/src/views/tweet.rs b/src/views/tweet.rs index ed7b5ae..fbeb522 100644 --- a/src/views/tweet.rs +++ b/src/views/tweet.rs @@ -266,6 +266,26 @@ impl<'a> TweetRenderer<'a> { (PreEscaped(&text_html)) } + // Content disclosure labels + @if display_tweet.paid_promotion || display_tweet.ai_generated { + @let paid_label = format!("{} Paid partnership", config.config.paid_emoji); + @let ai_label = format!("{} Made with AI", config.config.ai_emoji); + @if is_main { + @if display_tweet.paid_promotion { + div class="disclosure-label" { (paid_label) } + } + @if display_tweet.ai_generated { + div class="disclosure-label" { (ai_label) } + } + } @else { + @let labels: Vec<&str> = [ + display_tweet.paid_promotion.then_some(paid_label.as_str()), + display_tweet.ai_generated.then_some(ai_label.as_str()), + ].into_iter().flatten().collect(); + div class="disclosure-label" { (labels.join(" ยท ")) } + } + } + // Attribution (when another user owns the content) โ€” after tweet-content @if let Some(ref attr_user) = display_tweet.attribution { a class="attribution" href=(format!("/{}", attr_user.username)) { @@ -457,7 +477,8 @@ fn render_quote_media(quote: &Tweet, config: &Config, prefs: Option<&Prefs>) -> div class="quote-media-container" { @if !quote.photos.is_empty() { (render_photos("e.photos, config)) - } @else if let Some(ref video) = quote.video { + } + @if let Some(ref video) = quote.video { (render_video(video, config, prefs)) } @else if let Some(ref gif) = quote.gif { div class="attachments media-gif" {