mirror of
https://github.com/4cuck/teapawt.git
synced 2026-10-27 19:04:35 +00:00
views: improve embeds, feeds, and privacy-safe rendering
This commit is contained in:
1 parent
6e3418b10d
commit
bbf2ab3442
16 files changed
+1725
-1666
No files matched your search
+5
-10
@@ -1,19 +1,14 @@
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: 300;
|
||||
font-style: normal;
|
||||
.nav-icon span {
|
||||
font-size: 18px !important;
|
||||
display: inline-block;
|
||||
line-height: 1;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
word-wrap: normal;
|
||||
white-space: nowrap;
|
||||
direction: ltr;
|
||||
font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.nav-icon span::before {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -43,7 +43,7 @@
|
||||
font-size: 20px;
|
||||
font-weight: 500;
|
||||
letter-spacing: -0.1px;
|
||||
font-family: "Readex Pro", sans-serif;
|
||||
font-family: inherit;
|
||||
text-shadow: none;
|
||||
max-width: 100%;
|
||||
transition: color 0.15s ease;
|
||||
|
||||
@@ -5,8 +5,6 @@
|
||||
@import "inputs.css";
|
||||
@import "timeline.css";
|
||||
@import "search.css";
|
||||
@import url('https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@24,400,0,0&display=block');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Readex+Pro:[email protected]&display=swap');
|
||||
|
||||
body {
|
||||
--bg_color: #121214;
|
||||
@@ -97,7 +95,7 @@ body {
|
||||
|
||||
background-color: var(--bg_color);
|
||||
color: var(--fg_color);
|
||||
--font-family: 'Readex Pro', sans-serif, Twemoji;
|
||||
--font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif, Twemoji;
|
||||
font-family: var(--font-family);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
"use strict";
|
||||
|
||||
document.addEventListener("click", async (event) => {
|
||||
const button = event.target.closest(".copy-btn");
|
||||
if (!button) return;
|
||||
|
||||
const block = button.closest(".code-block");
|
||||
const code = block?.querySelector("code");
|
||||
if (!code || !navigator.clipboard) return;
|
||||
|
||||
try {
|
||||
await navigator.clipboard.writeText(code.textContent);
|
||||
const toast = document.createElement("div");
|
||||
toast.className = "copy-toast";
|
||||
toast.textContent = "Copied!";
|
||||
block.appendChild(toast);
|
||||
window.setTimeout(() => toast.remove(), 1500);
|
||||
} catch {
|
||||
// Clipboard access can be denied by browser permissions.
|
||||
}
|
||||
});
|
||||
@@ -231,12 +231,12 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_expand_mention() {
|
||||
fn expands_mention() {
|
||||
let entities = vec![Entity {
|
||||
indices: (0, 5),
|
||||
kind: EntityKind::Mention,
|
||||
url: "/test".to_string(),
|
||||
display: "Test User".to_string(),
|
||||
url: "/test".to_owned(),
|
||||
display: "Test User".to_owned(),
|
||||
}];
|
||||
let result = expand_entities("@test hello", &entities);
|
||||
assert!(result.contains(r#"<a href="/test""#));
|
||||
@@ -244,7 +244,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_hashtag() {
|
||||
fn expands_hashtag() {
|
||||
let entities = vec![Entity {
|
||||
indices: (0, 5),
|
||||
kind: EntityKind::Hashtag,
|
||||
@@ -291,11 +291,11 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_expand_url() {
|
||||
fn expands_url() {
|
||||
let entities = vec![Entity {
|
||||
indices: (6, 26),
|
||||
kind: EntityKind::Url,
|
||||
url: "https://example.com/long/path".to_string(),
|
||||
url: "https://example.com/long/path".to_owned(),
|
||||
..Default::default()
|
||||
}];
|
||||
let result = expand_entities("Check https://t.co/xyz out!", &entities);
|
||||
@@ -304,7 +304,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_escape() {
|
||||
fn escapes_html() {
|
||||
let result = html_escape("<script>alert('xss')</script>");
|
||||
assert!(!result.contains('<'));
|
||||
assert!(!result.contains('>'));
|
||||
@@ -313,15 +313,15 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_regex_fallback() {
|
||||
fn regex_fallback_expands_entities() {
|
||||
let result = expand_with_regex("Hello @user and #topic!");
|
||||
assert!(result.contains(r#"<a href="/user">@user</a>"#));
|
||||
assert!(result.contains(r#"<a href="/search?q=%23topic">#topic</a>"#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_entity_not_treated_as_hashtag() {
|
||||
// ' is the HTML entity for single quote — the #x27 must NOT become
|
||||
fn html_entity_is_not_treated_as_hashtag() {
|
||||
// ' is the HTML entity for a single quote. The #x27 must not become
|
||||
// a hashtag link. Twitter sends this pre-escaped.
|
||||
let result = expand_with_regex("It's a test");
|
||||
assert!(
|
||||
@@ -335,7 +335,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_html_entity_ampersand_not_treated_as_hashtag() {
|
||||
fn html_entity_ampersand_is_not_treated_as_hashtag() {
|
||||
let result = expand_with_regex("Test & 'end");
|
||||
assert!(
|
||||
!result.contains(r#"<a href="/search?q=%23x27">"#),
|
||||
@@ -344,15 +344,16 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_no_double_escape() {
|
||||
// Twitter returns < in full_text — should stay as < not become &lt;
|
||||
fn avoids_double_escape() {
|
||||
// Twitter returns < in full_text. It must stay < instead of becoming
|
||||
// &lt;
|
||||
let result = expand_entities("x < y", &[]);
|
||||
assert_eq!(result, "x < y");
|
||||
assert!(!result.contains("&"), "should not double-escape");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_passthrough_preserves_twitter_escaping() {
|
||||
fn passthrough_preserves_twitter_escaping() {
|
||||
// Twitter pre-escapes: & < > "
|
||||
let result = expand_entities("A & B < C > D", &[]);
|
||||
assert_eq!(result, "A & B < C > D");
|
||||
|
||||
@@ -74,7 +74,7 @@ pub fn replace_twitter_urls(text: &str, config: &Config) -> String {
|
||||
let result = X_LINK_RE.replace_all(&result, link_replacement.as_str());
|
||||
let result = TW_LINK_RE.replace_all(&result, link_replacement.as_str());
|
||||
|
||||
// Handle raw URLs: replace domain after "://" boundary.
|
||||
// Replace domains in raw URLs after the "://" boundary.
|
||||
let url_replacement = format!("${{1}}{replace_with}");
|
||||
let result = X_URL_RE.replace_all(&result, url_replacement.as_str());
|
||||
let result = TW_URL_RE.replace_all(&result, url_replacement.as_str());
|
||||
@@ -202,7 +202,7 @@ pub fn get_video_embed_url(config: &Config, tweet_id: i64) -> String {
|
||||
}
|
||||
|
||||
/// Generate GIF URL with HMAC signature (for local transcoding).
|
||||
/// The `.gif` suffix is critical: Discord's image proxy uses the URL
|
||||
/// The `.gif` suffix is critical because Discord's image proxy uses the URL
|
||||
/// extension to decide whether to preserve animation.
|
||||
pub fn get_gif_url(mp4_url: &str, hmac_key: &str, base64_media: bool) -> String {
|
||||
let sig = super::sign(mp4_url, hmac_key);
|
||||
@@ -439,10 +439,10 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn test_parse_twitter_time_zero_padded_day() {
|
||||
fn parses_twitter_time_with_zero_padded_day() {
|
||||
use time::macros::format_description;
|
||||
|
||||
// Elon Musk's created_at: zero-padded day
|
||||
// Elon Musk's created_at uses a zero-padded day
|
||||
let result = parse_twitter_time("Tue Jun 02 20:12:29 +0000 2009");
|
||||
assert!(result.is_some(), "Failed to parse zero-padded day");
|
||||
let dt = result.unwrap();
|
||||
@@ -454,7 +454,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parse_twitter_time_space_padded_day() {
|
||||
fn parses_twitter_time_with_space_padded_day() {
|
||||
use time::macros::format_description;
|
||||
|
||||
// Space-padded day (single digit)
|
||||
@@ -469,7 +469,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_format_join_date() {
|
||||
fn formats_join_date() {
|
||||
let dt = parse_twitter_time("Tue Jun 02 20:12:29 +0000 2009").unwrap();
|
||||
assert_eq!(format_join_date(dt), "Joined June 2009");
|
||||
}
|
||||
|
||||
+11
-1593
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,879 @@
|
||||
use super::*;
|
||||
|
||||
/// Mastodon API v1-compatible status for Discord embed support.
|
||||
/// Discord uses `created_at` for the footer timestamp and `content`
|
||||
/// for rich text rendering (blockquotes for quoted tweets).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ActivityPubNote {
|
||||
pub id: String,
|
||||
pub url: String,
|
||||
pub uri: String,
|
||||
pub created_at: String,
|
||||
pub edited_at: Option<String>,
|
||||
pub reblog: Option<()>,
|
||||
pub in_reply_to_id: Option<String>,
|
||||
pub in_reply_to_account_id: Option<String>,
|
||||
pub language: String,
|
||||
pub content: String,
|
||||
pub spoiler_text: String,
|
||||
pub sensitive: bool,
|
||||
pub visibility: String,
|
||||
pub application: MastodonApplication,
|
||||
pub media_attachments: Vec<MediaAttachment>,
|
||||
pub account: MastodonAccount,
|
||||
pub mentions: Vec<()>,
|
||||
pub tags: Vec<()>,
|
||||
pub emojis: Vec<()>,
|
||||
pub card: Option<MastodonPreviewCard>,
|
||||
pub poll: Option<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MastodonPreviewCard {
|
||||
pub url: String,
|
||||
pub title: String,
|
||||
pub description: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub authors: Vec<()>,
|
||||
pub author_name: String,
|
||||
pub author_url: String,
|
||||
pub provider_name: String,
|
||||
pub provider_url: String,
|
||||
pub html: String,
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub image: Option<String>,
|
||||
pub embed_url: String,
|
||||
pub blurhash: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MastodonApplication {
|
||||
pub name: String,
|
||||
pub website: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
#[expect(
|
||||
clippy::struct_excessive_bools,
|
||||
reason = "mirrors the Mastodon API account schema Discord expects"
|
||||
)]
|
||||
pub struct MastodonAccount {
|
||||
pub id: String,
|
||||
pub display_name: String,
|
||||
pub username: String,
|
||||
pub acct: String,
|
||||
pub url: String,
|
||||
pub uri: String,
|
||||
pub created_at: String,
|
||||
pub locked: bool,
|
||||
pub bot: bool,
|
||||
pub discoverable: bool,
|
||||
pub indexable: bool,
|
||||
pub group: bool,
|
||||
pub avatar: String,
|
||||
pub avatar_static: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub header: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub header_static: Option<String>,
|
||||
pub followers_count: i64,
|
||||
pub following_count: i64,
|
||||
pub statuses_count: i64,
|
||||
pub hide_collections: bool,
|
||||
pub noindex: bool,
|
||||
pub emojis: Vec<()>,
|
||||
pub roles: Vec<()>,
|
||||
pub fields: Vec<()>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MediaAttachment {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub type_: String,
|
||||
pub url: String,
|
||||
pub preview_url: Option<String>,
|
||||
pub remote_url: Option<String>,
|
||||
pub preview_remote_url: Option<String>,
|
||||
pub text_url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub meta: Option<MediaMeta>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MediaMeta {
|
||||
pub original: MediaDimensions,
|
||||
}
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MediaDimensions {
|
||||
pub width: i32,
|
||||
pub height: i32,
|
||||
pub size: String,
|
||||
pub aspect: f64,
|
||||
}
|
||||
|
||||
/// Create a Mastodon-compatible media attachment.
|
||||
fn make_attachment(
|
||||
id: String,
|
||||
type_: &str,
|
||||
url: String,
|
||||
preview: Option<String>,
|
||||
description: Option<String>,
|
||||
width: i32,
|
||||
height: i32,
|
||||
) -> MediaAttachment {
|
||||
MediaAttachment {
|
||||
id,
|
||||
type_: type_.to_owned(),
|
||||
url,
|
||||
preview_url: preview,
|
||||
remote_url: None,
|
||||
preview_remote_url: None,
|
||||
text_url: None,
|
||||
description,
|
||||
meta: Some(MediaMeta {
|
||||
original: MediaDimensions {
|
||||
width,
|
||||
height,
|
||||
size: format!("{width}x{height}"),
|
||||
aspect: aspect_ratio(width, height),
|
||||
},
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
const fn dimensions_or(width: i32, height: i32, fallback: (i32, i32)) -> (i32, i32) {
|
||||
if width > 0 && height > 0 {
|
||||
(width, height)
|
||||
} else {
|
||||
fallback
|
||||
}
|
||||
}
|
||||
|
||||
fn full_media_url(url_prefix: &str, path: &str) -> String {
|
||||
format!("{url_prefix}{path}")
|
||||
}
|
||||
|
||||
fn application_name(source: &str) -> String {
|
||||
let mut name = String::with_capacity(source.len());
|
||||
let mut in_tag = false;
|
||||
for character in source.chars() {
|
||||
match character {
|
||||
'<' => in_tag = true,
|
||||
'>' => in_tag = false,
|
||||
_ if !in_tag => name.push(character),
|
||||
_ => {},
|
||||
}
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
fn card_image_dimensions(url: &str) -> (i32, i32) {
|
||||
url.split(['?', '&'])
|
||||
.find_map(|part| {
|
||||
let dimensions = part.strip_prefix("name=")?;
|
||||
let (width, height) = dimensions.split_once('x')?;
|
||||
Some((width.parse().ok()?, height.parse().ok()?))
|
||||
})
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
fn build_preview_card(
|
||||
tweet: &Tweet,
|
||||
config: &Config,
|
||||
url_prefix: &str,
|
||||
) -> Option<MastodonPreviewCard> {
|
||||
let card = tweet.card.as_ref().filter(|card| {
|
||||
!card.url.is_empty()
|
||||
&& !matches!(card.kind, CardKind::Hidden | CardKind::Unknown)
|
||||
&& tweet.poll.is_none()
|
||||
})?;
|
||||
let url = if card.url.starts_with("http://") || card.url.starts_with("https://") {
|
||||
card.url.clone()
|
||||
} else {
|
||||
format!("{url_prefix}{}", card.url)
|
||||
};
|
||||
let (width, height) = match card_image_dimensions(&card.image) {
|
||||
(0, 0) if matches!(card.kind, CardKind::Broadcast | CardKind::Periscope) => (1920, 1080),
|
||||
dimensions => dimensions,
|
||||
};
|
||||
let image = (!card.image.is_empty()).then(|| {
|
||||
full_media_url(
|
||||
url_prefix,
|
||||
&formatters::get_pic_url(&card.image, config.config.base64_media),
|
||||
)
|
||||
});
|
||||
|
||||
Some(MastodonPreviewCard {
|
||||
url: url.clone(),
|
||||
title: card.title.clone(),
|
||||
description: card.text.clone(),
|
||||
type_: "link".to_owned(),
|
||||
authors: vec![],
|
||||
author_name: String::new(),
|
||||
author_url: String::new(),
|
||||
provider_name: card.dest.clone(),
|
||||
provider_url: url,
|
||||
html: String::new(),
|
||||
width,
|
||||
height,
|
||||
image,
|
||||
embed_url: String::new(),
|
||||
blurhash: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build media attachments for a single tweet's photos/video/gif.
|
||||
fn build_media_attachments(
|
||||
tweet: &Tweet,
|
||||
config: &Config,
|
||||
url_prefix: &str,
|
||||
) -> Vec<MediaAttachment> {
|
||||
let mut attachments = Vec::new();
|
||||
|
||||
if let Some(video) = tweet.video.as_ref() {
|
||||
let (raw_width, raw_height) = video.best_dimensions();
|
||||
let scaled = formatters::scale_dimensions_for_embed(raw_width, raw_height);
|
||||
let (width, height) = dimensions_or(scaled.0, scaled.1, (1280, 720));
|
||||
if let Some(mp4_url) = video.best_mp4_url() {
|
||||
let poster = tweet
|
||||
.photos
|
||||
.first()
|
||||
.map_or(video.thumb.as_str(), |photo| photo.url.as_str());
|
||||
attachments.push(make_attachment(
|
||||
format!("{}-video-0", tweet.id),
|
||||
"video",
|
||||
full_media_url(
|
||||
url_prefix,
|
||||
&formatters::get_vid_url(
|
||||
mp4_url,
|
||||
&config.config.hmac_key,
|
||||
config.config.base64_media,
|
||||
),
|
||||
),
|
||||
Some(full_media_url(
|
||||
url_prefix,
|
||||
&formatters::get_pic_url(poster, config.config.base64_media),
|
||||
)),
|
||||
None,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
for (index, photo) in tweet.photos.iter().enumerate() {
|
||||
let (width, height) = dimensions_or(photo.width, photo.height, (1200, 675));
|
||||
attachments.push(make_attachment(
|
||||
format!("{}-image-{index}", tweet.id),
|
||||
"image",
|
||||
full_media_url(
|
||||
url_prefix,
|
||||
&formatters::get_orig_pic_url(&photo.url, config.config.base64_media),
|
||||
),
|
||||
None,
|
||||
(!photo.alt_text.is_empty()).then(|| photo.alt_text.clone()),
|
||||
width,
|
||||
height,
|
||||
));
|
||||
}
|
||||
|
||||
if let Some(ref gif) = tweet.gif
|
||||
&& !gif.url.is_empty()
|
||||
{
|
||||
let (width, height) = dimensions_or(gif.width, gif.height, (480, 480));
|
||||
let description = (!gif.alt_text.is_empty()).then(|| gif.alt_text.clone());
|
||||
match config.gif_transcoding.mode {
|
||||
GifTranscodingMode::Local => {
|
||||
attachments.push(make_attachment(
|
||||
format!("{}-gif-0", tweet.id),
|
||||
"image",
|
||||
full_media_url(
|
||||
url_prefix,
|
||||
&formatters::get_gif_url(
|
||||
&gif.url,
|
||||
&config.config.hmac_key,
|
||||
config.config.base64_media,
|
||||
),
|
||||
),
|
||||
None,
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
},
|
||||
GifTranscodingMode::External => {
|
||||
attachments.push(make_attachment(
|
||||
format!("{}-gif-0", tweet.id),
|
||||
"image",
|
||||
formatters::get_external_gif_url(&gif.url, &config.gif_transcoding.external_domain),
|
||||
None,
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
},
|
||||
GifTranscodingMode::Off => {
|
||||
attachments.push(make_attachment(
|
||||
format!("{}-gif-0", tweet.id),
|
||||
"video",
|
||||
full_media_url(
|
||||
url_prefix,
|
||||
&formatters::get_vid_url(
|
||||
&gif.url,
|
||||
&config.config.hmac_key,
|
||||
config.config.base64_media,
|
||||
),
|
||||
),
|
||||
Some(full_media_url(
|
||||
url_prefix,
|
||||
&formatters::get_pic_url(&gif.thumb, config.config.base64_media),
|
||||
)),
|
||||
description,
|
||||
width,
|
||||
height,
|
||||
));
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
attachments
|
||||
}
|
||||
|
||||
fn append_media_overflow(content: &mut String, tweet: &Tweet, config: &Config) {
|
||||
if tweet.additional_videos.is_empty() {
|
||||
return;
|
||||
}
|
||||
|
||||
let count = usize::from(tweet.video.is_some())
|
||||
+ tweet.additional_videos.len()
|
||||
+ tweet.photos.len()
|
||||
+ usize::from(tweet.gif.is_some());
|
||||
let url = format!(
|
||||
"{}/{}/status/{}",
|
||||
config.url_prefix(),
|
||||
tweet.user.username,
|
||||
tweet.id
|
||||
);
|
||||
let _ = write!(
|
||||
content,
|
||||
r#"<sub>🗂️ <a href="{}">View all {count} media</a></sub><br>"#,
|
||||
html_escape(&url)
|
||||
);
|
||||
}
|
||||
|
||||
fn preserve_activity_spacing(html: &str) -> String {
|
||||
const MAX_RUN: usize = 64;
|
||||
|
||||
let mut output = String::with_capacity(html.len());
|
||||
let mut spaces = 0_usize;
|
||||
let flush_spaces = |target: &mut String, run_length: usize| {
|
||||
if run_length == 1 {
|
||||
target.push(' ');
|
||||
} else if run_length > 1 {
|
||||
for index in 0..run_length.min(MAX_RUN) {
|
||||
if index % 2 == 0 {
|
||||
target.push_str(" ");
|
||||
} else {
|
||||
target.push(' ');
|
||||
}
|
||||
}
|
||||
if run_length > MAX_RUN {
|
||||
target.push(' ');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for character in html.chars() {
|
||||
if character == ' ' {
|
||||
spaces += 1;
|
||||
continue;
|
||||
}
|
||||
flush_spaces(&mut output, spaces);
|
||||
spaces = 0;
|
||||
output.push(character);
|
||||
}
|
||||
flush_spaces(&mut output, spaces);
|
||||
output
|
||||
}
|
||||
|
||||
fn activity_text(tweet: &Tweet) -> String {
|
||||
let expanded = expand_entities_for_x(tweet.text.trim(), &tweet.entities).replace('\n', "<br>︀︀");
|
||||
preserve_activity_spacing(&expanded)
|
||||
}
|
||||
|
||||
fn activity_text_limited(tweet: &Tweet, max_chars: usize) -> String {
|
||||
let text = tweet.text.trim();
|
||||
if text.chars().count() <= max_chars {
|
||||
return activity_text(tweet);
|
||||
}
|
||||
|
||||
let prefix = text.chars().take(max_chars).collect::<String>();
|
||||
let mut cutoff = prefix
|
||||
.rfind(char::is_whitespace)
|
||||
.map(|byte_index| prefix[..byte_index].chars().count())
|
||||
.filter(|&char_index| char_index >= max_chars * 3 / 4)
|
||||
.unwrap_or(max_chars);
|
||||
|
||||
if let Some(entity) = tweet
|
||||
.entities
|
||||
.iter()
|
||||
.find(|entity| entity.indices.0 < cutoff && entity.indices.1 > cutoff)
|
||||
{
|
||||
cutoff = entity.indices.0;
|
||||
}
|
||||
|
||||
let truncated = text.chars().take(cutoff).collect::<String>();
|
||||
let entities = tweet
|
||||
.entities
|
||||
.iter()
|
||||
.filter(|entity| entity.indices.1 <= cutoff)
|
||||
.cloned()
|
||||
.collect::<Vec<_>>();
|
||||
let expanded = expand_entities_for_x(truncated.trim_end(), &entities).replace('\n', "<br>︀︀");
|
||||
let expanded = preserve_activity_spacing(&expanded);
|
||||
format!("{expanded}…")
|
||||
}
|
||||
|
||||
fn activity_text_with_reply_mentions(tweet: &Tweet) -> String {
|
||||
let text = activity_text(tweet);
|
||||
if !tweet.reply_mentions_stripped {
|
||||
return text;
|
||||
}
|
||||
let Some(first_reply) = tweet.reply.first() else {
|
||||
return text;
|
||||
};
|
||||
if tweet
|
||||
.text
|
||||
.trim_start()
|
||||
.get(..=first_reply.len())
|
||||
.is_some_and(|prefix| prefix.eq_ignore_ascii_case(&format!("@{first_reply}")))
|
||||
{
|
||||
return text;
|
||||
}
|
||||
|
||||
let mentions = tweet
|
||||
.reply
|
||||
.iter()
|
||||
.map(|username| {
|
||||
let username = html_escape(username);
|
||||
format!(r#"<a href="https://x.com/{username}">@{username}</a>"#)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("{mentions} {text}")
|
||||
}
|
||||
|
||||
fn engagement_html(tweet: &Tweet) -> String {
|
||||
let status_id = tweet.id;
|
||||
let mut parts = Vec::new();
|
||||
|
||||
if tweet.stats.replies > 0 {
|
||||
parts.push(format!(
|
||||
r#"<a href="https://x.com/intent/tweet?in_reply_to={status_id}">💬</a> {}"#,
|
||||
formatters::abbreviate_number(tweet.stats.replies)
|
||||
));
|
||||
}
|
||||
if tweet.stats.retweets > 0 {
|
||||
parts.push(format!(
|
||||
r#"<a href="https://x.com/intent/retweet?tweet_id={status_id}">🔁</a> {}"#,
|
||||
formatters::abbreviate_number(tweet.stats.retweets)
|
||||
));
|
||||
}
|
||||
if tweet.stats.likes > 0 {
|
||||
parts.push(format!(
|
||||
r#"<a href="https://x.com/intent/like?tweet_id={status_id}">❤️</a> {}"#,
|
||||
formatters::abbreviate_number(tweet.stats.likes)
|
||||
));
|
||||
}
|
||||
if tweet.stats.views > 0 {
|
||||
parts.push(format!(
|
||||
"👁️ {}",
|
||||
formatters::abbreviate_number(tweet.stats.views)
|
||||
));
|
||||
}
|
||||
|
||||
if parts.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("<b>{} </b>", parts.join(" "))
|
||||
}
|
||||
}
|
||||
|
||||
fn append_poll(content: &mut String, tweet: &Tweet) {
|
||||
let Some(poll) = tweet.poll.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let total_votes = if poll.votes > 0 {
|
||||
poll.votes
|
||||
} else {
|
||||
poll.values.iter().sum()
|
||||
};
|
||||
content.push_str("<blockquote>");
|
||||
|
||||
for (option, votes) in poll.options.iter().zip(&poll.values) {
|
||||
let percentage = if total_votes > 0 {
|
||||
(votes.saturating_mul(100) + total_votes / 2) / total_votes
|
||||
} else {
|
||||
0
|
||||
}
|
||||
.clamp(0, 100);
|
||||
let bar_len = usize::try_from(percentage * 32 / 100).unwrap_or_default();
|
||||
let _ = write!(
|
||||
content,
|
||||
"{}<br><b>{}</b> {percentage}%<br>︀︀︀<br>︀",
|
||||
"█".repeat(bar_len),
|
||||
html_escape(option),
|
||||
);
|
||||
}
|
||||
|
||||
let _ = write!(
|
||||
content,
|
||||
"{} votes · {}</blockquote>",
|
||||
formatters::abbreviate_number(total_votes),
|
||||
html_escape(&poll.status_text()),
|
||||
);
|
||||
}
|
||||
|
||||
fn append_quote(content: &mut String, tweet: &Tweet) {
|
||||
let Some(quote) = tweet.quote.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let quote_url = format!("https://x.com/{}/status/{}", quote.user.username, quote.id);
|
||||
let profile_url = format!("https://x.com/{}", quote.user.username);
|
||||
let _ = write!(
|
||||
content,
|
||||
r#"<blockquote><b><a href="{quote_url}">Quoting</a> {} (<a href="{profile_url}">@{}</a>)</b><br>︀<br>{}</blockquote>"#,
|
||||
html_escape("e.user.fullname),
|
||||
html_escape("e.user.username),
|
||||
activity_text(quote),
|
||||
);
|
||||
}
|
||||
|
||||
fn reply_context<'a>(tweet: &Tweet, replied_to: Option<&'a Tweet>) -> Option<&'a Tweet> {
|
||||
replied_to.filter(|original| {
|
||||
tweet.reply_id > 0 && original.id == tweet.reply_id && !original.text.trim().is_empty()
|
||||
})
|
||||
}
|
||||
|
||||
fn append_reply_context(content: &mut String, original: &Tweet) {
|
||||
const MAX_CHARS: usize = 220;
|
||||
|
||||
let status_url = format!(
|
||||
"https://x.com/{}/status/{}",
|
||||
original.user.username, original.id
|
||||
);
|
||||
let profile_url = format!("https://x.com/{}", original.user.username);
|
||||
let username = html_escape(&original.user.username);
|
||||
let _ = write!(
|
||||
content,
|
||||
r#"<blockquote><b>↩ <a href="{status_url}">Original post</a> · <a href="{profile_url}">@{username}</a></b><br>{}</blockquote>"#,
|
||||
activity_text_limited(original, MAX_CHARS),
|
||||
);
|
||||
}
|
||||
|
||||
fn append_community_note(content: &mut String, tweet: &Tweet) {
|
||||
let Some(note) = tweet.note.as_ref() else {
|
||||
return;
|
||||
};
|
||||
|
||||
let note_html = community_note_to_html(note).replace('\n', "<br>︀︀");
|
||||
let _ = write!(
|
||||
content,
|
||||
"<blockquote><b>📝 Community Note</b><br>{note_html}</blockquote>",
|
||||
);
|
||||
}
|
||||
|
||||
fn append_article_preview(content: &mut String, tweet: &Tweet, config: &Config) {
|
||||
let Some(card) = tweet
|
||||
.card
|
||||
.as_ref()
|
||||
.filter(|card| card.kind == CardKind::Article && !card.title.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if content == "<br><br>" {
|
||||
content.clear();
|
||||
}
|
||||
|
||||
let url = if card.url.starts_with("http://") || card.url.starts_with("https://") {
|
||||
card.url.clone()
|
||||
} else {
|
||||
format!("{}{}", config.url_prefix(), card.url)
|
||||
};
|
||||
let description = preserve_activity_spacing(&html_escape(&card.text).replace('\n', "<br>"));
|
||||
let _ = write!(
|
||||
content,
|
||||
r#"<blockquote><b>📄 <a href="{}">{}</a></b>"#,
|
||||
html_escape(&url),
|
||||
html_escape(&card.title),
|
||||
);
|
||||
if !description.is_empty() {
|
||||
let _ = write!(content, "<br>{description}");
|
||||
}
|
||||
content.push_str("</blockquote>");
|
||||
}
|
||||
|
||||
fn append_audio_space_preview(content: &mut String, tweet: &Tweet) {
|
||||
let Some(card) = tweet
|
||||
.card
|
||||
.as_ref()
|
||||
.filter(|card| card.kind == CardKind::Audiospace && !card.url.is_empty())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
|
||||
if content == "<br><br>" {
|
||||
content.clear();
|
||||
}
|
||||
|
||||
let title = if card.title.is_empty() {
|
||||
"X Space"
|
||||
} else {
|
||||
&card.title
|
||||
};
|
||||
let _ = write!(
|
||||
content,
|
||||
r#"<blockquote><b>🎙️ <a href="{}">{}</a></b>"#,
|
||||
html_escape(&card.url),
|
||||
html_escape(title),
|
||||
);
|
||||
if !card.text.is_empty() {
|
||||
let _ = write!(content, "<br>{}", html_escape(&card.text));
|
||||
}
|
||||
if !card.dest.is_empty() {
|
||||
let _ = write!(content, "<br>{}", html_escape(&card.dest));
|
||||
}
|
||||
content.push_str("</blockquote>");
|
||||
}
|
||||
|
||||
fn append_broadcast_preview(content: &mut String, tweet: &Tweet) {
|
||||
let Some(card) = tweet.card.as_ref().filter(|card| {
|
||||
matches!(card.kind, CardKind::Broadcast | CardKind::Periscope) && !card.url.is_empty()
|
||||
}) else {
|
||||
return;
|
||||
};
|
||||
|
||||
if content == "<br><br>" {
|
||||
content.clear();
|
||||
}
|
||||
|
||||
let title = if card.title.is_empty() {
|
||||
"Live broadcast"
|
||||
} else {
|
||||
&card.title
|
||||
};
|
||||
if broadcast_title_repeats_tweet(tweet, title) {
|
||||
content.push_str("<blockquote>📺");
|
||||
if !card.text.is_empty() {
|
||||
let _ = write!(content, " {}", html_escape(&card.text));
|
||||
}
|
||||
if !card.dest.is_empty() {
|
||||
let _ = write!(content, "<br>{}", html_escape(&card.dest));
|
||||
}
|
||||
content.push_str("</blockquote>");
|
||||
return;
|
||||
}
|
||||
let _ = write!(
|
||||
content,
|
||||
r#"<blockquote><b>📺 <a href="{}">{}</a></b>"#,
|
||||
html_escape(&card.url),
|
||||
html_escape(title),
|
||||
);
|
||||
if !card.text.is_empty() {
|
||||
let _ = write!(content, "<br>{}", html_escape(&card.text));
|
||||
}
|
||||
if !card.dest.is_empty() {
|
||||
let _ = write!(content, "<br>{}", html_escape(&card.dest));
|
||||
}
|
||||
content.push_str("</blockquote>");
|
||||
}
|
||||
|
||||
/// Build rich Mastodon-status HTML for Discord's activity embed path.
|
||||
fn build_mastodon_content(tweet: &Tweet, replied_to: Option<&Tweet>, config: &Config) -> String {
|
||||
let reply_context = reply_context(tweet, replied_to);
|
||||
let tweet_text = if reply_context.is_some() {
|
||||
activity_text(tweet)
|
||||
} else {
|
||||
activity_text_with_reply_mentions(tweet)
|
||||
};
|
||||
let mut content = tweet.translation.as_ref().map_or_else(
|
||||
|| format!("{tweet_text}<br><br>"),
|
||||
|tl| {
|
||||
let translated = html_escape(&tl.text).replace('\n', "<br>");
|
||||
let translated = preserve_activity_spacing(&translated);
|
||||
format!(
|
||||
"<b>📑 Translated from {}</b><br><br>{}<br><br><blockquote><b>Original \
|
||||
text</b><br>{}</blockquote>",
|
||||
html_escape(&tl.source_lang_display),
|
||||
translated,
|
||||
tweet_text,
|
||||
)
|
||||
},
|
||||
);
|
||||
|
||||
append_article_preview(&mut content, tweet, config);
|
||||
append_audio_space_preview(&mut content, tweet);
|
||||
append_broadcast_preview(&mut content, tweet);
|
||||
append_quote(&mut content, tweet);
|
||||
if let Some(original) = reply_context {
|
||||
append_reply_context(&mut content, original);
|
||||
}
|
||||
append_community_note(&mut content, tweet);
|
||||
|
||||
if reply_context.is_none() && !tweet.reply.is_empty() {
|
||||
let username = html_escape(&tweet.reply[0]);
|
||||
content = format!(
|
||||
r#"<sub>↩ <a href="https://x.com/{username}" class="u-url mention"> (@{username})</a></sub><br>{content}"#
|
||||
);
|
||||
}
|
||||
|
||||
append_poll(&mut content, tweet);
|
||||
append_media_overflow(&mut content, tweet, config);
|
||||
|
||||
let engagement = engagement_html(tweet);
|
||||
if !engagement.is_empty() {
|
||||
content.push_str(&engagement);
|
||||
}
|
||||
|
||||
content
|
||||
}
|
||||
|
||||
/// Build Mastodon API v1-compatible status JSON for Discord.
|
||||
pub fn build_activity_pub(tweet: &Tweet, config: &Config) -> ActivityPubNote {
|
||||
build_activity_pub_with_reply(tweet, None, config)
|
||||
}
|
||||
|
||||
/// Build a Mastodon status with optional replied-to context for Discord.
|
||||
pub fn build_activity_pub_with_reply(
|
||||
tweet: &Tweet,
|
||||
replied_to: Option<&Tweet>,
|
||||
config: &Config,
|
||||
) -> ActivityPubNote {
|
||||
let url_prefix = config.url_prefix();
|
||||
|
||||
let mut attachments = build_media_attachments(tweet, config, url_prefix);
|
||||
|
||||
if !tweet.has_media()
|
||||
&& let Some(ref quote) = tweet.quote
|
||||
{
|
||||
attachments.extend(build_media_attachments(quote, config, url_prefix));
|
||||
}
|
||||
|
||||
let card = build_preview_card(tweet, config, url_prefix);
|
||||
|
||||
// Discord ignores Mastodon's PreviewCard object, but does render media
|
||||
// attachments.
|
||||
if attachments.is_empty()
|
||||
&& let Some(preview_card) = card.as_ref()
|
||||
&& let Some(image) = preview_card.image.as_ref()
|
||||
{
|
||||
let (width, height) = dimensions_or(preview_card.width, preview_card.height, (800, 419));
|
||||
attachments.push(make_attachment(
|
||||
format!("{}-card-0", tweet.id),
|
||||
"image",
|
||||
image.clone(),
|
||||
None,
|
||||
Some(preview_card.title.clone()),
|
||||
width,
|
||||
height,
|
||||
));
|
||||
}
|
||||
|
||||
let created_at = tweet.time.map_or_else(
|
||||
|| time::OffsetDateTime::now_utc().format(&Rfc3339).unwrap(),
|
||||
|ts| ts.format(&Rfc3339).unwrap(),
|
||||
);
|
||||
let account_created_at = tweet.user.join_date.map_or_else(
|
||||
|| time::OffsetDateTime::now_utc().format(&Rfc3339).unwrap(),
|
||||
|ts| ts.format(&Rfc3339).unwrap(),
|
||||
);
|
||||
|
||||
let status_url = format!("https://x.com/{}/status/{}", tweet.user.username, tweet.id);
|
||||
let profile_url = format!("https://x.com/{}", tweet.user.username);
|
||||
let avatar_url = formatters::get_pic_url(&tweet.user.user_pic, config.config.base64_media);
|
||||
let avatar_url = format!("{url_prefix}{avatar_url}");
|
||||
let header_url = (!tweet.user.banner.is_empty()).then(|| {
|
||||
format!(
|
||||
"{url_prefix}{}",
|
||||
formatters::get_pic_url(&tweet.user.banner, config.config.base64_media)
|
||||
)
|
||||
});
|
||||
|
||||
ActivityPubNote {
|
||||
id: tweet.id.to_string(),
|
||||
url: status_url.clone(),
|
||||
uri: status_url,
|
||||
created_at,
|
||||
edited_at: None,
|
||||
reblog: None,
|
||||
in_reply_to_id: None,
|
||||
in_reply_to_account_id: None,
|
||||
language: if tweet.lang.is_empty() {
|
||||
"en".to_owned()
|
||||
} else {
|
||||
tweet.lang.clone()
|
||||
},
|
||||
content: build_mastodon_content(tweet, replied_to, config),
|
||||
spoiler_text: String::new(),
|
||||
sensitive: tweet.sensitive,
|
||||
visibility: "public".to_owned(),
|
||||
application: MastodonApplication {
|
||||
name: application_name(&tweet.source),
|
||||
website: None,
|
||||
},
|
||||
media_attachments: attachments,
|
||||
account: MastodonAccount {
|
||||
id: tweet.user.id.clone(),
|
||||
display_name: tweet.user.fullname.clone(),
|
||||
username: tweet.user.username.clone(),
|
||||
acct: tweet.user.username.clone(),
|
||||
url: profile_url.clone(),
|
||||
uri: profile_url,
|
||||
created_at: account_created_at,
|
||||
locked: false,
|
||||
bot: false,
|
||||
discoverable: true,
|
||||
indexable: false,
|
||||
group: false,
|
||||
avatar: avatar_url.clone(),
|
||||
avatar_static: avatar_url,
|
||||
header: header_url.clone(),
|
||||
header_static: header_url,
|
||||
followers_count: tweet.user.followers,
|
||||
following_count: tweet.user.following,
|
||||
statuses_count: tweet.user.tweets,
|
||||
hide_collections: false,
|
||||
noindex: false,
|
||||
emojis: vec![],
|
||||
roles: vec![],
|
||||
fields: vec![],
|
||||
},
|
||||
mentions: vec![],
|
||||
tags: vec![],
|
||||
emojis: vec![],
|
||||
card,
|
||||
poll: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::application_name;
|
||||
|
||||
#[test]
|
||||
fn application_uses_source_label() {
|
||||
assert_eq!(
|
||||
application_name(
|
||||
r#"<a href="https://mobile.twitter.com" rel="nofollow">Twitter for iPhone</a>"#
|
||||
),
|
||||
"Twitter for iPhone"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,746 @@
|
||||
use super::*;
|
||||
use crate::{
|
||||
api::schema::CommunityNote,
|
||||
config::{
|
||||
AppConfig,
|
||||
CacheConfig,
|
||||
GifTranscodingConfig,
|
||||
PreferencesConfig,
|
||||
ServerConfig,
|
||||
},
|
||||
types::{
|
||||
Card,
|
||||
Photo,
|
||||
Poll,
|
||||
User,
|
||||
VideoType,
|
||||
VideoVariant,
|
||||
},
|
||||
};
|
||||
|
||||
fn test_config() -> Config {
|
||||
Config {
|
||||
server: ServerConfig {
|
||||
hostname: "teapot.test".to_owned(),
|
||||
title: "teapot".to_owned(),
|
||||
address: "127.0.0.1".to_owned(),
|
||||
port: 443,
|
||||
public_port: None,
|
||||
https: true,
|
||||
http_max_connections: 100,
|
||||
static_dir: "./public".to_owned(),
|
||||
},
|
||||
cache: CacheConfig {
|
||||
list_minutes: 120,
|
||||
rss_minutes: 10,
|
||||
max_entries: 50_000,
|
||||
},
|
||||
config: AppConfig {
|
||||
hmac_key: "0123456789abcdef0123456789abcdef".to_owned(),
|
||||
base64_media: true,
|
||||
enable_rss: true,
|
||||
enable_debug: false,
|
||||
debug_token: String::new(),
|
||||
proxy: String::new(),
|
||||
proxy_auth: String::new(),
|
||||
api_proxy: String::new(),
|
||||
disable_tid: false,
|
||||
max_concurrent_reqs: 2,
|
||||
paid_emoji: ":paid:".to_owned(),
|
||||
ai_emoji: ":ai:".to_owned(),
|
||||
kagi_token: String::new(),
|
||||
kagi_token_file: String::new(),
|
||||
},
|
||||
preferences: PreferencesConfig::default(),
|
||||
gif_transcoding: GifTranscodingConfig::default(),
|
||||
url_prefix: "https://teapot.test".to_owned(),
|
||||
}
|
||||
}
|
||||
|
||||
fn user(username: &str) -> User {
|
||||
User {
|
||||
id: username.to_owned(),
|
||||
username: username.to_owned(),
|
||||
fullname: username.to_owned(),
|
||||
user_pic: "https://pbs.twimg.com/profile_images/avatar.jpg".to_owned(),
|
||||
..User::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn tweet(id: i64, username: &str, text: &str) -> Tweet {
|
||||
Tweet {
|
||||
id,
|
||||
user: user(username),
|
||||
text: text.to_owned(),
|
||||
available: true,
|
||||
..Tweet::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn video() -> Video {
|
||||
Video {
|
||||
thumb: "https://pbs.twimg.com/ext_tw_video_thumb/thumb.jpg".to_owned(),
|
||||
variants: vec![VideoVariant {
|
||||
content_type: VideoType::Mp4,
|
||||
url: "https://video.twimg.com/ext_tw_video/1/pu/vid/720x1280/video.mp4?tag=12"
|
||||
.to_owned(),
|
||||
bitrate: 2_176_000,
|
||||
resolution: 1280,
|
||||
}],
|
||||
..Video::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn quoted_video_tweet() -> Tweet {
|
||||
let mut outer = tweet(100, "outer", "outer text");
|
||||
let mut quote = tweet(200, "quote", "quote text");
|
||||
quote.video = Some(video());
|
||||
outer.quote = Some(Box::new(quote));
|
||||
outer
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quoted_video_player_uses_quote_tweet_id() {
|
||||
let html = render_tweet_embed("ed_video_tweet(), &test_config(), false).into_string();
|
||||
|
||||
assert!(
|
||||
html.contains(r#"name="twitter:player" content="https://teapot.test/i/videos/tweet/200""#)
|
||||
);
|
||||
assert!(
|
||||
!html.contains(r#"name="twitter:player" content="https://teapot.test/i/videos/tweet/100""#)
|
||||
);
|
||||
assert!(html.contains(r#"name="twitter:player:stream" content="https://teapot.test/video/"#));
|
||||
assert!(html.contains("/enc/"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn video_embed_uses_signed_proxy_for_quoted_video() {
|
||||
let tweet = quoted_video_tweet();
|
||||
let html = render_video_embed(&tweet, &test_config()).into_string();
|
||||
|
||||
assert!(has_playable_video(&tweet));
|
||||
assert!(html.contains(r#"poster="/pic/enc/"#));
|
||||
assert!(html.contains(r#"<source src="/video/"#));
|
||||
assert!(html.contains(r#"name="twitter:player:stream" content="https://teapot.test/video/"#));
|
||||
assert!(html.contains("/enc/"));
|
||||
assert!(!html.contains("https://teapot.test/video/https://video.twimg.com"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_pub_quote_fallback_uses_signed_media_urls() {
|
||||
let mut tweet = quoted_video_tweet();
|
||||
tweet.reply = vec!["parent".to_owned()];
|
||||
tweet.reply_mentions_stripped = true;
|
||||
|
||||
let activity = build_activity_pub(&tweet, &test_config());
|
||||
let attachment = activity.media_attachments.first().unwrap();
|
||||
|
||||
assert_eq!(activity.media_attachments.len(), 1);
|
||||
assert_eq!(attachment.type_, "video");
|
||||
assert!(attachment.url.starts_with("https://teapot.test/video/"));
|
||||
assert!(attachment.url.contains("/enc/"));
|
||||
assert!(!attachment.url.contains("/video/https://video.twimg.com"));
|
||||
assert!(
|
||||
attachment
|
||||
.preview_url
|
||||
.as_ref()
|
||||
.is_some_and(|url| url.starts_with("https://teapot.test/pic/enc/"))
|
||||
);
|
||||
let dimensions = &attachment.meta.as_ref().unwrap().original;
|
||||
assert_eq!((dimensions.width, dimensions.height), (720, 1280));
|
||||
assert_eq!(dimensions.size, "720x1280");
|
||||
assert!(
|
||||
activity
|
||||
.content
|
||||
.contains(r#"<sub>↩ <a href="https://x.com/parent" class="u-url mention"> (@parent)</a>"#)
|
||||
);
|
||||
assert!(
|
||||
activity
|
||||
.content
|
||||
.contains(r#"</sub><br><a href="https://x.com/parent">@parent</a> outer text"#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_reply_context_links_and_truncates_original_post() {
|
||||
let mut reply = tweet(402, "replier", "reply text");
|
||||
reply.reply_id = 401;
|
||||
reply.reply = vec!["parent".to_owned()];
|
||||
reply.reply_mentions_stripped = true;
|
||||
let original_text = format!("{} final marker", "original context ".repeat(20));
|
||||
let original = tweet(401, "parent", &original_text);
|
||||
|
||||
let activity = build_activity_pub_with_reply(&reply, Some(&original), &test_config());
|
||||
|
||||
let context_start = activity
|
||||
.content
|
||||
.find(r#"<blockquote><b>↩ <a href="https://x.com/parent/status/401">Original post</a> · <a href="https://x.com/parent">@parent</a></b><br>"#)
|
||||
.unwrap();
|
||||
assert!(activity.content.find("reply text").unwrap() < context_start);
|
||||
assert!(!activity.content.contains("<sub>↩"));
|
||||
assert!(!activity.content.contains(">@parent</a> reply text"));
|
||||
assert!(activity.content.contains("original context"));
|
||||
assert!(activity.content.contains('…'));
|
||||
assert!(!activity.content.contains("final marker"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_preserves_intentional_space_runs() {
|
||||
let status = tweet(403, "spaced", "How it started How it's going");
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert!(
|
||||
activity
|
||||
.content
|
||||
.contains("How it started How it's going")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_includes_community_note_with_sources() {
|
||||
let mut status = tweet(403, "noted", "A claim");
|
||||
let note_text = "Germany did so in 2002. Source";
|
||||
let source_start = note_text.find("Source").unwrap();
|
||||
status.note = Some(CommunityNote {
|
||||
text: note_text.to_owned(),
|
||||
links: vec![(
|
||||
source_start,
|
||||
source_start + "Source".chars().count(),
|
||||
"https://example.com/source".to_owned(),
|
||||
)],
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert!(activity.content.contains(
|
||||
r#"<blockquote><b>📝 Community Note</b><br>Germany did so in 2002. <a href="https://example.com/source">Source</a></blockquote>"#,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_marks_sensitive_media() {
|
||||
let mut status = tweet(403, "sensitive", "content warning");
|
||||
status.sensitive = true;
|
||||
status.photos.push(Photo {
|
||||
url: "https://pbs.twimg.com/media/sensitive.jpg".to_owned(),
|
||||
width: 1200,
|
||||
height: 800,
|
||||
..Photo::default()
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert!(activity.sensitive);
|
||||
assert!(activity.spoiler_text.is_empty());
|
||||
assert_eq!(activity.media_attachments.len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_renders_article_title_and_excerpt() {
|
||||
let mut status = tweet(404, "writer", "");
|
||||
status.card = Some(Card {
|
||||
kind: CardKind::Article,
|
||||
url: "/writer/article/404".to_owned(),
|
||||
title: "Defining Taste".to_owned(),
|
||||
text: "Taste is the ability to make qualitative judgments.".to_owned(),
|
||||
dest: "Article · @writer".to_owned(),
|
||||
..Card::default()
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert!(activity.content.contains(
|
||||
r#"<blockquote><b>📄 <a href="https://teapot.test/writer/article/404">Defining Taste</a></b><br>Taste is the ability to make qualitative judgments.</blockquote>"#,
|
||||
));
|
||||
assert!(!activity.content.starts_with("<br>"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_renders_audio_space_preview() {
|
||||
let mut status = tweet(405, "host", "Join our Space");
|
||||
status.card = Some(Card {
|
||||
kind: CardKind::Audiospace,
|
||||
url: "https://x.com/i/spaces/1AxRnnrNvyDxl".to_owned(),
|
||||
title: "Building in public".to_owned(),
|
||||
text: "Scheduled · Jul 16, 2026 · 8:00 AM UTC".to_owned(),
|
||||
dest: "Hosted by Example (@host)".to_owned(),
|
||||
..Card::default()
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert!(activity.content.contains(
|
||||
r#"<blockquote><b>🎙️ <a href="https://x.com/i/spaces/1AxRnnrNvyDxl">Building in public</a></b><br>Scheduled · Jul 16, 2026 · 8:00 AM UTC<br>Hosted by Example (@host)</blockquote>"#,
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_renders_live_broadcast_preview() {
|
||||
let mut status = tweet(406, "tbpn", "Going live");
|
||||
status.card = Some(Card {
|
||||
kind: CardKind::Broadcast,
|
||||
url: "https://x.com/i/broadcasts/1XxyggAaLzvGM".to_owned(),
|
||||
title: "Stripe x PayPal".to_owned(),
|
||||
text: "Live now · 26 watching".to_owned(),
|
||||
dest: "Hosted by TBPN (@tbpn)".to_owned(),
|
||||
image: "https://video.pscp.tv/latest.jpg".to_owned(),
|
||||
..Card::default()
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
let html = render_status_page(
|
||||
&status,
|
||||
&html! {},
|
||||
&Prefs::default(),
|
||||
&test_config(),
|
||||
"tbpn",
|
||||
"406",
|
||||
true,
|
||||
)
|
||||
.into_string();
|
||||
let image_url = format!(
|
||||
"https://teapot.test{}",
|
||||
formatters::get_pic_url("https://video.pscp.tv/latest.jpg", true)
|
||||
);
|
||||
|
||||
assert!(activity.content.contains("📺"));
|
||||
assert!(activity.content.contains("Stripe x PayPal"));
|
||||
assert!(activity.content.contains("Live now · 26 watching"));
|
||||
assert!(activity.content.contains("Hosted by TBPN (@tbpn)"));
|
||||
assert_eq!(activity.media_attachments.len(), 1);
|
||||
assert_eq!(
|
||||
activity.media_attachments[0]
|
||||
.meta
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.original
|
||||
.size,
|
||||
"1920x1080"
|
||||
);
|
||||
assert!(html.contains(r#"property="og:site_name" content="teapot""#));
|
||||
assert!(html.contains(&format!(r#"property="og:image" content="{image_url}""#)));
|
||||
assert!(html.contains(r#"property="og:image:width" content="1920""#));
|
||||
assert!(html.contains(r#"property="twitter:card" content="summary_large_image""#));
|
||||
assert!(!build_oembed_url(&status, &test_config()).contains("&provider="));
|
||||
|
||||
status.text = "Stripe x PayPal https://t.co/broadcast".to_owned();
|
||||
assert_eq!(
|
||||
build_discord_embed_description(&status),
|
||||
"Stripe x PayPal https://t.co/broadcast"
|
||||
);
|
||||
let duplicate_activity = build_activity_pub(&status, &test_config());
|
||||
assert_eq!(
|
||||
duplicate_activity
|
||||
.content
|
||||
.matches("Stripe x PayPal")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert!(
|
||||
duplicate_activity
|
||||
.content
|
||||
.contains("<blockquote>📺 Live now · 26 watching<br>Hosted by TBPN (@tbpn)</blockquote>")
|
||||
);
|
||||
|
||||
status.text = "Stripe & PayPal https://t.co/broadcast".to_owned();
|
||||
status.card.as_mut().unwrap().title = "Stripe & PayPal".to_owned();
|
||||
let entity_activity = build_activity_pub(&status, &test_config());
|
||||
assert_eq!(
|
||||
entity_activity
|
||||
.content
|
||||
.matches("Stripe & PayPal")
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn oembed_footer_renders_content_disclosures() {
|
||||
let mut status = tweet(405, "creator", "Generated campaign artwork");
|
||||
status.paid_promotion = true;
|
||||
status.ai_generated = true;
|
||||
|
||||
let oembed_url = build_oembed_url(&status, &test_config());
|
||||
let html = render_tweet_embed(&status, &test_config(), true).into_string();
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert!(oembed_url.contains(&format!(
|
||||
"&provider={}",
|
||||
formatters::url_encode("teapot · :paid: Paid partnership · :ai: Made with AI")
|
||||
)));
|
||||
assert!(html.contains(
|
||||
r#"property="og:site_name" content="teapot · :paid: Paid partnership · :ai: Made with AI""#
|
||||
));
|
||||
assert!(!activity.content.contains("Paid partnership"));
|
||||
assert!(!activity.content.contains("Made with AI"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_includes_external_link_preview_card() {
|
||||
let mut status = tweet(403, "github", "Download the app");
|
||||
status.card = Some(Card {
|
||||
kind: CardKind::SummaryLarge,
|
||||
url: "https://github.com/mobile?utm_source=x".to_owned(),
|
||||
title: "GitHub Mobile".to_owned(),
|
||||
dest: "github.com".to_owned(),
|
||||
text: "The world's development platform, in your pocket".to_owned(),
|
||||
image: "https://pbs.twimg.com/card_img/2075083153316749312/qlKLI1ak?format=jpg&name=800x419"
|
||||
.to_owned(),
|
||||
..Card::default()
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
let card = activity.card.unwrap();
|
||||
|
||||
assert_eq!(card.url, "https://github.com/mobile?utm_source=x");
|
||||
assert_eq!(card.title, "GitHub Mobile");
|
||||
assert_eq!(
|
||||
card.description,
|
||||
"The world's development platform, in your pocket"
|
||||
);
|
||||
assert_eq!(card.type_, "link");
|
||||
assert_eq!((card.width, card.height), (800, 419));
|
||||
assert_eq!(card.provider_name, "github.com");
|
||||
assert!(
|
||||
card
|
||||
.image
|
||||
.is_some_and(|image| image.starts_with("https://teapot.test/pic/enc/"))
|
||||
);
|
||||
assert_eq!(activity.media_attachments.len(), 1);
|
||||
let attachment = &activity.media_attachments[0];
|
||||
assert_eq!(attachment.type_, "image");
|
||||
assert_eq!(attachment.description.as_deref(), Some("GitHub Mobile"));
|
||||
assert_eq!(
|
||||
attachment
|
||||
.meta
|
||||
.as_ref()
|
||||
.map(|meta| (meta.original.width, meta.original.height,)),
|
||||
Some((800, 419))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_renders_poll_and_quote_blocks() {
|
||||
let mut status = tweet(405, "poller", "pick one");
|
||||
status.poll = Some(Poll {
|
||||
options: vec!["first".to_owned(), "second".to_owned()],
|
||||
values: vec![3, 1],
|
||||
votes: 4,
|
||||
..Poll::default()
|
||||
});
|
||||
let quote = tweet(406, "quoted", "quoted text");
|
||||
status.quote = Some(Box::new(quote));
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert!(
|
||||
activity
|
||||
.content
|
||||
.contains("<blockquote>████████████████████████")
|
||||
);
|
||||
assert!(activity.content.contains("<b>first</b> 75%"));
|
||||
assert!(
|
||||
activity
|
||||
.content
|
||||
.contains("4 votes · Final results</blockquote>")
|
||||
);
|
||||
assert!(
|
||||
activity
|
||||
.content
|
||||
.contains(r#"<a href="https://x.com/quoted/status/406">Quoting</a> quoted"#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_uses_image_attachment_for_transcoded_gif() {
|
||||
let mut tweet = tweet(407, "gif", "animated");
|
||||
tweet.gif = Some(Gif {
|
||||
url: "https://video.twimg.com/tweet_video/animation.mp4".to_owned(),
|
||||
thumb: "https://pbs.twimg.com/tweet_video_thumb/animation.jpg".to_owned(),
|
||||
alt_text: "animation alt text".to_owned(),
|
||||
width: 400,
|
||||
height: 400,
|
||||
});
|
||||
let mut config = test_config();
|
||||
config.gif_transcoding.mode = GifTranscodingMode::Local;
|
||||
|
||||
let activity = build_activity_pub(&tweet, &config);
|
||||
let attachment = activity.media_attachments.first().unwrap();
|
||||
|
||||
assert_eq!(attachment.type_, "image");
|
||||
assert!(attachment.url.starts_with("https://teapot.test/gif/"));
|
||||
assert_eq!(attachment.preview_url, None);
|
||||
assert_eq!(
|
||||
attachment.description.as_deref(),
|
||||
Some("animation alt text")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn player_oembed_duplicates_engagement_in_provider() {
|
||||
let mut tweet = quoted_video_tweet();
|
||||
tweet.stats.views = 1_200;
|
||||
let html = render_tweet_embed(&tweet, &test_config(), true).into_string();
|
||||
|
||||
assert!(html.contains("&provider="));
|
||||
assert!(html.contains(r#"property="twitter:card" content="player""#));
|
||||
assert!(!html.contains(r#"property="og:video""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_pub_photo_urls_are_encoded_and_keep_alt_text() {
|
||||
let mut tweet = tweet(300, "photos", "photo text");
|
||||
tweet.photos.push(Photo {
|
||||
url: "https://pbs.twimg.com/media/photo.jpg?format=jpg&name=large".to_owned(),
|
||||
alt_text: "alt text".to_owned(),
|
||||
width: 2048,
|
||||
height: 1536,
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&tweet, &test_config());
|
||||
let attachment = activity.media_attachments.first().unwrap();
|
||||
|
||||
assert_eq!(attachment.type_, "image");
|
||||
assert!(
|
||||
attachment
|
||||
.url
|
||||
.starts_with("https://teapot.test/pic/orig/enc/")
|
||||
);
|
||||
assert_eq!(attachment.preview_url, None);
|
||||
assert_eq!(attachment.description.as_deref(), Some("alt text"));
|
||||
let dimensions = &attachment.meta.as_ref().unwrap().original;
|
||||
assert_eq!((dimensions.width, dimensions.height), (2048, 1536));
|
||||
assert_eq!(dimensions.size, "2048x1536");
|
||||
assert!((dimensions.aspect - 4.0 / 3.0).abs() < f64::EPSILON);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_mixed_media_prioritizes_video_with_photo_poster() {
|
||||
let mut status = tweet(301, "mixed", "mixed media");
|
||||
let photo_url = "https://pbs.twimg.com/media/photo.jpg";
|
||||
status.photos.push(Photo {
|
||||
url: photo_url.to_owned(),
|
||||
width: 1200,
|
||||
height: 1600,
|
||||
..Photo::default()
|
||||
});
|
||||
status.video = Some(video());
|
||||
let config = test_config();
|
||||
|
||||
let activity = build_activity_pub(&status, &config);
|
||||
let expected_poster = format!(
|
||||
"https://teapot.test{}",
|
||||
formatters::get_pic_url(photo_url, config.config.base64_media),
|
||||
);
|
||||
|
||||
assert_eq!(activity.media_attachments.len(), 2);
|
||||
assert_ne!(
|
||||
activity.media_attachments[0].id,
|
||||
activity.media_attachments[1].id
|
||||
);
|
||||
assert_eq!(activity.media_attachments[0].type_, "video");
|
||||
assert_eq!(activity.media_attachments[1].type_, "image");
|
||||
assert_eq!(
|
||||
activity.media_attachments[0].preview_url.as_deref(),
|
||||
Some(expected_poster.as_str()),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_pub_links_to_additional_videos() {
|
||||
let mut status = tweet(302, "videos", "two videos");
|
||||
status.video = Some(video());
|
||||
let mut second = video();
|
||||
second.thumb = "https://pbs.twimg.com/ext_tw_video_thumb/second.jpg".to_owned();
|
||||
second.variants[0].url =
|
||||
"https://video.twimg.com/ext_tw_video/2/pu/vid/1280x720/video.mp4".to_owned();
|
||||
status.additional_videos.push(second);
|
||||
|
||||
let activity = build_activity_pub(&status, &test_config());
|
||||
|
||||
assert_eq!(activity.media_attachments.len(), 1);
|
||||
assert_eq!(activity.media_attachments[0].type_, "video");
|
||||
assert!(activity.content.contains(
|
||||
r#"<sub>🗂️ <a href="https://teapot.test/videos/status/302">View all 2 media</a></sub><br>"#
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn status_page_uses_author_first_embed_metadata() {
|
||||
let tweet = tweet(400, "G2CSGO", "Trying hard to win/lose/something");
|
||||
let html = render_status_page(
|
||||
&tweet,
|
||||
&html! {},
|
||||
&Prefs::default(),
|
||||
&test_config(),
|
||||
"G2CSGO",
|
||||
"400",
|
||||
false,
|
||||
)
|
||||
.into_string();
|
||||
|
||||
assert!(!html.contains(
|
||||
r#"property="og:title" content="G2CSGO (@G2CSGO): "Trying hard to win/lose/something"""#
|
||||
));
|
||||
assert!(html.contains(r#"property="twitter:title" content="G2CSGO (@G2CSGO)""#));
|
||||
assert!(html.contains(r#"property="og:title" content="G2CSGO (@G2CSGO)""#));
|
||||
assert!(
|
||||
html.contains(r#"property="og:description" content="Trying hard to win/lose/something""#)
|
||||
);
|
||||
assert!(html.contains(r#"property="twitter:site" content="@G2CSGO""#));
|
||||
assert!(html.contains(r#"property="og:url" content="https://x.com/G2CSGO/status/400""#));
|
||||
assert!(html.contains(r#"link rel="apple-touch-icon" href="https://teapot.test/pic/enc/"#));
|
||||
assert!(
|
||||
!html.contains(r#"link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png""#)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_activity_page_defers_photo_media_to_mastodon_api() {
|
||||
let mut tweet = tweet(401, "photos", "photo text");
|
||||
tweet.time = time::OffsetDateTime::from_unix_timestamp(1).ok();
|
||||
tweet.photos.push(Photo {
|
||||
url: "https://pbs.twimg.com/media/photo.jpg".to_owned(),
|
||||
width: 2048,
|
||||
height: 1536,
|
||||
..Photo::default()
|
||||
});
|
||||
|
||||
let html = render_status_page(
|
||||
&tweet,
|
||||
&html! {},
|
||||
&Prefs::default(),
|
||||
&test_config(),
|
||||
"photos",
|
||||
"401",
|
||||
true,
|
||||
)
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains(r##"property="theme-color" content="#005ace""##));
|
||||
assert!(html.contains(r##"rel="mask-icon" href="/safari-pinned-tab.svg" color="#005ace""##));
|
||||
assert!(html.contains(
|
||||
r#"href="https://teapot.test/users/photos/statuses/401" type="application/activity+json""#
|
||||
));
|
||||
assert!(html.contains(r#"property="twitter:card" content="summary_large_image""#));
|
||||
assert!(!html.contains(r#"property="og:image" content="https://teapot.test/pic/"#));
|
||||
assert!(!html.contains("article:published_time"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn discord_activity_page_uses_avatar_for_text_only_status() {
|
||||
let tweet = tweet(402, "plain", "plain text");
|
||||
let html = render_status_page(
|
||||
&tweet,
|
||||
&html! {},
|
||||
&Prefs::default(),
|
||||
&test_config(),
|
||||
"plain",
|
||||
"402",
|
||||
true,
|
||||
)
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains(r#"property="og:image" content="https://teapot.test/pic/enc/"#));
|
||||
assert!(html.contains(r#"property="twitter:image" content="0""#));
|
||||
assert!(html.contains(r#"property="twitter:card" content="summary""#));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn generic_photo_metadata_includes_real_dimensions() {
|
||||
let mut tweet = tweet(403, "photos", "photo text");
|
||||
tweet.photos.push(Photo {
|
||||
url: "https://pbs.twimg.com/media/photo.jpg".to_owned(),
|
||||
width: 2048,
|
||||
height: 1536,
|
||||
..Photo::default()
|
||||
});
|
||||
|
||||
let html = render_status_page(
|
||||
&tweet,
|
||||
&html! {},
|
||||
&Prefs::default(),
|
||||
&test_config(),
|
||||
"photos",
|
||||
"403",
|
||||
false,
|
||||
)
|
||||
.into_string();
|
||||
|
||||
assert!(html.contains(r#"property="og:image:width" content="2048""#));
|
||||
assert!(html.contains(r#"property="og:image:height" content="1536""#));
|
||||
assert!(html.contains(r#"property="twitter:image" content="https://teapot.test/pic/enc/"#));
|
||||
assert!(!html.contains("twitter:image:src"));
|
||||
assert!(!html.contains("application/activity+json"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_payload_matches_discord_mastodon_shape() {
|
||||
let mut tweet = tweet(404, "photos", "hello\nworld");
|
||||
tweet.stats.replies = 2;
|
||||
tweet.stats.retweets = 3;
|
||||
tweet.stats.likes = 4;
|
||||
tweet.stats.views = 5;
|
||||
tweet.lang = "en".to_owned();
|
||||
tweet.photos.extend([
|
||||
Photo {
|
||||
url: "https://pbs.twimg.com/media/one.jpg".to_owned(),
|
||||
width: 2048,
|
||||
height: 1536,
|
||||
..Photo::default()
|
||||
},
|
||||
Photo {
|
||||
url: "https://pbs.twimg.com/media/two.jpg".to_owned(),
|
||||
width: 1024,
|
||||
height: 2048,
|
||||
..Photo::default()
|
||||
},
|
||||
]);
|
||||
|
||||
let activity = build_activity_pub(&tweet, &test_config());
|
||||
|
||||
assert_eq!(activity.id, "404");
|
||||
assert_eq!(activity.url, "https://x.com/photos/status/404");
|
||||
assert_eq!(activity.language, "en");
|
||||
assert_eq!(activity.media_attachments.len(), 2);
|
||||
assert_eq!(activity.media_attachments[0].preview_url, None);
|
||||
assert_eq!(
|
||||
activity.media_attachments[1]
|
||||
.meta
|
||||
.as_ref()
|
||||
.map(|meta| (meta.original.width, meta.original.height)),
|
||||
Some((1024, 2048))
|
||||
);
|
||||
assert!(activity.content.contains("hello<br>︀︀world<br><br><b>"));
|
||||
assert!(activity.content.contains("in_reply_to=404"));
|
||||
assert!(activity.content.contains("tweet_id=404"));
|
||||
assert_eq!(activity.account.avatar, activity.account.avatar_static);
|
||||
assert_eq!(activity.account.url, "https://x.com/photos");
|
||||
assert_eq!(activity.account.uri, "https://x.com/photos");
|
||||
assert!(activity.card.is_none());
|
||||
assert!(activity.poll.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activity_media_with_missing_dimensions_uses_finite_fallback() {
|
||||
let mut tweet = tweet(405, "photos", "photo");
|
||||
tweet.photos.push(Photo {
|
||||
url: "https://pbs.twimg.com/media/no-size.jpg".to_owned(),
|
||||
..Photo::default()
|
||||
});
|
||||
|
||||
let activity = build_activity_pub(&tweet, &test_config());
|
||||
let dimensions = &activity.media_attachments[0]
|
||||
.meta
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.original;
|
||||
assert_eq!((dimensions.width, dimensions.height), (1200, 675));
|
||||
assert!(dimensions.aspect.is_finite());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn best_dimensions_parse_non_widescreen_video_url() {
|
||||
assert_eq!(video().best_dimensions(), (720, 1280));
|
||||
}
|
||||
+1
-1
@@ -201,7 +201,7 @@ fn render_atomic(
|
||||
div class="code-block" {
|
||||
div class="code-header" {
|
||||
span class="code-lang" { (lang) }
|
||||
button class="copy-btn" onclick="navigator.clipboard.writeText(this.closest('.code-block').querySelector('code').textContent).then(()=>{let t=document.createElement('div');t.className='copy-toast';t.textContent='Copied!';this.closest('.code-block').appendChild(t);setTimeout(()=>t.remove(),1500)})" title="Copy" {
|
||||
button class="copy-btn" type="button" title="Copy" {
|
||||
(PreEscaped(COPY_ICON_SVG))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -302,7 +302,7 @@ fn render_photo_rail(photos: &[GalleryPhoto], user: &User, config: &Config) -> M
|
||||
div class="photo-rail-header" {
|
||||
a href=(format!("/{}/media", user.username)) {
|
||||
span class="photo-rail-icon-wrap" {
|
||||
span class="material-symbols-outlined photo-rail-media-icon" { "photo_library" }
|
||||
span class="icon-picture photo-rail-media-icon" aria-hidden="true" {}
|
||||
span class="photo-rail-media-badge" { (count) }
|
||||
}
|
||||
span class="photo-rail-media-label" { "Media" }
|
||||
@@ -314,7 +314,7 @@ fn render_photo_rail(photos: &[GalleryPhoto], user: &User, config: &Config) -> M
|
||||
label for="photo-rail-grid-toggle" class="photo-rail-header-mobile" {
|
||||
div class="photo-rail-header-mobile-inner" {
|
||||
span class="photo-rail-icon-wrap" {
|
||||
span class="material-symbols-outlined photo-rail-media-icon" { "photo_library" }
|
||||
span class="icon-picture photo-rail-media-icon" aria-hidden="true" {}
|
||||
span class="photo-rail-media-badge" { (count) }
|
||||
}
|
||||
span { "Media" }
|
||||
|
||||
@@ -111,7 +111,7 @@ pub fn verified_icon(user: &User) -> Markup {
|
||||
/// Render a linked user.
|
||||
///
|
||||
/// Renders as fullname (when class doesn't contain "username") or "@username".
|
||||
/// The verified icon is NOT included — callers must add `verified_icon()` as a
|
||||
/// The verified icon is not included. Callers must add `verified_icon()` as a
|
||||
/// sibling after this element.
|
||||
pub fn link_user(user: &User, class: &str) -> Markup {
|
||||
let is_name = !class.contains("username");
|
||||
|
||||
+16
-9
@@ -82,7 +82,7 @@ pub fn render_user_rss_with_pinned(
|
||||
let description = if user.bio.is_empty() {
|
||||
format!("Twitter feed for: {handle}. Generated by {url_prefix}")
|
||||
} else {
|
||||
let bio = escape_xml(&strip_html(&user.bio));
|
||||
let bio = strip_html(&user.bio);
|
||||
format!("{bio} | Twitter feed for: {handle}. Generated by {url_prefix}")
|
||||
};
|
||||
let link = format!("{url_prefix}/{}", user.username);
|
||||
@@ -114,7 +114,7 @@ pub fn render_search_rss(query: &str, tweets: &Tweets, config: &Config) -> Strin
|
||||
let esc_name = escape_xml(query);
|
||||
let encoded_q = percent_encoding::utf8_percent_encode(query, percent_encoding::NON_ALPHANUMERIC);
|
||||
let items = render_tweet_items(tweets, config);
|
||||
let description = format!("Twitter feed for: Search \"{esc_name}\". Generated by {url_prefix}");
|
||||
let description = format!("Twitter feed for: Search \"{query}\". Generated by {url_prefix}");
|
||||
rss_channel(
|
||||
&format!("{url_prefix}/search/rss?q={encoded_q}"),
|
||||
&format!("Search results for \"{esc_name}\""),
|
||||
@@ -286,7 +286,7 @@ fn render_rss_tweet(tweet: &Tweet, config: &Config) -> String {
|
||||
}
|
||||
}
|
||||
if let Some(ref video) = tweet.video {
|
||||
// Video: linked thumbnail with "Video" label
|
||||
// Render videos as linked thumbnails with a "Video" label
|
||||
if !video.thumb.is_empty() {
|
||||
let thumb_url = get_pic_url(&video.thumb, config);
|
||||
let tweet_link = format!("{}{}", url_prefix, tweet_link(tweet));
|
||||
@@ -313,9 +313,9 @@ fn render_rss_tweet(tweet: &Tweet, config: &Config) -> String {
|
||||
}
|
||||
}
|
||||
if let Some(ref gif) = tweet.gif {
|
||||
// GIF: <video> element
|
||||
// Render GIFs with a <video> element
|
||||
let thumb = format!("{}{}", url_prefix, get_pic_url(&gif.thumb, config));
|
||||
let url = format!("{}{}", url_prefix, get_pic_url(&gif.url, config));
|
||||
let url = format!("{}{}", url_prefix, get_video_url(&gif.url, config));
|
||||
let _ = write!(
|
||||
body,
|
||||
r#"<video poster="{thumb}" autoplay muted loop style="max-width:250px;">
|
||||
@@ -358,7 +358,8 @@ fn render_rss_tweet(tweet: &Tweet, config: &Config) -> String {
|
||||
— <cite><a href="{quote_link}">{quote_link}</a>
|
||||
</footer>
|
||||
</blockquote>"#,
|
||||
quote.user.fullname, quote.user.username
|
||||
html_escape("e.user.fullname),
|
||||
quote.user.username
|
||||
);
|
||||
}
|
||||
|
||||
@@ -367,7 +368,7 @@ fn render_rss_tweet(tweet: &Tweet, config: &Config) -> String {
|
||||
|
||||
/// Render a single tweet as an RSS item.
|
||||
fn render_tweet_item(tweet: &Tweet, url_prefix: &str, config: &Config) -> String {
|
||||
// Handle retweets: extract inner tweet
|
||||
// Extract the inner tweet from retweets
|
||||
let retweet_username = if tweet.retweet.is_some() {
|
||||
tweet.user.username.as_str()
|
||||
} else {
|
||||
@@ -395,9 +396,10 @@ fn render_tweet_item(tweet: &Tweet, url_prefix: &str, config: &Config) -> String
|
||||
format!("{url_prefix}{}", tweet_link(display_tweet))
|
||||
};
|
||||
let title = formatters::sanitize_xml(&get_title(display_tweet, retweet_username));
|
||||
let description = formatters::sanitize_xml(&render_rss_tweet(display_tweet, config));
|
||||
let description = formatters::sanitize_xml(&render_rss_tweet(display_tweet, config))
|
||||
.replace("]]>", "]]]]><![CDATA[>");
|
||||
|
||||
// GUID: use bare tweet ID for new tweets (>= cutoff), URL for old
|
||||
// Use bare tweet IDs for new tweets and URLs for tweets before the cutoff
|
||||
let guid = if display_tweet.id >= GUID_CUTOFF {
|
||||
format!(
|
||||
r#" <guid isPermaLink="false">{}</guid>"#,
|
||||
@@ -427,6 +429,11 @@ fn get_pic_url(url: &str, config: &Config) -> String {
|
||||
formatters::get_pic_url(url, config.config.base64_media)
|
||||
}
|
||||
|
||||
/// Helper to get a signed proxied video URL path (not full URL).
|
||||
fn get_video_url(url: &str, config: &Config) -> String {
|
||||
formatters::get_vid_url(url, &config.config.hmac_key, config.config.base64_media)
|
||||
}
|
||||
|
||||
/// Escape XML special characters (delegates to shared `html_escape`).
|
||||
fn escape_xml(input: &str) -> String {
|
||||
html_escape(input).into_owned()
|
||||
|
||||
+4
-11
@@ -11,6 +11,7 @@ use crate::{
|
||||
Tweets,
|
||||
User,
|
||||
},
|
||||
utils::formatters,
|
||||
views::{
|
||||
renderutils::icon,
|
||||
user_list::render_user,
|
||||
@@ -74,7 +75,7 @@ pub fn render_search_results_with_prefs(
|
||||
|
||||
@if let Some(cur) = cursor {
|
||||
div class="show-more" {
|
||||
a href=(format!("/search?q={}&cursor={}", urlencoding::encode(query), cur)) {
|
||||
a href=(format!("/search?q={}&cursor={}", formatters::url_encode(query), cur)) {
|
||||
"Load more"
|
||||
}
|
||||
}
|
||||
@@ -131,7 +132,7 @@ pub fn render_user_search_results(
|
||||
|
||||
@if let Some(cur) = cursor {
|
||||
div class="show-more" {
|
||||
a href=(format!("/search?q={}&f=users&cursor={}", urlencoding::encode(query), cur)) {
|
||||
a href=(format!("/search?q={}&f=users&cursor={}", formatters::url_encode(query), cur)) {
|
||||
"Load more"
|
||||
}
|
||||
}
|
||||
@@ -148,7 +149,7 @@ pub fn render_user_search_results(
|
||||
|
||||
/// Render search tabs (Top / Latest / Media / Users).
|
||||
fn render_search_tabs(query: &str, active: &str) -> Markup {
|
||||
let encoded_query = urlencoding::encode(query);
|
||||
let encoded_query = formatters::url_encode(query);
|
||||
let tabs: &[(&str, &str, &str)] = &[
|
||||
("top", "Top", &format!("/search?q={encoded_query}&f=top")),
|
||||
("tweets", "Latest", &format!("/search?q={encoded_query}")),
|
||||
@@ -307,11 +308,3 @@ fn gen_search_checkbox(name: &str, label: &str, checked: bool) -> Markup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod urlencoding {
|
||||
use crate::utils::formatters;
|
||||
|
||||
pub fn encode(input: &str) -> String {
|
||||
formatters::url_encode(input)
|
||||
}
|
||||
}
|
||||
@@ -109,7 +109,7 @@ fn render_thread(thread: &[&Tweet], config: &Config, prefs: Option<&Prefs>) -> M
|
||||
html! {
|
||||
div class="thread-line" {
|
||||
@for (idx, tweet) in sorted.iter().enumerate() {
|
||||
// Detect gap: if this tweet's reply_id doesn't match the previous tweet's id
|
||||
// Detect a gap when reply_id does not match the previous tweet's ID
|
||||
@if idx > 0 && tweet.reply_id != sorted[idx - 1].id {
|
||||
div class="timeline-item thread more-replies-thread" {
|
||||
div class="more-replies" {
|
||||
@@ -141,7 +141,7 @@ fn render_thread(thread: &[&Tweet], config: &Config, prefs: Option<&Prefs>) -> M
|
||||
}
|
||||
|
||||
/// Full timeline rendering with all options.
|
||||
/// `groups` preserves conversation structure from the API: each inner
|
||||
/// `groups` preserves conversation structure from the API. Each inner
|
||||
/// Vec<Tweet> is a conversation thread (parent -> reply chain).
|
||||
fn render_timeline_full(
|
||||
groups: &[Tweets],
|
||||
@@ -181,7 +181,7 @@ fn render_timeline_full(
|
||||
}
|
||||
}
|
||||
|
||||
// Render groups: each group is a conversation thread from the API
|
||||
// Each group renders as one conversation thread from the API
|
||||
@for group in groups {
|
||||
// Filter out pinned duplicates
|
||||
@let filtered = group.iter().filter(|tweet| Some(tweet.id) != pinned_id).collect::<Vec<_>>();
|
||||
|
||||
+13
-12
@@ -154,7 +154,7 @@ impl<'a> TweetRenderer<'a> {
|
||||
let extra_class = self.extra_class;
|
||||
let index = self.index;
|
||||
|
||||
// Handle unavailable tweets (tombstoned) — use <a> to make clickable
|
||||
// Use <a> to make unavailable tombstoned tweets clickable
|
||||
if !tweet.available {
|
||||
let link = get_link(tweet);
|
||||
let text = if !tweet.tombstone.is_empty() {
|
||||
@@ -174,14 +174,14 @@ impl<'a> TweetRenderer<'a> {
|
||||
};
|
||||
}
|
||||
|
||||
// Handle retweets: extract the inner tweet and show retweet header
|
||||
// Extract the inner tweet and show its retweet header
|
||||
let (display_tweet, retweet_by) = tweet
|
||||
.retweet
|
||||
.as_ref()
|
||||
.map_or((tweet, None), |rt| (rt.as_ref(), Some(&tweet.user)));
|
||||
|
||||
// Build CSS class based on context.
|
||||
// Only "thread-last" for the final tweet; no start/middle classes.
|
||||
// Use "thread-last" only for the final tweet. Omit start and middle classes.
|
||||
// Includes .author-{user} and .retweet-{user} for adblock filtering.
|
||||
let author_class = format!("author-{}", display_tweet.user.username);
|
||||
let retweet_class = retweet_by.map(|rt_user| format!("retweet-{}", rt_user.username));
|
||||
@@ -264,7 +264,7 @@ impl<'a> TweetRenderer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// Reply indicator: only shown for the first tweet (index 0) in a
|
||||
// Show the reply indicator only for the first tweet in a
|
||||
// thread group, not for main tweets, not in thread context (conversation
|
||||
// page), and not for self-replies.
|
||||
@if !is_main && index == 0 && thread_ctx == ThreadContext::None && !display_tweet.reply.is_empty() && !is_reply_to_self(display_tweet, pinned) {
|
||||
@@ -306,7 +306,7 @@ impl<'a> TweetRenderer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
// Attribution (when another user owns the content) — after tweet-content
|
||||
// Show attribution after tweet-content when another user owns it
|
||||
@if let Some(ref attr_user) = display_tweet.attribution {
|
||||
a class="attribution" href=(format!("/{}", attr_user.username)) {
|
||||
(render_mini_avatar(attr_user, config, prefs))
|
||||
@@ -337,7 +337,7 @@ impl<'a> TweetRenderer<'a> {
|
||||
@if let Some(ref gif) = display_tweet.gif {
|
||||
@let autoplay_gifs = prefs.is_none_or(|pref| pref.autoplay_gifs);
|
||||
@let poster = get_small_pic(&gif.thumb, config);
|
||||
@let gif_src = formatters::get_pic_url(&gif.url, config.config.base64_media);
|
||||
@let gif_src = formatters::get_vid_url(&gif.url, &config.config.hmac_key, config.config.base64_media);
|
||||
div class="attachments media-gif" {
|
||||
div class="gallery-gif" style="max-height: unset" {
|
||||
div class="attachment" {
|
||||
@@ -692,7 +692,7 @@ fn render_card_content(card: &Card) -> Markup {
|
||||
@if !card.text.is_empty() {
|
||||
p class="card-description" { (card.text) }
|
||||
}
|
||||
// Format destination: "List · 42 Members" for list/community, else raw dest
|
||||
// Format list and community destinations as "List · 42 Members"
|
||||
@if card.member_count > 0 && !card.dest.is_empty() {
|
||||
span class="card-destination" {
|
||||
(format!("{} · {} Members", card.dest, card.member_count))
|
||||
@@ -706,8 +706,9 @@ fn render_card_content(card: &Card) -> Markup {
|
||||
|
||||
/// Render card preview for links.
|
||||
///
|
||||
/// Small cards: App, Player, Summary, `StoreLink`.
|
||||
/// Large cards: everything else (`SummaryLarge`, `PromoWebsite`, etc.).
|
||||
/// App, Player, Summary, and `StoreLink` use small cards.
|
||||
/// All other variants, including `SummaryLarge` and `PromoWebsite`, use large
|
||||
/// cards.
|
||||
fn render_card(card: &Card, config: &Config, prefs: Option<&Prefs>) -> Markup {
|
||||
// Skip hidden or unknown cards
|
||||
if matches!(card.kind, CardKind::Hidden | CardKind::Unknown) {
|
||||
@@ -754,7 +755,7 @@ fn render_user_avatar(user: &User, config: &Config, prefs: Option<&Prefs>) -> Ma
|
||||
}
|
||||
|
||||
/// Render tweet text and location as a single HTML string (for verbatim
|
||||
/// output). This avoids wrapping in an extra `<p>` tag — the original version
|
||||
/// output). This avoids wrapping in an extra `<p>` tag. The original version
|
||||
/// uses `verbatim` directly inside the content div.
|
||||
fn render_tweet_text_html(
|
||||
text: &str,
|
||||
@@ -824,7 +825,7 @@ fn render_video(video: &Video, config: &Config, prefs: Option<&Prefs>) -> Markup
|
||||
(button_referer("/enablemp4", "Enable mp4 playback", "", "", ""))
|
||||
}
|
||||
} @else if let Some(mp4_url) = video.best_mp4_url() {
|
||||
// MP4 playback: native video element with controls
|
||||
// Render MP4 playback with a native video element and controls
|
||||
@let video_src = formatters::get_vid_url(mp4_url, &config.config.hmac_key, config.config.base64_media);
|
||||
video poster=(thumb) controls="" muted=[mute_videos.then_some("")] {
|
||||
source src=(video_src) type="video/mp4";
|
||||
@@ -851,7 +852,7 @@ fn render_video(video: &Video, config: &Config, prefs: Option<&Prefs>) -> Markup
|
||||
}
|
||||
|
||||
/// Render tweet stats using `icon()` helper from renderutils.
|
||||
/// Retweet count links to the retweeters page; quote count links to search.
|
||||
/// Retweet counts link to the retweeters page. Quote counts link to search.
|
||||
/// Optional `extra` markup is appended after the stat icons (used for the
|
||||
/// reply-sort toggle on the main tweet).
|
||||
fn render_stats(stats: &TweetStats, username: &str, id: i64, extra: Option<&Markup>) -> Markup {
|
||||
|
||||
Reference in new issue
Block a user