views: improve embeds, feeds, and privacy-safe rendering

This commit is contained in:
4cuck committed 2026-07-15 21:06:50 -04:00
1 parent 6e3418b10d
commit bbf2ab3442
16 files changed
+1725 -1666

No files matched your search

+5 -10
View File
@@ -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;
+1 -1
View File
@@ -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;
+1 -3
View File
@@ -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;
+21
View File
@@ -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.
}
});
+15 -14
View File
@@ -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() {
// &#x27; is the HTML entity for single quote — the #x27 must NOT become
fn html_entity_is_not_treated_as_hashtag() {
// &#x27; 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&#x27;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 &amp; &#x27;end");
assert!(
!result.contains(r#"<a href="/search?q=%23x27">"#),
@@ -344,15 +344,16 @@ mod tests {
}
#[test]
fn test_no_double_escape() {
// Twitter returns &lt; in full_text — should stay as &lt; not become &amp;lt;
fn avoids_double_escape() {
// Twitter returns &lt; in full_text. It must stay &lt; instead of becoming
// &amp;lt;
let result = expand_entities("x &lt; y", &[]);
assert_eq!(result, "x &lt; y");
assert!(!result.contains("&amp;"), "should not double-escape");
}
#[test]
fn test_passthrough_preserves_twitter_escaping() {
fn passthrough_preserves_twitter_escaping() {
// Twitter pre-escapes: &amp; &lt; &gt; &quot;
let result = expand_entities("A &amp; B &lt; C &gt; D", &[]);
assert_eq!(result, "A &amp; B &lt; C &gt; D");
+6 -6
View File
@@ -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
View File
File diff suppressed because it is too large. Load diff
+879
View File
@@ -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("&nbsp;");
} 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>{}&ensp;</b>", parts.join("&ensp;"))
}
}
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>&emsp;{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(&quote.user.fullname),
html_escape(&quote.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"
);
}
}
+746
View File
@@ -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(&quoted_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&nbsp; &nbsp; &nbsp;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 &amp; 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 &amp; 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>&emsp;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("&amp;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): &quot;Trying hard to win/lose/something&quot;""#
));
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
View File
@@ -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))
}
}
+2 -2
View File
@@ -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" }
+1 -1
View File
@@ -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
View File
@@ -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(&quote.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
View File
@@ -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)
}
}
+3 -3
View File
@@ -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
View File
@@ -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 {