feat(embed): add fixed height support for various social media embeds
ZhenYi committed
This change is very large, so some diffs are not shown. Clone the repository to see the full change.
Modified lib/chat/src/services/messages.rs lib/chat/src/services/messages.rs +3 −0 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 lib/chat/src/services/unfurl.rs +12 −13 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 lib/social/src/parse/facebook.rs +7 −1 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 lib/social/src/parse/instagram.rs +7 −1 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 lib/social/src/parse/mastodon.rs +2 −1 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 lib/social/src/parse/mod.rs +52 −2 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 lib/social/src/parse/netease_music.rs +7 −1 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 lib/social/src/parse/pinterest.rs +6 −1 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 lib/social/src/parse/reddit.rs +5 −1 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 lib/social/src/parse/reddit_short.rs +8 −1 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 lib/social/src/parse/soundcloud.rs +7 −1 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 lib/social/src/parse/spotify.rs +34 −1 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 lib/social/src/parse/threads.rs +1 −1 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 lib/social/src/parse/twitter.rs +10 −1 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 lib/social/src/lib.rs +3 −0 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 lib/social/src/render.rs +48 −7 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"b<c>&'");
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 lib/social/src/resolve.rs +1 −0 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 web/src/features/chat/ui/parts.tsx +2 −0 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 web/src/shared/api/globals.d.ts View web/src/shared/api/globals.d.ts
Diff too large to display.
Modified web/openapi.json web/openapi.json View web/openapi.json
Diff too large to display.
Modified openapi.json openapi.json View openapi.json
Diff too large to display.