diff --git a/src/routes/embed.rs b/src/routes/embed.rs index 69341ba..f03a331 100644 --- a/src/routes/embed.rs +++ b/src/routes/embed.rs @@ -172,7 +172,7 @@ async fn oembed( author_url, provider_name: state.config.server.title.clone(), provider_url: state.config.url_prefix().to_owned(), - title: "Post", + title: "Embed", kind: "rich", version: "1.0", }; diff --git a/src/views/embed.rs b/src/views/embed.rs index dae5789..ba9d7b0 100644 --- a/src/views/embed.rs +++ b/src/views/embed.rs @@ -684,16 +684,14 @@ pub fn render_status_page( let avatar_url = formatters::get_pic_url(&tweet.user.user_pic, config.config.base64_media); let head_extra = html! { - // Theme color for Discord embed accent - meta name="theme-color" content="#1F1F1F"; - - // Profile pic as author icon in Discord embeds + link rel="canonical" href=(canonical); + meta property="og:url" content=(canonical); + meta property="twitter:site" content=(format!("@{}", tweet.user.username)); + meta property="twitter:creator" content=(format!("@{}", tweet.user.username)); + meta property="theme-color" content="#1F1F1F"; + meta property="twitter:title" content=(og_title); link rel="apple-touch-icon" href=(format!("{url_prefix}{avatar_url}")); - // Override OG title/description with tweet-specific values - meta property="og:title" content=(og_title); - meta property="og:description" content=(description); - // Publish time for Discord footer timestamp @if let Some(ts) = tweet.time { @let iso = ts.format(&Rfc3339).unwrap_or_default(); @@ -703,6 +701,11 @@ pub fn render_status_page( // Media-specific OG/twitter tags (render_media_meta_tags(tweet, config, url_prefix)) + meta property="og:title" content=(og_title); + meta property="og:description" content=(description); + meta property="og:site_name" content=(config.server.title); + meta property="og:locale" content="en_US"; + // ActivityPub discovery link link rel="alternate" href=(format!("{url_prefix}/users/{username}/statuses/{id}")) @@ -711,7 +714,8 @@ pub fn render_status_page( // oEmbed link for engagement metrics link rel="alternate" href=(oembed_url) - type="application/json+oembed"; + type="application/json+oembed" + title=(tweet.user.fullname); }; let rss_url = format!("/{username}/status/{id}/rss"); @@ -723,6 +727,7 @@ pub fn render_status_page( .canonical(&canonical) .referer(&referer) .head_extra(&head_extra) + .custom_open_graph() .render() } @@ -902,6 +907,37 @@ mod tests { assert_eq!(attachment.description.as_deref(), Some("alt text")); } + #[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", + ) + .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 best_dimensions_parse_non_widescreen_video_url() { assert_eq!(video().best_dimensions(), (720, 1280)); diff --git a/src/views/layout.rs b/src/views/layout.rs index 2210e32..864cf7e 100644 --- a/src/views/layout.rs +++ b/src/views/layout.rs @@ -33,6 +33,7 @@ pub struct PageLayout<'a> { og_image: &'a str, og_type: &'a str, head_extra: Option<&'a Markup>, + custom_og: bool, } impl<'a> PageLayout<'a> { @@ -49,6 +50,7 @@ impl<'a> PageLayout<'a> { og_image: "", og_type: "article", head_extra: None, + custom_og: false, } } @@ -94,6 +96,12 @@ impl<'a> PageLayout<'a> { self } + /// Let `head_extra` provide the complete OpenGraph/Twitter card block. + pub const fn custom_open_graph(mut self) -> Self { + self.custom_og = true; + self + } + pub fn render(self) -> Markup { let (infinite_scroll, theme, sticky_nav, use_twemoji, font_size) = self.prefs.map_or((false, None, true, true, ""), |prefs| { @@ -133,7 +141,9 @@ impl<'a> PageLayout<'a> { meta name="theme-color" content="#1F1F1F"; // Favicon and manifest links - link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"; + @if !self.custom_og { + link rel="apple-touch-icon" sizes="180x180" href="/apple-touch-icon.png"; + } link rel="icon" type="image/png" sizes="32x32" href="/favicon-32x32.png"; link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png"; link rel="manifest" href="/site.webmanifest"; @@ -171,22 +181,24 @@ impl<'a> PageLayout<'a> { } // OpenGraph meta tags - meta property="og:site_name" content=(self.config.server.title); - meta property="og:locale" content="en_US"; - meta property="og:title" content=(self.title); - meta property="og:description" content=(strip_html(self.description)); - // head_extra provides its own og:type and twitter:card - // when present (e.g. video/player for GIF embeds). - // Discord takes the first occurrence, so suppress defaults. - @if self.head_extra.is_none() { - meta property="og:type" content=(self.og_type); - } - @if !self.og_image.is_empty() { - meta property="og:image" content=(self.og_image); - meta property="twitter:image:src" content=(self.og_image); - } - @if self.head_extra.is_none() && !self.rss.is_empty() { - meta name="twitter:card" content="summary"; + @if !self.custom_og { + meta property="og:site_name" content=(self.config.server.title); + meta property="og:locale" content="en_US"; + meta property="og:title" content=(self.title); + meta property="og:description" content=(strip_html(self.description)); + // head_extra provides its own og:type and twitter:card + // when present (e.g. video/player for GIF embeds). + // Discord takes the first occurrence, so suppress defaults. + @if self.head_extra.is_none() { + meta property="og:type" content=(self.og_type); + } + @if !self.og_image.is_empty() { + meta property="og:image" content=(self.og_image); + meta property="twitter:image:src" content=(self.og_image); + } + @if self.head_extra.is_none() && !self.rss.is_empty() { + meta name="twitter:card" content="summary"; + } } // Custom head content (OG overrides, oEmbed, ActivityPub)