Skip to content
Clone

feat(embed): add fixed height support for various social media embeds

ZhenYi committed
parent 7a6a0bf commit 59a958b Browse files

21 files changed

+215 −33

This change is very large, so some diffs are not shown. Clone the repository to see the full change.

File tree 21
Modified lib/chat/src/services/messages.rs View lib/chat/src/services/messages.rs
@@ -82,6 +82,9 @@
8282 pub kind: Option<String>,
8383 #[serde(default, skip_serializing_if = "Option::is_none")]
8484 pub aspect: Option<String>,
85 /// 平台声明的固定像素高;前端据它给 iframe 定高(重量级见 `social::render`)。
86 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub height: Option<u32>,
8588 #[serde(default, skip_serializing_if = "Option::is_none")]
8689 pub src: Option<String>,
8790 #[serde(default, skip_serializing_if = "Option::is_none")]
Modified lib/chat/src/services/unfurl.rs View lib/chat/src/services/unfurl.rs
@@ -17,14 +17,14 @@
1717const FAILED_RETRY_MINUTES: i64 = 60;
1818
1919fn url_hash(url: &str) -> String {
20 let mut hasher = Sha256::new();
21 hasher.update(url.as_bytes());
22 hasher
23 .finalize()
24 .iter()
25 .map(|b| format!("{b:02x}"))
26 .collect()
27 }
20 let mut hasher = Sha256::new();
21 hasher.update(url.as_bytes());
22 hasher
23 .finalize()
24 .iter()
25 .map(|b| format!("{b:02x}"))
26 .collect()
27}
2828
2929/// 裁掉句尾标点,但保留括号配对时的右括号。
3030///
@@ -40,8 +40,8 @@
4040 ')' => value[..end].matches('(').count() < value[..end].matches(')').count(),
4141 ']' => value[..end].matches('[').count() < value[..end].matches(']').count(),
4242 '}' => value[..end].matches('{').count() < value[..end].matches('}').count(),
43 '.' | ',' | '>' | '"' | '\'' | ';' | '!' | '?' | '。' | ',' | '、' | ')'
44 | '】' | '》' | '」' | '』' => true,
43 '.' | ',' | '>' | '"' | '\'' | ';' | '!' | '?' | '。' | ',' | '、' | ')' | '】'
44 | '》' | '」' | '』' => true,
4545 _ => false,
4646 };
4747 if !drop {
@@ -748,9 +748,8 @@
748748 /// Wikipedia 这类带括号的路径不能被当成句尾括号裁掉。
749749 #[test]
750750 fn urls_keep_balanced_parentheses() {
751 let urls = extract_urls(
752 "看看 https://en.wikipedia.org/wiki/Rust_(programming_language) 这个",
753 );
751 let urls =
752 extract_urls("看看 https://en.wikipedia.org/wiki/Rust_(programming_language) 这个");
754753 assert_eq!(
755754 urls,
756755 vec!["https://en.wikipedia.org/wiki/Rust_(programming_language)".to_string()]
Modified lib/social/src/parse/facebook.rs View lib/social/src/parse/facebook.rs
@@ -26,7 +26,13 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::frame("https://www.facebook.com/plugins/post.php?href={url_enc}&show_text=true&width=552"))
29 // 插件地址自带 `width=552`,高度随内容变化,给一个能装下常见帖子的值。
30 Some(
31 EmbedRule::frame(
32 "https://www.facebook.com/plugins/post.php?href={url_enc}&show_text=true&width=552",
33 )
34 .fixed_height(520),
35 )
3036 }
3137}
3238
Modified lib/social/src/parse/instagram.rs View lib/social/src/parse/instagram.rs
@@ -26,7 +26,13 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::tall("^/(?:p|reel|tv)/([A-Za-z0-9_-]+)", "https://www.instagram.com/p/{id}/embed"))
29 // 帖子卡片:方形图 + 作者行,固定高比 4:5 更贴近实际。
30 Some(EmbedRule::capture(
31 r"^/(?:p|reel|tv)/([A-Za-z0-9_-]+)",
32 "https://www.instagram.com/p/{id}/embed",
33 crate::Aspect::Auto,
34 )
35 .fixed_height(580))
3036 }
3137}
3238
Modified lib/social/src/parse/mastodon.rs View lib/social/src/parse/mastodon.rs
@@ -26,7 +26,8 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::frame("https://{host}{path}/embed"))
29 // 嘟文卡片:非 16:9,按卡片高度给固定值,避免黑底空框。
30 Some(EmbedRule::frame("https://{host}{path}/embed").fixed_height(420))
3031 }
3132}
3233
Modified lib/social/src/parse/mod.rs View lib/social/src/parse/mod.rs
@@ -57,6 +57,12 @@
5757 /// 以及正则捕获组 `{id}`(等价 `{1}`)、`{2}`…`{9}`(自动 percent-encode)。
5858 pub src: &'static str,
5959 pub aspect: Aspect,
60 /// 平台自己声明的固定像素高。
61 ///
62 /// 音乐播放器、推文卡片这类高宽比不是 16:9 的嵌入,用 `aspect` 会把它们
63 /// 撑成一块空框(Spotify 单曲实际只有 152px 高,网易云外链播放器只有 66px)。
64 /// 有值时优先于 `aspect`。
65 pub height: Option<u32>,
6066}
6167
6268impl EmbedRule {
@@ -66,6 +72,7 @@
6672 pattern: None,
6773 src,
6874 aspect: Aspect::Wide,
75 height: None,
6976 }
7077 }
7178
@@ -75,6 +82,7 @@
7582 pattern: None,
7683 src,
7784 aspect,
85 height: None,
7886 }
7987 }
8088
@@ -84,6 +92,33 @@
8492 pattern: Some(pattern),
8593 src,
8694 aspect,
95 height: None,
96 }
97 }
98
99 /// 指定固定像素高(音乐播放器、社交卡片这类非 16:9 的嵌入)。
100 pub const fn fixed_height(mut self, height: u32) -> Self {
101 self.height = Some(height);
102 self
103 }
104
105 /// 高宽比用 `aspect`,同时钉一个固定像素高。
106 pub const fn frame_fixed(src: &'static str, height: u32) -> Self {
107 Self {
108 pattern: None,
109 src,
110 aspect: Aspect::Auto,
111 height: Some(height),
112 }
113 }
114
115 /// 正则 + 固定像素高。
116 pub const fn capture_fixed(pattern: &'static str, src: &'static str, height: u32) -> Self {
117 Self {
118 pattern: Some(pattern),
119 src,
120 aspect: Aspect::Auto,
121 height: Some(height),
87122 }
88123 }
89124
@@ -306,6 +341,18 @@
306341 src: Option<String>,
307342 aspect: Aspect,
308343) -> Resolved {
344 platform_resolved_sized(platform, url, kind, src, aspect, None)
345}
346
347/// 同 [`platform_resolved`],但带平台声明的固定像素高。
348pub fn platform_resolved_sized<P: Platform + ?Sized>(
349 platform: &P,
350 url: &Url,
351 kind: EmbedKind,
352 src: Option<String>,
353 aspect: Aspect,
354 height: Option<u32>,
355) -> Resolved {
309356 Resolved {
310357 platform: platform.id(),
311358 platform_name: platform.name(),
@@ -314,6 +361,7 @@
314361 url: url.to_string(),
315362 src,
316363 aspect,
364 height,
317365 title: platform.name().to_string(),
318366 description: None,
319367 image_url: None,
@@ -332,20 +380,22 @@
332380 ));
333381 };
334382 match rule.pattern {
335 None => Some(platform_resolved(
383 None => Some(platform_resolved_sized(
336384 platform,
337385 url,
338386 EmbedKind::Iframe,
339387 Some(substitute(rule.src, url, &[])),
340388 rule.aspect,
389 rule.height,
341390 )),
342391 Some(pattern) => match capture_args(pattern, url) {
343 Some(caps) => Some(platform_resolved(
392 Some(caps) => Some(platform_resolved_sized(
344393 platform,
345394 url,
346395 EmbedKind::Iframe,
347396 Some(substitute(rule.src, url, &caps)),
348397 rule.aspect,
398 rule.height,
349399 )),
350400 None => Some(platform_resolved(
351401 platform,
Modified lib/social/src/parse/netease_music.rs View lib/social/src/parse/netease_music.rs
@@ -26,7 +26,13 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide(r"song\?id=(\d+)", "https://music.163.com/outchain/player?type=2&id={id}&auto=0&height=66"))
29 // 外链播放器自身就是 66px 高(URL 里写死的)。
30 Some(EmbedRule::capture(
31 r"song\?id=(\d+)",
32 "https://music.163.com/outchain/player?type=2&id={id}&auto=0&height=66",
33 crate::Aspect::Auto,
34 )
35 .fixed_height(66))
3036 }
3137}
3238
Modified lib/social/src/parse/pinterest.rs View lib/social/src/parse/pinterest.rs
@@ -26,7 +26,12 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide(r"^/pin/(\d+)", "https://assets.pinterest.com/ext/embed.html?id={id}"))
29 Some(EmbedRule::capture(
30 r"^/pin/(\d+)",
31 "https://assets.pinterest.com/ext/embed.html?id={id}",
32 crate::Aspect::Auto,
33 )
34 .fixed_height(420))
3035 }
3136 /// oEmbed 接口,聊天侧联网补全标题 / 简介 / 封面(iframe 仅在平台自有域名下采用)。
3237 fn oembed(&self) -> Option<&'static str> {
Modified lib/social/src/parse/reddit.rs View lib/social/src/parse/reddit.rs
@@ -26,7 +26,11 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::frame("https://www.redditmedia.com{path}?embed=true&depth=1&showmedia=true"))
29 // Reddit oEmbed 声明 height=500
30 Some(
31 EmbedRule::frame("https://www.redditmedia.com{path}?embed=true&depth=1&showmedia=true")
32 .fixed_height(500),
33 )
3034 }
3135 /// oEmbed 接口,聊天侧联网补全标题 / 简介 / 封面(iframe 仅在平台自有域名下采用)。
3236 fn oembed(&self) -> Option<&'static str> {
Modified lib/social/src/parse/reddit_short.rs View lib/social/src/parse/reddit_short.rs
@@ -26,7 +26,14 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide("^/([A-Za-z0-9]+)", "https://www.redditmedia.com/comments/{id}?embed=true"))
29 Some(
30 EmbedRule::capture(
31 r"^/([A-Za-z0-9]+)",
32 "https://www.redditmedia.com/comments/{id}?embed=true",
33 crate::Aspect::Auto,
34 )
35 .fixed_height(500),
36 )
3037 }
3138}
3239
Modified lib/social/src/parse/soundcloud.rs View lib/social/src/parse/soundcloud.rs
@@ -26,7 +26,13 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::frame("https://w.soundcloud.com/player/?url={url_enc}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&visual=false"))
29 // SoundCloud oEmbed 声明 height=400
30 Some(
31 EmbedRule::frame(
32 "https://w.soundcloud.com/player/?url={url_enc}&color=%23ff5500&auto_play=false&hide_related=true&show_comments=false&show_user=true&show_reposts=false&visual=false",
33 )
34 .fixed_height(400),
35 )
3036 }
3137 fn oembed(&self) -> Option<&'static str> {
3238 Some("https://soundcloud.com/oembed?url={url_enc}&format=json")
Modified lib/social/src/parse/spotify.rs View lib/social/src/parse/spotify.rs
@@ -26,7 +26,40 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide("^/(track|album|playlist|artist|episode|show)/([A-Za-z0-9]+)", "https://open.spotify.com/embed/{1}/{2}"))
29 // Spotify 自己的 oEmbed 声明:单曲 456x152、专辑 456x352。用一个宽高比
30 // 套不住两种高度,所以按形态给固定高。
31 Some(
32 EmbedRule::capture(
33 r"^/(track|episode)/([A-Za-z0-9]+)",
34 "https://open.spotify.com/embed/{1}/{2}",
35 crate::Aspect::Auto,
36 )
37 .fixed_height(152),
38 )
39 }
40
41 /// 专辑 / 歌单 / 艺人 / 节目比单曲高一档。
42 fn parse(&self, url: &url::Url) -> Option<crate::Resolved> {
43 use crate::parse::{capture_args, default_parse, platform_resolved_sized};
44 use crate::EmbedKind;
45
46 const TALL: &[&str] = &["album", "playlist", "artist", "show"];
47 if let Some(caps) = capture_args(r"^/(album|playlist|artist|show)/([A-Za-z0-9]+)", url)
48 && TALL.contains(&caps[0].as_str())
49 {
50 return Some(platform_resolved_sized(
51 self,
52 url,
53 EmbedKind::Iframe,
54 Some(format!(
55 "https://open.spotify.com/embed/{}/{}",
56 caps[0], caps[1]
57 )),
58 crate::Aspect::Auto,
59 Some(352),
60 ));
61 }
62 default_parse(self, url)
3063 }
3164 fn oembed(&self) -> Option<&'static str> {
3265 Some("https://open.spotify.com/oembed?url={url_enc}")
Modified lib/social/src/parse/threads.rs View lib/social/src/parse/threads.rs
@@ -26,7 +26,7 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::frame("https://www.threads.net{path}/embed"))
29 Some(EmbedRule::frame("https://www.threads.net{path}/embed").fixed_height(520))
3030 }
3131}
3232
Modified lib/social/src/parse/twitter.rs View lib/social/src/parse/twitter.rs
@@ -26,7 +26,16 @@
2626 }
2727
2828 fn embed_rule(&self) -> Option<EmbedRule> {
29 Some(EmbedRule::wide(r"^/(?:i/web/status|[^/]+/status(?:es)?)/(\d+)", "https://platform.twitter.com/embed/Tweet.html?id={id}"))
29 // 推文卡片不是 16:9:强制宽高比会把白色卡片塞进一块黑底空框。
30 // 卡片本身没有固定高(带图/带引用的推文更高),取一个能容纳常见推文的高度。
31 Some(
32 EmbedRule::capture(
33 r"^/(?:i/web/status|[^/]+/status(?:es)?)/(\d+)",
34 "https://platform.twitter.com/embed/Tweet.html?id={id}",
35 crate::Aspect::Auto,
36 )
37 .fixed_height(520),
38 )
3039 }
3140}
3241
Modified lib/social/src/lib.rs View lib/social/src/lib.rs
@@ -139,6 +139,8 @@
139139 /// iframe / 媒体源;卡片为 `None`。
140140 pub src: Option<String>,
141141 pub aspect: Aspect,
142 /// 平台声明的固定像素高;`Some` 时比 `aspect` 优先。
143 pub height: Option<u32>,
142144 /// 展示标题(平台名,OG / oEmbed 抓取成功时由 chat 侧覆盖)。
143145 pub title: String,
144146 /// 简介(联网抓取成功时由 chat 侧填入)。
@@ -182,6 +184,7 @@
182184 "description": self.resolved.description,
183185 "image_url": self.resolved.image_url,
184186 "aspect": self.resolved.aspect.as_str(),
187 "height": self.resolved.height,
185188 "html": self.html,
186189 })
187190 }
Modified lib/social/src/render.rs View lib/social/src/render.rs
@@ -37,13 +37,33 @@
3737 )
3838}
3939
40fn frame_style(aspect: Aspect) -> String {
41 format!(
42 "width:100%;max-width:640px;aspect-ratio:{};border:0;border-radius:12px;background:#000;display:block",
43 aspect.css(),
44 )
40/// iframe 的尺寸。
41///
42/// 平台声明了固定像素高时用它(Spotify 单曲 152px、网易云外链 66px、
43/// Reddit 500px…),否则才按宽高比撑开。`background:#000` 只给视频类——
44/// 推文、音乐、地图这些是浅色卡片,套黑底会在加载前露出一块黑矩形。
45fn frame_style(resolved: &Resolved) -> String {
46 let base = "width:100%;max-width:640px;border:0;border-radius:12px;display:block";
47 let backdrop = if is_dark_player(resolved) {
48 ";background:#000"
49 } else {
50 ""
51 };
52 match resolved.height {
53 Some(height) => format!("{base};height:{height}px{backdrop}"),
54 None => format!("{base};aspect-ratio:{}{backdrop}", resolved.aspect.css()),
55 }
4556}
4657
58/// 视频类播放器才用黑底:其余嵌入是浅色卡片,不该有黑框。
59fn is_dark_player(resolved: &Resolved) -> bool {
60 matches!(resolved.kind, EmbedKind::Video | EmbedKind::Audio)
61 || matches!(
62 resolved.category,
63 crate::Category::Video | crate::Category::Live
64 )
65}
66
4767const MEDIA_STYLE: &str =
4868 "width:100%;max-width:640px;border-radius:12px;background:#000;display:block";
4969const IMAGE_STYLE: &str =
@@ -64,9 +84,9 @@
6484 referrerpolicy=\"strict-origin-when-cross-origin\" allowfullscreen \
6585 allow=\"accelerometer;autoplay;clipboard-write;encrypted-media;gyroscope;picture-in-picture;web-share;fullscreen\" \
6686 sandbox=\"allow-scripts allow-same-origin allow-popups allow-popups-to-escape-sandbox allow-forms allow-presentation\" \
67 style=\"{style}\"{attrs}></iframe>",
87 style=\"{style}\"{attrs}></iframe>",
6888 src = escape_html(src),
69 style = frame_style(resolved.aspect),
89 style = frame_style(resolved),
7090 )
7191 }
7292 EmbedKind::Video => {
@@ -145,4 +165,25 @@
145165 fn escapes_attribute_breakers() {
146166 assert_eq!(escape_html(r#"a"b<c>&'"#), "a&quot;b&lt;c&gt;&amp;&#39;");
147167 }
168
169 /// 卡片类嵌入不该带黑底,视频类才带;声明了固定高度就用它。
170 #[test]
171 fn frame_style_matches_the_embed_shape() {
172 let tweet = crate::parse("https://x.com/u/status/1234567890").expect("tweet resolves");
173 let html = render_html(&tweet);
174 assert!(html.contains("height:520px"), "{html}");
175 assert!(!html.contains("background:#000"), "卡片不该有黑底: {html}");
176
177 let video = crate::parse("https://www.youtube.com/watch?v=dQw4w9WgXcQ").expect("video");
178 let html = render_html(&video);
179 assert!(html.contains("aspect-ratio:16 / 9"), "{html}");
180 assert!(
181 html.contains("background:#000"),
182 "视频播放器保留黑底: {html}"
183 );
184
185 let song = crate::parse("https://music.163.com/#/song?id=123").expect("song");
186 let html = render_html(&song);
187 assert!(html.contains("height:66px"), "{html}");
188 }
148189}
Modified lib/social/src/resolve.rs View lib/social/src/resolve.rs
@@ -59,6 +59,7 @@
5959 url: url.to_string(),
6060 src: Some(url.to_string()),
6161 aspect,
62 height: None,
6263 title: name.to_string(),
6364 description: None,
6465 image_url: None,
Modified web/src/features/chat/ui/parts.tsx View web/src/features/chat/ui/parts.tsx
@@ -381,6 +381,8 @@
381381 site_name?: string | null
382382 image_url?: string | null
383383 aspect?: string | null
384 /** 平台声明的固定像素高;服务端已把它写进 `html` 的 style,这里只作类型对齐。 */
385 height?: number | null
384386 src?: string | null
385387 html?: string | null
386388}
Modified web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts

Diff too large to display.

Modified web/openapi.json View web/openapi.json

Diff too large to display.

Modified openapi.json View openapi.json

Diff too large to display.