mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 09:04:35 +00:00
discord: improve embeds
This commit is contained in:
1 parent
07f3ed76b2
commit
5695b439c9
22 files changed
+2533
-286
No files matched your search
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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&and=more"));
|
||||
assert!(!result.contains('…'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_url() {
|
||||
let entities = vec![Entity {
|
||||
|
||||
+14
-8
@@ -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
File diff suppressed because it is too large.
Load diff
+18
-2
@@ -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"
|
||||
|
||||
@@ -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>"#
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 "e.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" {
|
||||
|
||||
Reference in new issue
Block a user