discord: improve embeds

This commit is contained in:
4cuck committed 2026-07-15 16:26:29 -04:00
1 parent 07f3ed76b2
commit 5695b439c9
22 files changed
+2533 -286

No files matched your search

+2 -2
View File
@@ -55,8 +55,8 @@ kagiTokenFile = ""
[gifTranscoding]
# Transcode Twitter GIF MP4s to actual GIF format for Discord inline playback
# Modes: "off" (default), "local" (ffmpeg on server), "external" (URL rewrite)
mode = "off"
# Modes: "local" (default, ffmpeg on server), "off", "external" (URL rewrite)
mode = "local"
# Cache directory for transcoded GIFs (local mode only)
cacheDir = "./cache/gif"
# Maximum cache size in MB (local mode only)
+2 -1
View File
@@ -56,6 +56,7 @@
pkgs.pkg-config
pkgs.mold
pkgs.clang
pkgs.ffmpeg-headless
];
};
}
@@ -245,7 +246,7 @@
"local"
"external"
];
default = "off";
default = "local";
description = "GIF transcoding mode. 'local' requires ffmpeg, 'external' rewrites URLs to an external domain.";
};
cacheDir = lib.mkOption {
+8 -1
View File
@@ -2,6 +2,8 @@
lib,
rustPlatform,
pkg-config,
makeWrapper,
ffmpeg-headless,
}:
rustPlatform.buildRustPackage {
pname = "teapot";
@@ -20,7 +22,10 @@ rustPlatform.buildRustPackage {
cargoLock.lockFile = ../Cargo.lock;
nativeBuildInputs = [ pkg-config ];
nativeBuildInputs = [
pkg-config
makeWrapper
];
doCheck = false;
stripAllList = [ "bin" ];
@@ -29,6 +34,8 @@ rustPlatform.buildRustPackage {
mkdir -p $out/share/teapot
cp -r public $out/share/teapot/
cp -r config $out/share/teapot/
wrapProgram $out/bin/teapot \
--prefix PATH : ${lib.makeBinPath [ ffmpeg-headless ]}
'';
meta = {
+460 -26
View File
@@ -13,6 +13,10 @@ use super::{
};
use crate::{
api::schema::{
AudioSpaceData,
AudioSpaceMetadata,
BroadcastMetadata,
BroadcastsData,
ConversationData,
EditHistoryData,
GqlResponse,
@@ -22,6 +26,7 @@ use crate::{
ListTimelineData,
RetweetersData,
SearchTimelineData,
TweetData,
UserResultData,
UserTimelineData,
},
@@ -33,6 +38,7 @@ use crate::{
},
types::{
Article,
CardKind,
Conversation,
EditHistory,
GalleryPhoto,
@@ -45,8 +51,188 @@ use crate::{
Tweet,
User,
},
utils::formatters,
};
fn space_id_from_url(url: &str) -> Option<&str> {
url.split("/spaces/")
.nth(1)?
.split(['/', '?', '#'])
.next()
.filter(|id| !id.is_empty())
}
fn millis_to_time(milliseconds: i64) -> Option<time::OffsetDateTime> {
time::OffsetDateTime::from_unix_timestamp(milliseconds.checked_div(1_000)?).ok()
}
fn audio_space_status(metadata: &AudioSpaceMetadata) -> String {
match metadata.state.as_deref() {
Some("NotStarted") => {
metadata
.scheduled_start
.and_then(millis_to_time)
.map_or_else(
|| "Scheduled".to_owned(),
|time| format!("Scheduled · {}", formatters::format_tweet_time(time)),
)
},
Some("Running") => {
metadata
.total_live_listeners
.filter(|count| *count > 0)
.map_or_else(
|| "Live now".to_owned(),
|count| {
format!(
"Live now · {} listening",
formatters::abbreviate_number(count)
)
},
)
},
Some("Ended" | "TimedOut") if metadata.is_space_available_for_replay.unwrap_or(false) => {
metadata
.total_replay_watched
.filter(|count| *count > 0)
.map_or_else(
|| "Replay available".to_owned(),
|count| {
format!(
"Replay available · {} plays",
formatters::abbreviate_number(count)
)
},
)
},
Some("Ended" | "TimedOut") => "Space ended".to_owned(),
_ => String::new(),
}
}
fn audio_space_host(metadata: &AudioSpaceMetadata) -> String {
let Some(user) = metadata
.creator_results
.as_ref()
.and_then(|results| results.result.as_deref())
else {
return "X Space".to_owned();
};
let name = user
.core
.as_ref()
.and_then(|core| core.name.as_deref())
.or_else(|| {
user
.legacy
.as_ref()
.and_then(|legacy| legacy.name.as_deref())
})
.unwrap_or_default();
let username = user
.core
.as_ref()
.and_then(|core| core.screen_name.as_deref())
.or_else(|| {
user
.legacy
.as_ref()
.and_then(|legacy| legacy.screen_name.as_deref())
})
.unwrap_or_default();
hosted_by(name, username).unwrap_or_else(|| "X Space".to_owned())
}
fn hosted_by(name: &str, username: &str) -> Option<String> {
match (name.is_empty(), username.is_empty()) {
(false, false) => Some(format!("Hosted by {name} (@{username})")),
(false, true) => Some(format!("Hosted by {name}")),
(true, false) => Some(format!("Hosted by @{username}")),
(true, true) => None,
}
}
fn broadcast_id_from_url(url: &str) -> Option<&str> {
url.split("/broadcasts/")
.nth(1)?
.split(['/', '?', '#'])
.next()
.filter(|id| !id.is_empty())
}
fn broadcast_status(metadata: &BroadcastMetadata) -> String {
match metadata.state.as_str() {
"RUNNING" => {
metadata
.total_watching
.filter(|count| *count > 0)
.map_or_else(
|| "Live now".to_owned(),
|count| {
format!(
"Live now · {} watching",
formatters::abbreviate_number(count)
)
},
)
},
"ENDED" if metadata.available_for_replay => {
metadata
.total_watched
.filter(|count| *count > 0)
.map_or_else(
|| "Replay available".to_owned(),
|count| {
format!(
"Replay available · {} views",
formatters::abbreviate_number(count)
)
},
)
},
"ENDED" => {
metadata
.total_watched
.filter(|count| *count > 0)
.map_or_else(
|| "Broadcast ended".to_owned(),
|count| {
format!(
"Broadcast ended · {} views",
formatters::abbreviate_number(count)
)
},
)
},
_ => String::new(),
}
}
fn article_tweet_data<'a>(data: &'a ConversationData, tweet_id: &str) -> Option<&'a TweetData> {
let raw = data
.tweet_result
.as_ref()
.and_then(|nested| nested.result.as_deref())
.or_else(|| {
data
.threaded_conversation_with_injections_v2
.as_ref()?
.instructions
.iter()
.filter_map(|instruction| instruction.entries.as_deref())
.flatten()
.find(|entry| {
entry
.entry_id_str()
.starts_with(&format!("tweet-{tweet_id}"))
})
.and_then(|entry| entry.tweet_result())
})?;
raw.tweet.as_deref().or(Some(raw))
}
/// Twitter/X API client.
#[derive(Clone)]
pub struct ApiClient {
@@ -347,6 +533,82 @@ impl ApiClient {
Ok(resp.data)
}
async fn cookie_json_request<T: DeserializeOwned>(
&self,
session_key: &str,
api_path: &str,
url: &str,
) -> Result<T> {
let session = self.sessions.get_session(session_key).await?;
if !matches!(session.kind, SessionKind::Cookie) {
return Err(Error::Internal(
"This X endpoint requires a cookie session".into(),
));
}
let (bearer, tid) = self
.tid
.generate(api_path)
.await
.map_or((endpoints::BEARER_TOKEN_NO_TID, None), |tid_val| {
(endpoints::BEARER_TOKEN, Some(tid_val))
});
let mut headers = header::HeaderMap::new();
headers.insert(
header::AUTHORIZATION,
header::HeaderValue::from_str(bearer)
.map_err(|_| Error::Internal("invalid bearer token value".into()))?,
);
headers.insert(
"x-twitter-auth-type",
header::HeaderValue::from_static("OAuth2Session"),
);
headers.insert(
"x-csrf-token",
session
.ct0
.parse()
.map_err(|_| Error::Internal("invalid ct0 header value".into()))?,
);
headers.insert(
header::COOKIE,
format!("auth_token={}; ct0={}", session.auth_token, session.ct0)
.parse()
.map_err(|_| Error::Internal("invalid cookie header value".into()))?,
);
headers.insert(
header::ORIGIN,
header::HeaderValue::from_static("https://x.com"),
);
headers.insert(header::ACCEPT, header::HeaderValue::from_static("*/*"));
headers.insert(
"x-twitter-active-user",
header::HeaderValue::from_static("yes"),
);
headers.insert(
"x-twitter-client-language",
header::HeaderValue::from_static("en"),
);
if let Some(tid) = tid
&& let Ok(value) = tid.parse()
{
headers.insert("x-client-transaction-id", value);
}
let response = self.client.get_with_headers(url, &headers).await?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(Error::TwitterApi(format!(
"X API request failed ({status}): {body}"
)));
}
let bytes = response.bytes().await?;
serde_json::from_slice(&bytes)
.map_err(|err| Error::Internal(format!("Response parse error: {err}")))
}
/// Get user by screen name.
pub async fn get_user(&self, screen_name: &str) -> Result<User> {
let data = self
@@ -414,6 +676,94 @@ impl ApiClient {
}
}
async fn enrich_audio_space_card(&self, tweet: &mut Tweet) {
let Some(space_id) = tweet
.card
.as_ref()
.filter(|card| card.kind == CardKind::Audiospace)
.and_then(|card| space_id_from_url(&card.url))
.map(str::to_owned)
else {
return;
};
let Ok(data) = self
.graphql_request::<AudioSpaceData>(
endpoints::GRAPH_AUDIO_SPACE,
&endpoints::audio_space_vars(&space_id),
endpoints::GQL_FEATURES,
None,
)
.await
else {
return;
};
let Some(metadata) = data.audio_space.and_then(|space| space.metadata) else {
return;
};
let Some(card) = tweet
.card
.as_mut()
.filter(|card| card.kind == CardKind::Audiospace)
else {
return;
};
if let Some(title) = metadata.title.as_deref().filter(|title| !title.is_empty()) {
title.clone_into(&mut card.title);
}
card.text = audio_space_status(&metadata);
card.dest = audio_space_host(&metadata);
}
async fn enrich_broadcast_card(&self, tweet: &mut Tweet) {
let Some(broadcast_id) = tweet
.card
.as_ref()
.filter(|card| matches!(card.kind, CardKind::Broadcast | CardKind::Periscope))
.and_then(|card| broadcast_id_from_url(&card.url))
.map(str::to_owned)
else {
return;
};
let url = endpoints::broadcast_show_url(&broadcast_id);
let Ok(data) = self
.cookie_json_request::<BroadcastsData>(
endpoints::BROADCAST_SHOW_PATH,
endpoints::BROADCAST_SHOW_PATH,
&url,
)
.await
else {
return;
};
let Some(metadata) = data.broadcasts.get(&broadcast_id) else {
return;
};
let Some(card) = tweet
.card
.as_mut()
.filter(|card| matches!(card.kind, CardKind::Broadcast | CardKind::Periscope))
else {
return;
};
if !metadata.status.is_empty() {
metadata.status.clone_into(&mut card.title);
} else if !card.text.is_empty() {
card.text.clone_into(&mut card.title);
}
card.text = broadcast_status(metadata);
card.dest = hosted_by(&metadata.user_display_name, &metadata.twitter_username)
.unwrap_or_else(|| "Live broadcast".to_owned());
if !metadata.image_url.is_empty() {
metadata.image_url.clone_into(&mut card.image);
if let Some(video) = card.video.as_mut() {
metadata.image_url.clone_into(&mut video.thumb);
}
}
}
/// Get conversation/thread for a tweet.
pub async fn get_conversation(
&self,
@@ -429,7 +779,28 @@ impl ApiClient {
Some(endpoints::TWEET_DETAIL_FIELD_TOGGLES),
)
.await?;
super::parse_conversation(&data, tweet_id, cursor.is_some())
let mut conversation = super::parse_conversation(&data, tweet_id, cursor.is_some())?;
if cursor.is_none()
&& let Some(tweet_data) = article_tweet_data(&data, tweet_id)
&& let Ok(article) = parser::parse_article(tweet_data)
{
parser::attach_article_preview(&mut conversation.tweet, &article);
}
if cursor.is_none()
&& conversation
.tweet
.entities
.iter()
.any(|entity| entity.url.contains("/article/"))
&& let Ok((_tweet, article)) = self.get_article_tweet(tweet_id).await
{
parser::attach_article_preview(&mut conversation.tweet, &article);
}
if cursor.is_none() {
self.enrich_audio_space_card(&mut conversation.tweet).await;
self.enrich_broadcast_card(&mut conversation.tweet).await;
}
Ok(conversation)
}
/// Get user's tweets timeline.
@@ -703,34 +1074,12 @@ impl ApiClient {
let conversation = super::parse_conversation(&data, tweet_id, false)?;
let tweet = conversation.tweet;
// Extract raw TweetData for the article — try single-tweet path first,
// then scan timeline entries. Handle TweetWithVisibilityResults wrapper.
let raw = data
.tweet_result
.as_ref()
.and_then(|nested| nested.result.as_deref())
.or_else(|| {
data
.threaded_conversation_with_injections_v2
.as_ref()?
.instructions
.iter()
.filter_map(|instr| instr.entries.as_deref())
.flatten()
.find(|entry| {
entry
.entry_id_str()
.starts_with(&format!("tweet-{tweet_id}"))
})
.and_then(|entry| entry.tweet_result())
});
// Unwrap TweetWithVisibilityResults if needed
let tweet_data = raw
.and_then(|td| td.tweet.as_deref().or(Some(td)))
let tweet_data = article_tweet_data(&data, tweet_id)
.ok_or_else(|| Error::TweetNotFound("Tweet data not found in response".into()))?;
let article = parser::parse_article(tweet_data)?;
let mut tweet = tweet;
parser::attach_article_preview(&mut tweet, &article);
Ok((tweet, article))
}
@@ -1000,3 +1349,88 @@ impl ApiClient {
Ok(photos)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn formats_scheduled_audio_space_status() {
let metadata = AudioSpaceMetadata {
state: Some("NotStarted".to_owned()),
scheduled_start: Some(1_784_203_200_000),
..AudioSpaceMetadata::default()
};
assert_eq!(
audio_space_status(&metadata),
"Scheduled · Jul 16, 2026 · 12:00 PM UTC"
);
}
#[test]
fn parses_recorded_audio_space_metadata() {
let data: AudioSpaceData = serde_json::from_str(
r#"{
"audioSpace": {
"metadata": {
"ended_at": "1784107519245",
"is_space_available_for_replay": true,
"state": "TimedOut",
"title": "Interlink enters a new phase",
"total_replay_watched": 4
}
}
}"#,
)
.unwrap();
let metadata = data.audio_space.unwrap().metadata.unwrap();
assert_eq!(metadata.ended_at, Some(1_784_107_519_245));
assert_eq!(audio_space_status(&metadata), "Replay available · 4 plays");
}
#[test]
fn parses_live_broadcast_metadata() {
let data: BroadcastsData = serde_json::from_str(
r#"{
"broadcasts": {
"1XxyggAaLzvGM": {
"available_for_replay": true,
"image_url": "https://video.pscp.tv/latest.jpg",
"state": "RUNNING",
"status": "Stripe x PayPal",
"total_watched": "26",
"total_watching": "26",
"twitter_username": "tbpn",
"user_display_name": "TBPN"
}
}
}"#,
)
.unwrap();
let metadata = &data.broadcasts["1XxyggAaLzvGM"];
assert_eq!(broadcast_status(metadata), "Live now · 26 watching");
assert_eq!(
hosted_by(&metadata.user_display_name, &metadata.twitter_username),
Some("Hosted by TBPN (@tbpn)".to_owned())
);
}
#[test]
fn extracts_audio_space_id_from_canonical_url() {
assert_eq!(
space_id_from_url("https://x.com/i/spaces/1AxRnnrNvyDxl/peek?foo=bar"),
Some("1AxRnnrNvyDxl")
);
}
#[test]
fn extracts_broadcast_id_from_canonical_url() {
assert_eq!(
broadcast_id_from_url("https://x.com/i/broadcasts/1XxyggAaLzvGM?foo=bar"),
Some("1XxyggAaLzvGM")
);
}
}
+16
View File
@@ -31,6 +31,8 @@ pub const GRAPH_USER_TWEETS_AND_REPLIES_V2: &str =
"BDX77Xzqypdt11-mDfgdpQ/UserWithProfileTweetsAndRepliesQueryV2";
pub const GRAPH_TWEET_EDIT_HISTORY: &str = "upS9teTSG45aljmP9oTuXA/TweetEditHistory";
pub const GRAPH_RETWEETERS: &str = "tj-dlOvzRKjw69iy4z3LzQ/Retweeters";
pub const GRAPH_AUDIO_SPACE: &str = "xpwpkJD3FetGBaSq7zH4Lw/AudioSpaceById";
pub const BROADCAST_SHOW_PATH: &str = "/i/api/1.1/broadcasts/show.json";
pub const USER_AGENT: &str = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 \
(KHTML, like Gecko) Chrome/142.0.0.0 Safari/537.36";
@@ -127,6 +129,20 @@ pub fn retweeters_vars(tweet_id: &str, cursor: Option<&str>) -> String {
}))
}
pub fn audio_space_vars(space_id: &str) -> String {
json!({
"id": space_id,
"isMetatagsQuery": false,
"withReplays": true,
"withListeners": true,
})
.to_string()
}
pub fn broadcast_show_url(broadcast_id: &str) -> String {
format!("https://x.com{BROADCAST_SHOW_PATH}?ids={broadcast_id}&include_events=false")
}
pub fn search_vars(raw_query: &str, cursor: Option<&str>, product: &str) -> String {
vars(json!({
"rawQuery": raw_query, "cursor": cursor, "count": 20,
+58
View File
@@ -22,6 +22,10 @@ use crate::{
ArticleParagraph,
ArticleStyle,
ArticleStyleRange,
Card,
CardKind,
EntityKind,
Tweet,
User,
},
};
@@ -47,6 +51,60 @@ pub fn parse_article(tweet_data: &TweetData) -> Result<Article> {
Ok(parse_inline_article(inline, user))
}
pub fn attach_article_preview(tweet: &mut Tweet, article: &Article) {
let has_usable_card = tweet
.card
.as_ref()
.is_some_and(|card| !matches!(card.kind, CardKind::Hidden | CardKind::Unknown));
if article.title.is_empty() || has_usable_card {
return;
}
if let Some(start) = tweet
.entities
.iter()
.filter(|entity| entity.kind == EntityKind::Url && entity.url.contains("/article/"))
.map(|entity| entity.indices.0)
.min()
{
let byte_start = tweet
.text
.char_indices()
.nth(start)
.map_or(tweet.text.len(), |(index, _)| index);
tweet.text.truncate(byte_start);
tweet.text = tweet.text.trim_end().to_owned();
tweet
.entities
.retain(|entity| entity.kind != EntityKind::Url || !entity.url.contains("/article/"));
}
let description = article
.paragraphs
.iter()
.find(|paragraph| {
paragraph.base_type == ArticleBlockType::Unstyled && !paragraph.text.trim().is_empty()
})
.map(|paragraph| {
let mut excerpt: String = paragraph.text.chars().take(200).collect();
if paragraph.text.chars().count() > 200 {
excerpt.push('…');
}
excerpt
})
.unwrap_or_default();
tweet.card = Some(Card {
kind: CardKind::Article,
url: format!("/{}/article/{}", tweet.user.username, tweet.id),
title: article.title.clone(),
image: article.cover_image.clone(),
text: description,
dest: format!("Article · @{}", tweet.user.username),
..Card::default()
});
}
fn parse_inline_article(raw: &InlineArticle, user: User) -> Article {
let title = raw.title.clone().unwrap_or_default();
+6
View File
@@ -134,6 +134,12 @@ impl TryFrom<&CardData> for Card {
CardKind::LiveEvent => {
bv.string("event_title").clone_into(&mut card_text);
},
CardKind::Audiospace => {
let space_id = bv.string("id");
if !space_id.is_empty() {
card_url = format!("https://x.com/i/spaces/{space_id}");
}
},
CardKind::Player => {
let player_url = bv.string("player_url");
if !player_url.is_empty() {
+80 -7
View File
@@ -19,12 +19,13 @@ use crate::{
/// Parsed media result (no side effects on tweet text).
pub struct ParsedMedia {
pub photos: Vec<Photo>,
pub video: Option<Video>,
pub gif: Option<Gif>,
pub attribution: Option<User>,
pub photos: Vec<Photo>,
pub video: Option<Video>,
pub additional_videos: Vec<Video>,
pub gif: Option<Gif>,
pub attribution: Option<User>,
/// t.co and expanded URLs that should be stripped from tweet text.
pub strip_urls: Vec<String>,
pub strip_urls: Vec<String>,
}
/// Parse media from tweet legacy data.
@@ -34,6 +35,7 @@ pub struct ParsedMedia {
pub fn parse_media(legacy: &TweetLegacy) -> ParsedMedia {
let mut photos = Vec::new();
let mut video = None;
let mut additional_videos = Vec::new();
let mut gif = None;
let mut attribution = None;
let mut strip_urls = Vec::new();
@@ -47,11 +49,18 @@ pub fn parse_media(legacy: &TweetLegacy) -> ParsedMedia {
photos.push(Photo {
url: url.to_owned(),
alt_text: media.ext_alt_text.clone().unwrap_or_default(),
width: media.original_info.as_ref().map_or(0, |info| info.width),
height: media.original_info.as_ref().map_or(0, |info| info.height),
});
}
},
MediaType::Video => {
video = Some(parse_video(media));
let parsed = parse_video(media);
if video.is_none() {
video = Some(parsed);
} else {
additional_videos.push(parsed);
}
// Parse media attribution from additional_media_info.source_user
if let Some(ref info) = media.additional_media_info
&& let Some(ref source_user) = info.source_user
@@ -79,6 +88,7 @@ pub fn parse_media(legacy: &TweetLegacy) -> ParsedMedia {
ParsedMedia {
photos,
video,
additional_videos,
gif,
attribution,
strip_urls,
@@ -192,5 +202,68 @@ pub fn parse_gif(media: &MediaItem) -> Gif {
.unwrap_or_default()
.to_owned();
Gif { url, thumb }
let (width, height) = media
.original_info
.as_ref()
.map_or((0, 0), |info| (info.width, info.height));
Gif {
url,
thumb,
alt_text: media.ext_alt_text.clone().unwrap_or_default(),
width,
height,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::*;
#[test]
fn parse_media_preserves_multiple_videos() {
let legacy: TweetLegacy = serde_json::from_value(json!({
"extended_entities": {
"media": [
{
"type": "video",
"media_url_https": "https://pbs.twimg.com/first.jpg",
"video_info": {
"variants": [{
"content_type": "video/mp4",
"bitrate": 832000,
"url": "https://video.twimg.com/vid/640x360/first.mp4"
}]
}
},
{
"type": "video",
"media_url_https": "https://pbs.twimg.com/second.jpg",
"video_info": {
"variants": [{
"content_type": "video/mp4",
"bitrate": 832000,
"url": "https://video.twimg.com/vid/1280x720/second.mp4"
}]
}
}
]
}
}))
.unwrap();
let parsed = parse_media(&legacy);
assert_eq!(
parsed.video.as_ref().map(|video| video.url.as_str()),
Some("https://video.twimg.com/vid/640x360/first.mp4")
);
assert_eq!(parsed.additional_videos.len(), 1);
assert_eq!(
parsed.additional_videos[0].url,
"https://video.twimg.com/vid/1280x720/second.mp4"
);
}
}
+4 -1
View File
@@ -9,7 +9,10 @@ mod timeline;
mod tweet;
mod user;
pub use article::parse_article;
pub use article::{
attach_article_preview,
parse_article,
};
pub use conversation::parse_conversation;
pub use search::{
parse_list,
+28 -8
View File
@@ -124,6 +124,7 @@ impl TryFrom<&TweetData> for Tweet {
let media = parse_media(legacy);
let mut photos = media.photos;
let video = media.video;
let additional_videos = media.additional_videos;
let gif = media.gif;
let media_attribution = media.attribution;
@@ -171,6 +172,7 @@ impl TryFrom<&TweetData> for Tweet {
{
reply = vec![screen_name.clone()];
}
let mut reply_mentions_stripped = false;
// Parse thread info
let has_thread = legacy.self_thread.is_some();
@@ -294,13 +296,14 @@ impl TryFrom<&TweetData> for Tweet {
for mention in &ent.user_mentions {
let screen_name = mention.screen_name.as_deref().unwrap_or_default();
let (start, _) = indices(&mention.indices);
if start < display_start
&& !screen_name.is_empty()
&& !reply
if start < display_start && !screen_name.is_empty() {
reply_mentions_stripped = true;
if !reply
.iter()
.any(|existing| existing.eq_ignore_ascii_case(screen_name))
{
reply.push(screen_name.to_owned());
{
reply.push(screen_name.to_owned());
}
}
}
}
@@ -308,6 +311,7 @@ impl TryFrom<&TweetData> for Tweet {
if display_start > 0 && note_tweet.is_none() {
for entity in &entities {
if entity.indices.0 < display_start && entity.kind == EntityKind::Mention {
reply_mentions_stripped = true;
// url is "/{screen_name}", strip the leading "/"
let screen_name = entity.url.trim_start_matches('/');
if !screen_name.is_empty()
@@ -350,7 +354,7 @@ impl TryFrom<&TweetData> for Tweet {
.and_then(super::super::schema::BirdwatchPivot::to_note);
// Parse content disclosure labels
let (paid_promotion, ai_generated) =
let (paid_promotion, disclosed_ai) =
raw.content_disclosure
.as_ref()
.map_or((false, false), |cd| {
@@ -364,6 +368,13 @@ impl TryFrom<&TweetData> for Tweet {
.is_some_and(|ag| ag.has_ai_generated_media);
(paid, ai)
});
let ai_generated = disclosed_ai
|| legacy.media_items().iter().any(|media| {
media
.grok_post_id
.as_deref()
.is_some_and(|id| !id.is_empty())
});
// Parse edit history IDs
let history = raw
@@ -384,6 +395,8 @@ impl TryFrom<&TweetData> for Tweet {
photos.push(Photo {
url: img_url.clone(),
alt_text: String::new(),
width: 0,
height: 0,
});
}
@@ -414,7 +427,10 @@ impl TryFrom<&TweetData> for Tweet {
// withArticleRichContentState is true. When we have a card, strip the
// article URL from the text (the card is clickable). Otherwise, keep it
// as a "View article" text link fallback.
let has_article_card = card.is_none() && {
let can_replace_card = card
.as_ref()
.is_none_or(|card| matches!(card.kind, CardKind::Hidden | CardKind::Unknown));
let has_article_card = can_replace_card && {
if let Some(article) = raw
.article
.as_ref()
@@ -495,6 +511,7 @@ impl TryFrom<&TweetData> for Tweet {
});
let lang = legacy.lang.as_deref().unwrap_or_default().to_owned();
let sensitive = legacy.possibly_sensitive.unwrap_or(false);
Ok(Self {
id,
@@ -504,12 +521,13 @@ impl TryFrom<&TweetData> for Tweet {
text: text.clone(),
time,
reply,
reply_mentions_stripped,
pinned: false,
has_thread,
available: !is_withheld,
tombstone: if is_withheld { text } else { String::new() },
location,
source: String::new(),
source: raw.source.clone().unwrap_or_default(),
stats,
retweet,
attribution: media_attribution,
@@ -519,8 +537,10 @@ impl TryFrom<&TweetData> for Tweet {
poll,
gif,
video,
additional_videos,
photos,
note,
sensitive,
paid_promotion,
ai_generated,
history,
+90 -1
View File
@@ -7,7 +7,10 @@ use serde::{
Deserialize,
Deserializer,
Serialize,
de::IgnoredAny,
de::{
Error as _,
IgnoredAny,
},
};
use crate::utils::formatters::parse_twitter_time;
@@ -327,12 +330,20 @@ pub struct MediaItem {
#[serde(rename = "type")]
pub media_type: Option<MediaType>,
pub media_url_https: Option<String>,
pub original_info: Option<MediaOriginalInfo>,
pub url: Option<String>,
pub expanded_url: Option<String>,
pub video_info: Option<VideoInfo>,
pub ext_media_availability: Option<MediaAvailability>,
pub additional_media_info: Option<AdditionalMediaInfo>,
pub ext_alt_text: Option<String>,
pub grok_post_id: Option<String>,
}
#[derive(Deserialize, Default)]
pub struct MediaOriginalInfo {
pub width: i32,
pub height: i32,
}
#[derive(Deserialize, Default)]
@@ -489,6 +500,7 @@ pub struct TweetData {
pub article: Option<ArticleWrapper>,
pub content_disclosure: Option<ContentDisclosure>,
pub is_translatable: Option<bool>,
pub source: Option<String>,
}
#[derive(Deserialize, Default)]
@@ -515,6 +527,7 @@ pub struct TweetLegacy {
pub is_quote_status: Option<bool>,
pub created_at_ms: Option<i64>,
pub lang: Option<String>,
pub possibly_sensitive: Option<bool>,
pub entities: Option<Entities>,
pub extended_entities: Option<ExtendedEntities>,
}
@@ -1000,6 +1013,82 @@ pub struct GqlResponse<T> {
pub data: T,
}
// ── X Spaces ──
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct AudioSpaceData {
#[serde(rename = "audioSpace")]
pub audio_space: Option<AudioSpace>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct AudioSpace {
pub metadata: Option<AudioSpaceMetadata>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct AudioSpaceMetadata {
pub rest_id: Option<String>,
pub state: Option<String>,
pub title: Option<String>,
#[serde(default, deserialize_with = "deser_optional_i64")]
pub scheduled_start: Option<i64>,
#[serde(default, deserialize_with = "deser_optional_i64")]
pub started_at: Option<i64>,
#[serde(default, deserialize_with = "deser_optional_i64")]
pub ended_at: Option<i64>,
pub is_space_available_for_replay: Option<bool>,
#[serde(default, deserialize_with = "deser_optional_i64")]
pub total_live_listeners: Option<i64>,
#[serde(default, deserialize_with = "deser_optional_i64")]
pub total_replay_watched: Option<i64>,
pub creator_results: Option<NestedResult<UserData>>,
}
// ── Live broadcasts ──
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct BroadcastsData {
pub broadcasts: HashMap<String, BroadcastMetadata>,
}
#[derive(Deserialize, Default)]
#[serde(default)]
pub struct BroadcastMetadata {
pub status: String,
pub image_url: String,
pub state: String,
pub user_display_name: String,
pub twitter_username: String,
pub available_for_replay: bool,
#[serde(default, deserialize_with = "deser_optional_i64")]
pub total_watching: Option<i64>,
#[serde(default, deserialize_with = "deser_optional_i64")]
pub total_watched: Option<i64>,
}
#[derive(Deserialize)]
#[serde(untagged)]
enum I64OrString {
I64(i64),
String(String),
}
fn deser_optional_i64<'de, D: Deserializer<'de>>(de: D) -> result::Result<Option<i64>, D::Error> {
Option::<I64OrString>::deserialize(de)?
.map(|value| {
match value {
I64OrString::I64(value) => Ok(value),
I64OrString::String(value) => value.parse().map_err(D::Error::custom),
}
})
.transpose()
}
/// `{timeline: {instructions: [...]}}` — shared by all timeline-shaped
/// endpoints.
#[derive(Deserialize, Default)]
+2 -2
View File
@@ -14,8 +14,8 @@ use crate::error::{
#[derive(Debug, Clone, Copy, Default, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum GifTranscodingMode {
#[default]
Off,
#[default]
Local,
External,
}
@@ -35,7 +35,7 @@ pub struct GifTranscodingConfig {
impl Default for GifTranscodingConfig {
fn default() -> Self {
Self {
mode: GifTranscodingMode::Off,
mode: GifTranscodingMode::Local,
cache_dir: default_gif_cache_dir(),
cache_max_mb: default_gif_cache_max_mb(),
external_domain: String::new(),
+97 -29
View File
@@ -22,10 +22,15 @@ use serde::Deserialize;
use crate::{
AppState,
cache::{
keys as cache_keys,
ttl,
},
error::{
Error,
Result,
},
types::Conversation,
utils::formatters::format_relative_time,
views::{
embed as embed_view,
@@ -35,9 +40,10 @@ use crate::{
#[derive(Debug, Deserialize)]
pub struct OEmbedQuery {
pub text: Option<String>,
pub author: Option<String>,
pub status: Option<String>,
pub text: Option<String>,
pub author: Option<String>,
pub status: Option<String>,
pub provider: Option<String>,
}
#[derive(Debug, Deserialize)]
@@ -60,6 +66,9 @@ pub fn router() -> Router<AppState> {
.route("/embed/Tweet.html", get(legacy_embed_redirect))
// ActivityPub endpoint for Discord multi-image support
.route("/users/{username}/statuses/{id}", get(activity_pub_status))
// Discord turns the Mastodon-looking discovery URL above into this
// API request; the discovery URL itself is not the JSON endpoint.
.route("/api/v1/statuses/{id}", get(mastodon_status))
// oEmbed endpoints
.route("/owoembed", get(oembed))
.route("/oembed", get(oembed_standard))
@@ -78,20 +87,21 @@ async fn legacy_embed_redirect(Query(query): Query<LegacyEmbedQuery>) -> Respons
async fn tweet_embed(
State(state): State<AppState>,
Path((_username, id)): Path<(String, String)>,
headers: HeaderMap,
) -> Result<Response> {
// Fetch tweet from API
let mut tweet = state.api.get_tweet(&id).await?;
state.api.resolve_unavailable_quote(&mut tweet).await;
let tweet = cached_conversation(&state, &id).await?.tweet;
let markup = embed_view::render_tweet_embed(&tweet, &state.config);
let discord_activity = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.is_some_and(|user_agent| user_agent.contains("Discordbot"));
let markup = embed_view::render_tweet_embed(&tweet, &state.config, discord_activity);
Ok(Html(markup.into_string()).into_response())
}
/// Video embed endpoint - returns HTML with video player.
async fn video_embed(State(state): State<AppState>, Path(id): Path<String>) -> Result<Response> {
// Fetch tweet from API
let mut tweet = state.api.get_tweet(&id).await?;
state.api.resolve_unavailable_quote(&mut tweet).await;
let tweet = cached_conversation(&state, &id).await?.tweet;
if !embed_view::has_playable_video(&tweet) {
return Err(Error::NotFound("No video found".into()));
@@ -126,24 +136,79 @@ async fn activity_pub_status(
|| accept.contains("application/activity+json")
|| accept.contains("application/ld+json")
{
// Fetch tweet from API
let mut tweet = state.api.get_tweet(&id).await?;
state.api.resolve_unavailable_quote(&mut tweet).await;
// Build ActivityPub JSON
let activity = embed_view::build_activity_pub(&tweet, &state.config);
return Ok((
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
Json(activity),
)
.into_response());
return activity_response(&state, &id).await;
}
// Otherwise redirect to normal status page
Ok(Redirect::to(&format!("/{username}/status/{id}")).into_response())
}
/// Mastodon API v1 status endpoint used by Discord after discovery.
async fn mastodon_status(
State(state): State<AppState>,
Path(id): Path<String>,
) -> Result<Response> {
activity_response(&state, &id).await
}
async fn activity_response(state: &AppState, id: &str) -> Result<Response> {
let conversation = cached_conversation(state, id).await?;
let reply_id = conversation.tweet.reply_id;
let mut replied_to = if reply_id > 0 {
conversation
.before
.content
.iter()
.find(|candidate| candidate.id == reply_id)
.cloned()
} else {
None
};
let mut tweet = conversation.tweet;
if replied_to.is_none() && reply_id > 0 && reply_id != tweet.id {
replied_to = cached_conversation(state, &reply_id.to_string())
.await
.ok()
.map(|context| context.tweet);
}
if tweet.is_translatable {
let kagi = &state.config.config.kagi_token;
let token = (!kagi.is_empty()).then_some(kagi.as_str());
if let Ok(translation) = state.api.translate_auto(id, token).await
&& !translation.text.is_empty()
{
tweet.translation = Some(translation);
}
}
let activity = replied_to.as_ref().map_or_else(
|| embed_view::build_activity_pub(&tweet, &state.config),
|original| embed_view::build_activity_pub_with_reply(&tweet, Some(original), &state.config),
);
Ok((
[(header::CONTENT_TYPE, "application/json; charset=utf-8")],
Json(activity),
)
.into_response())
}
async fn cached_conversation(state: &AppState, id: &str) -> Result<Conversation> {
let cache_key = cache_keys::conversation(id);
if let Some(cached) = state.cache.get::<Conversation>(&cache_key) {
return Ok(cached);
}
let mut conversation = state.api.get_conversation(id, None, "Relevance").await?;
state
.api
.resolve_unavailable_quote(&mut conversation.tweet)
.await;
state.cache.set(&cache_key, &conversation, ttl::DEFAULT);
Ok(conversation)
}
/// oEmbed JSON endpoint for engagement metrics in Discord embed footers.
async fn oembed(
State(state): State<AppState>,
@@ -164,17 +229,20 @@ async fn oembed(
let text = query.text.unwrap_or_default();
let author = query.author.unwrap_or_default();
let status = query.status.unwrap_or_default();
let provider = query.provider.filter(|value| !value.is_empty());
let author_url = format!("https://x.com/{author}/status/{status}");
let oembed = OEmbedData {
author_name: text,
author_url,
provider_name: state.config.server.title.clone(),
provider_url: state.config.url_prefix().to_owned(),
title: "Embed",
kind: "rich",
version: "1.0",
author_name: text,
author_url: author_url.clone(),
provider_name: provider
.clone()
.unwrap_or_else(|| state.config.server.title.clone()),
provider_url: provider.map_or_else(|| state.config.url_prefix().to_owned(), |_| author_url),
title: "Embed",
kind: "rich",
version: "1.0",
};
Ok((
+28 -2
View File
@@ -316,9 +316,8 @@ fn validate_media_url(url: &str, kind: MediaKind) -> Result<()> {
.host()
.ok_or_else(|| Error::InvalidUrl("media URL is missing host".into()))?
.to_ascii_lowercase();
let allowed = match kind {
MediaKind::Image => is_twimg_host(&host),
MediaKind::Image => is_twimg_host(&host) || is_pscp_image_host(&host),
MediaKind::Video => host == "video.twimg.com",
};
if allowed {
@@ -332,6 +331,10 @@ fn is_twimg_host(host: &str) -> bool {
host == "twimg.com" || host.ends_with(".twimg.com")
}
fn is_pscp_image_host(host: &str) -> bool {
host == "video.pscp.tv" || host.ends_with(".video.pscp.tv")
}
fn ensure_content_length_under(headers: &HeaderMap, max_bytes: u64) -> Result<()> {
let Some(value) = headers.get(header::CONTENT_LENGTH) else {
return Ok(());
@@ -348,3 +351,26 @@ fn ensure_content_length_under(headers: &HeaderMap, max_bytes: u64) -> Result<()
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn broadcast_thumbnail_hosts_are_allowed() {
assert!(
validate_media_url(
"https://prod-fastly-us-east-1.video.pscp.tv/Transcoding/v1/live_thumbnail/latest.jpg",
MediaKind::Image,
)
.is_ok()
);
assert!(
validate_media_url(
"https://prod-fastly-us-east-1.video.pscp.tv.evil.example/latest.jpg",
MediaKind::Image,
)
.is_err()
);
}
}
+34 -4
View File
@@ -5,7 +5,11 @@ use axum::{
Query,
State,
},
http::StatusCode,
http::{
HeaderMap,
StatusCode,
header,
},
response::{
Html,
IntoResponse as _,
@@ -127,11 +131,16 @@ async fn status_media_redirect(Path((username, id)): Path<(String, String)>) ->
async fn status(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path((username, id)): Path<(String, String)>,
Query(query): Query<StatusQuery>,
) -> Result<Response> {
// Extract prefs from cookies
let prefs = Prefs::from_cookies(&jar, &state.config);
let discord_activity = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.is_some_and(|user_agent| user_agent.contains("Discordbot"));
let sort = ranking_mode(query.sort.as_deref());
let is_sorted = sort != "Relevance";
@@ -171,7 +180,7 @@ async fn status(
let has_cursor = query.cursor.is_some();
// Auto-translate for embeds (OG tags / ActivityPub show translated text)
if !is_scroll && !has_cursor && conversation.tweet.is_translatable {
if !discord_activity && !is_scroll && !has_cursor && conversation.tweet.is_translatable {
let tid = conversation.tweet.id.to_string();
let kagi = &state.config.config.kagi_token;
let token = (!kagi.is_empty()).then_some(kagi.as_str());
@@ -224,6 +233,7 @@ async fn status(
&prefs,
&state.config,
query.sort.as_deref(),
discord_activity,
));
}
@@ -249,6 +259,7 @@ async fn status(
&prefs,
&state.config,
query.sort.as_deref(),
discord_activity,
))
},
Err(Error::TweetNotFound(msg)) => {
@@ -267,6 +278,10 @@ async fn status(
}
/// Render a conversation page (first page or paginated replies).
#[expect(
clippy::too_many_arguments,
reason = "rendering needs route identity, preferences, sorting, and scraper mode"
)]
fn render_conversation(
conversation: &Conversation,
has_cursor: bool,
@@ -275,6 +290,7 @@ fn render_conversation(
prefs: &Prefs,
config: &Config,
sort: Option<&str>,
discord_activity: bool,
) -> Response {
let tweet = &conversation.tweet;
@@ -422,13 +438,22 @@ fn render_conversation(
}
};
let markup = embed::render_status_page(tweet, &content, prefs, config, username, id);
let markup = embed::render_status_page(
tweet,
&content,
prefs,
config,
username,
id,
discord_activity,
);
Html(markup.into_string()).into_response()
}
async fn status_by_id(
State(state): State<AppState>,
jar: CookieJar,
headers: HeaderMap,
Path(id): Path<String>,
Query(query): Query<StatusQuery>,
) -> Result<Response> {
@@ -442,6 +467,10 @@ async fn status_by_id(
}
let prefs = Prefs::from_cookies(&jar, &state.config);
let discord_activity = headers
.get(header::USER_AGENT)
.and_then(|value| value.to_str().ok())
.is_some_and(|user_agent| user_agent.contains("Discordbot"));
let sort = ranking_mode(query.sort.as_deref());
// Fetch conversation directly — render the tweet page inline instead of
@@ -476,7 +505,7 @@ async fn status_by_id(
match conv_result {
Ok(mut conversation) => {
// Auto-translate for embeds (OG tags / ActivityPub show translated text)
if conversation.tweet.is_translatable {
if !discord_activity && conversation.tweet.is_translatable {
let tid = conversation.tweet.id.to_string();
let kagi = &state.config.config.kagi_token;
let token = (!kagi.is_empty()).then_some(kagi.as_str());
@@ -496,6 +525,7 @@ async fn status_by_id(
&prefs,
&state.config,
query.sort.as_deref(),
discord_activity,
))
},
Err(Error::TweetNotFound(msg)) => {
+45 -34
View File
@@ -124,8 +124,11 @@ impl Video {
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
#[serde(default)]
pub struct Gif {
pub url: String,
pub thumb: String,
pub url: String,
pub thumb: String,
pub alt_text: String,
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -133,6 +136,8 @@ pub struct Gif {
pub struct Photo {
pub url: String,
pub alt_text: String,
pub width: i32,
pub height: i32,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
@@ -291,45 +296,48 @@ pub struct TweetStats {
reason = "mirrors Twitter API tweet fields"
)]
pub struct Tweet {
pub id: i64,
pub thread_id: i64,
pub reply_id: i64,
pub user: User,
pub text: String,
pub id: i64,
pub thread_id: i64,
pub reply_id: i64,
pub user: User,
pub text: String,
#[serde(with = "time::serde::timestamp::option")]
pub time: Option<time::OffsetDateTime>,
pub reply: Vec<String>,
pub pinned: bool,
pub has_thread: bool,
pub available: bool,
pub tombstone: String,
pub location: String,
pub source: String,
pub stats: TweetStats,
pub retweet: Option<Box<Self>>,
pub attribution: Option<User>,
pub media_tags: Vec<User>,
pub quote: Option<Box<Self>>,
pub card: Option<Card>,
pub poll: Option<Poll>,
pub gif: Option<Gif>,
pub video: Option<Video>,
pub photos: Vec<Photo>,
pub time: Option<time::OffsetDateTime>,
pub reply: Vec<String>,
pub reply_mentions_stripped: bool,
pub pinned: bool,
pub has_thread: bool,
pub available: bool,
pub tombstone: String,
pub location: String,
pub source: String,
pub stats: TweetStats,
pub retweet: Option<Box<Self>>,
pub attribution: Option<User>,
pub media_tags: Vec<User>,
pub quote: Option<Box<Self>>,
pub card: Option<Card>,
pub poll: Option<Poll>,
pub gif: Option<Gif>,
pub video: Option<Video>,
pub additional_videos: Vec<Video>,
pub photos: Vec<Photo>,
/// Community note (Birdwatch) -- structured data, rendered in views.
pub note: Option<CommunityNote>,
pub note: Option<CommunityNote>,
/// Content disclosure labels (paid promotion, AI-generated media).
pub paid_promotion: bool,
pub ai_generated: bool,
pub sensitive: bool,
pub paid_promotion: bool,
pub ai_generated: bool,
/// Edit history tweet IDs.
pub history: Vec<i64>,
pub history: Vec<i64>,
/// Text entities for expansion (mentions, hashtags, URLs).
pub entities: Vec<Entity>,
pub entities: Vec<Entity>,
/// BCP-47 language code from Twitter's language detection.
pub lang: String,
pub lang: String,
/// Whether Twitter considers this tweet translatable.
pub is_translatable: bool,
pub is_translatable: bool,
/// Pre-fetched translation for embeds (OG tags, `ActivityPub`).
pub translation: Option<Translation>,
pub translation: Option<Translation>,
}
/// Translation of a tweet's text via Twitter's Strato translation API.
@@ -345,6 +353,9 @@ pub struct Translation {
impl Tweet {
/// Check if this tweet has any media.
pub const fn has_media(&self) -> bool {
!self.photos.is_empty() || self.video.is_some() || self.gif.is_some()
!self.photos.is_empty()
|| self.video.is_some()
|| !self.additional_videos.is_empty()
|| self.gif.is_some()
}
}
+73 -16
View File
@@ -44,6 +44,15 @@ fn char_to_byte_index(text: &str, char_idx: usize) -> Option<usize> {
/// then reconstructs the text with HTML anchor tags. Text between entities
/// is passed through verbatim (Twitter already HTML-escapes it).
pub fn expand_entities(text: &str, entities: &[Entity]) -> String {
expand_entities_with_target(text, entities, false)
}
/// Expand entities with links pointing back to X.
pub fn expand_entities_for_x(text: &str, entities: &[Entity]) -> String {
expand_entities_with_target(text, entities, true)
}
fn expand_entities_with_target(text: &str, entities: &[Entity], link_to_x: bool) -> String {
if entities.is_empty() {
return text.to_owned();
}
@@ -88,34 +97,47 @@ pub fn expand_entities(text: &str, entities: &[Entity]) -> String {
let entity_text = &text[start_byte..end_byte];
match entity.kind {
EntityKind::Mention => {
let _ = write!(
result,
r#"<a href="{}" title="{}">{}</a>"#,
html_escape(&entity.url),
html_escape(&entity.display),
entity_text
);
if link_to_x {
let username = entity_text.trim_start_matches('@');
let _ = write!(
result,
r#"<a href="https://x.com/{}">{}</a>"#,
url_encode(username),
entity_text,
);
} else {
let _ = write!(
result,
r#"<a href="{}" title="{}">{}</a>"#,
html_escape(&entity.url),
html_escape(&entity.display),
entity_text
);
}
},
EntityKind::Hashtag => {
let tag_name = entity_text.trim_start_matches('#').trim_start_matches('$');
let _ = write!(
result,
r#"<a href="/search?q=%23{}">{}</a>"#,
url_encode(tag_name),
entity_text
);
let href = if link_to_x {
format!("https://x.com/hashtag/{}", url_encode(tag_name))
} else {
format!("/search?q=%23{}", url_encode(tag_name))
};
let _ = write!(result, r#"<a href="{href}">{entity_text}</a>"#);
},
EntityKind::Symbol => {
let symbol_name = entity_text.trim_start_matches('$');
let root = if link_to_x { "https://x.com" } else { "" };
let _ = write!(
result,
r#"<a href="/search?q=%24{}">{}</a>"#,
r#"<a href="{root}/search?q=%24{}">{}</a>"#,
url_encode(symbol_name),
entity_text
entity_text,
);
},
EntityKind::Url => {
let display = if entity.display.is_empty() {
let display = if link_to_x {
short_url(&entity.url, 0)
} else if entity.display.is_empty() {
short_url(&entity.url, 28)
} else {
entity.display.clone()
@@ -233,6 +255,41 @@ mod tests {
assert!(result.contains(r#"href="/search?q=%23rust""#));
}
#[test]
fn activity_entities_link_to_x() {
let entities = vec![
Entity {
indices: (0, 5),
kind: EntityKind::Mention,
url: "/test".to_owned(),
..Default::default()
},
Entity {
indices: (6, 11),
kind: EntityKind::Hashtag,
..Default::default()
},
];
let result = expand_entities_for_x("@test #rust", &entities);
assert!(result.contains(r#"href="https://x.com/test""#));
assert!(result.contains(r#"href="https://x.com/hashtag/rust""#));
}
#[test]
fn activity_urls_use_full_expanded_url() {
let entities = vec![Entity {
indices: (0, 16),
kind: EntityKind::Url,
url: "https://example.com/a/long/path?with=query&and=more".to_owned(),
..Default::default()
}];
let result = expand_entities_for_x("https://t.co/xyz", &entities);
assert!(result.contains("example.com/a/long/path?with=query&amp;and=more"));
assert!(!result.contains('…'));
}
#[test]
fn test_expand_url() {
let entities = vec![Entity {
+14 -8
View File
@@ -247,7 +247,7 @@ pub fn format_with_commas(num: i64) -> String {
result
}
/// Abbreviate a number: 1234 → "1.2K", 1234567 → "1.23M".
/// Abbreviate a number using `FxTwitter`'s fixed precision.
#[expect(
clippy::cast_precision_loss,
reason = "display-only, precision loss is fine"
@@ -256,14 +256,8 @@ pub fn abbreviate_number(num: i64) -> String {
let abs = num.unsigned_abs() as f64;
if abs >= 1_000_000.0 {
format!("{:.2}M", abs / 1_000_000.0)
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
} else if abs >= 1_000.0 {
format!("{:.1}K", abs / 1_000.0)
.trim_end_matches('0')
.trim_end_matches('.')
.to_owned()
} else {
num.to_string()
}
@@ -377,7 +371,7 @@ pub fn format_join_date(time: time::OffsetDateTime) -> String {
pub const fn scale_dimensions_for_embed(width: i32, height: i32) -> (i32, i32) {
if width > 1920 || height > 1920 {
(width / 2, height / 2)
} else if width < 400 && height < 400 {
} else if width < 400 || height < 400 {
(width * 2, height * 2)
} else {
(width, height)
@@ -479,4 +473,16 @@ mod tests {
let dt = parse_twitter_time("Tue Jun 02 20:12:29 +0000 2009").unwrap();
assert_eq!(format_join_date(dt), "Joined June 2009");
}
#[test]
fn abbreviate_number_keeps_fxtwitter_precision() {
assert_eq!(abbreviate_number(496_000), "496.0K");
assert_eq!(abbreviate_number(1_000_000), "1.00M");
}
#[test]
fn narrow_video_dimensions_match_fxtwitter_scaling() {
assert_eq!(scale_dimensions_for_embed(320, 480), (640, 960));
assert_eq!(scale_dimensions_for_embed(3840, 2160), (1920, 1080));
}
}
+1449 -142
View File
File diff suppressed because it is too large. Load diff
+18 -2
View File
@@ -32,6 +32,7 @@ pub struct PageLayout<'a> {
referer: &'a str,
og_image: &'a str,
og_type: &'a str,
theme_color: &'a str,
head_extra: Option<&'a Markup>,
custom_og: bool,
}
@@ -49,6 +50,7 @@ impl<'a> PageLayout<'a> {
referer: "",
og_image: "",
og_type: "article",
theme_color: "#1F1F1F",
head_extra: None,
custom_og: false,
}
@@ -89,6 +91,11 @@ impl<'a> PageLayout<'a> {
self
}
pub const fn theme_color(mut self, theme_color: &'a str) -> Self {
self.theme_color = theme_color;
self
}
/// Extra markup injected into `<head>` after the standard OG tags.
/// Use for custom meta tags (media OG, oEmbed, `ActivityPub` links).
pub const fn head_extra(mut self, head_extra: &'a Markup) -> Self {
@@ -131,6 +138,11 @@ impl<'a> PageLayout<'a> {
},
_ => None,
};
let mask_icon_color = if self.custom_og {
self.theme_color
} else {
"#ff6c60"
};
html! {
(DOCTYPE)
@@ -138,7 +150,11 @@ impl<'a> PageLayout<'a> {
head {
meta charset="utf-8";
meta name="viewport" content="width=device-width, initial-scale=1.0";
meta name="theme-color" content="#1F1F1F";
@if self.custom_og {
meta property="theme-color" content=(self.theme_color);
} @else {
meta name="theme-color" content=(self.theme_color);
}
// Favicon and manifest links
@if !self.custom_og {
@@ -147,7 +163,7 @@ impl<'a> PageLayout<'a> {
link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png";
link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png";
link rel="manifest" href="/site.webmanifest";
link rel="mask-icon" href="/safari-pinned-tab.svg" color="#ff6c60";
link rel="mask-icon" href="/safari-pinned-tab.svg" color=(mask_icon_color);
// OpenSearch description (full absolute URL to /opensearch)
link rel="search" type="application/opensearchdescription+xml"
+13
View File
@@ -295,6 +295,19 @@ fn render_rss_tweet(tweet: &Tweet, config: &Config) -> String {
r#"<a href="{tweet_link}">
<br>Video<br>
<img src="{url_prefix}{thumb_url}" style="max-width:250px;" />
</a>"#
);
}
}
for video in &tweet.additional_videos {
if !video.thumb.is_empty() {
let thumb_url = get_pic_url(&video.thumb, config);
let tweet_link = format!("{}{}", url_prefix, tweet_link(tweet));
let _ = write!(
body,
r#"<a href="{tweet_link}">
<br>Video<br>
<img src="{url_prefix}{thumb_url}" style="max-width:250px;" />
</a>"#
);
}
+6
View File
@@ -329,6 +329,9 @@ impl<'a> TweetRenderer<'a> {
@if let Some(ref video) = display_tweet.video {
(render_video(video, config, prefs))
}
@for video in &display_tweet.additional_videos {
(render_video(video, config, prefs))
}
// GIF
@if let Some(ref gif) = display_tweet.gif {
@@ -568,6 +571,9 @@ fn render_quote(quote: &Tweet, config: &Config, prefs: Option<&Prefs>) -> Markup
@if let Some(ref video) = quote.video {
(render_video(video, config, prefs))
}
@for video in &quote.additional_videos {
(render_video(video, config, prefs))
}
@if let Some(ref gif) = quote.gif {
@let gif_url = formatters::get_vid_url(&gif.url, &config.config.hmac_key, config.config.base64_media);
div class="attachments media-gif" {