mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
feat(desktop): enrich link previews with page metadata
Co-authored-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz> Signed-off-by: npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f <ab176f059d100602ea25073d9a69ee9817f7b691c76a897180d48498d959faa2@buzz.block.builderlab.xyz>
This commit is contained in:
co-authored by
npub14vtk7pvazqrq9639qu7e560wnqtl0d53ca4gjuvq6jzf3k2el23qqlwa7f
parent
06582ee6f0
commit
8232306b32
@@ -1,90 +1,157 @@
|
||||
use std::time::Duration;
|
||||
use std::{net::IpAddr, time::Duration};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::{
|
||||
header::{ACCEPT, CONTENT_TYPE, USER_AGENT},
|
||||
header::{ACCEPT, CONTENT_LENGTH, CONTENT_TYPE, LOCATION, USER_AGENT},
|
||||
redirect::Policy,
|
||||
};
|
||||
use serde::Serialize;
|
||||
use url::Url;
|
||||
|
||||
const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024;
|
||||
const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
const MAX_PREVIEW_FETCH_BYTES: usize = 256 * 1024;
|
||||
const PREVIEW_FETCH_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
const PREVIEW_TOTAL_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
const MAX_REDIRECTS: usize = 3;
|
||||
const MAX_METADATA_CHARS: usize = 180;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
pub struct LinkPreviewMetadata {
|
||||
title: String,
|
||||
site_name: Option<String>,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_link_preview_title(href: String) -> Result<Option<String>, String> {
|
||||
let url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?;
|
||||
if !is_supported_google_link(&url) {
|
||||
return Ok(None);
|
||||
pub async fn fetch_link_preview_metadata(
|
||||
href: String,
|
||||
) -> Result<Option<LinkPreviewMetadata>, String> {
|
||||
tokio::time::timeout(
|
||||
PREVIEW_TOTAL_TIMEOUT,
|
||||
fetch_link_preview_metadata_inner(href),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "link preview request timed out".to_string())?
|
||||
}
|
||||
|
||||
async fn fetch_link_preview_metadata_inner(
|
||||
href: String,
|
||||
) -> Result<Option<LinkPreviewMetadata>, String> {
|
||||
let mut url = Url::parse(href.trim()).map_err(|error| format!("invalid URL: {error}"))?;
|
||||
validate_public_https_url(&url).await?;
|
||||
|
||||
for redirect_count in 0..=MAX_REDIRECTS {
|
||||
let response = send_pinned_request(&url).await?;
|
||||
|
||||
if response.status().is_redirection() {
|
||||
if redirect_count == MAX_REDIRECTS {
|
||||
return Ok(None);
|
||||
}
|
||||
let Some(location) = response.headers().get(LOCATION) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let location = location
|
||||
.to_str()
|
||||
.map_err(|_| "link preview redirect has an invalid location".to_string())?;
|
||||
url = url
|
||||
.join(location)
|
||||
.map_err(|error| format!("invalid link preview redirect: {error}"))?;
|
||||
validate_public_https_url(&url).await?;
|
||||
continue;
|
||||
}
|
||||
|
||||
if !response.status().is_success() || !is_html_response(&response) {
|
||||
return Ok(None);
|
||||
}
|
||||
if response
|
||||
.content_length()
|
||||
.is_some_and(|size| size > MAX_PREVIEW_FETCH_BYTES as u64)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = read_limited_text(response).await?;
|
||||
return Ok(extract_link_preview_metadata(&body));
|
||||
}
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.redirect(Policy::none())
|
||||
.pool_idle_timeout(Duration::from_secs(10))
|
||||
.pool_max_idle_per_host(1)
|
||||
.build()
|
||||
.map_err(|error| format!("link preview title client failed: {error}"))?;
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn validate_public_https_url(url: &Url) -> Result<(), String> {
|
||||
if url.scheme() != "https" || url.username() != "" || url.password().is_some() {
|
||||
return Err("link previews require an HTTPS URL without credentials".to_string());
|
||||
}
|
||||
if url.port().is_some_and(|port| port != 443) {
|
||||
return Err("link previews require the default HTTPS port".to_string());
|
||||
}
|
||||
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| "link preview URL has no host".to_string())?;
|
||||
resolve_public_addresses(host).await.map(|_| ())
|
||||
}
|
||||
|
||||
async fn resolve_public_addresses(host: &str) -> Result<Vec<IpAddr>, String> {
|
||||
let host = host.to_string();
|
||||
let addresses = tokio::net::lookup_host((host.as_str(), 443))
|
||||
.await
|
||||
.map_err(|error| format!("link preview DNS resolution failed: {error}"))?
|
||||
.map(|address| address.ip())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
if addresses.is_empty() {
|
||||
return Err("link preview DNS resolution returned no addresses".to_string());
|
||||
}
|
||||
if addresses.iter().any(buzz_core_pkg::network::is_private_ip) {
|
||||
return Err("link preview host resolved to a private or reserved address".to_string());
|
||||
}
|
||||
|
||||
Ok(addresses)
|
||||
}
|
||||
|
||||
async fn send_pinned_request(url: &Url) -> Result<reqwest::Response, String> {
|
||||
let host = url
|
||||
.host_str()
|
||||
.ok_or_else(|| "link preview URL has no host".to_string())?;
|
||||
let addresses = resolve_public_addresses(host).await?;
|
||||
let socket_addresses = addresses
|
||||
.into_iter()
|
||||
.map(|address| std::net::SocketAddr::new(address, 443))
|
||||
.collect::<Vec<_>>();
|
||||
let client = reqwest::Client::builder()
|
||||
.no_proxy()
|
||||
.redirect(Policy::none())
|
||||
.pool_max_idle_per_host(0)
|
||||
.resolve_to_addrs(host, &socket_addresses)
|
||||
.build()
|
||||
.map_err(|error| format!("link preview client failed: {error}"))?;
|
||||
let request = client
|
||||
.get(url.as_str())
|
||||
.header(
|
||||
ACCEPT,
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
)
|
||||
.header(ACCEPT, "text/html,application/xhtml+xml;q=0.9")
|
||||
.header(USER_AGENT, "Buzz Desktop link preview");
|
||||
|
||||
let response = tokio::time::timeout(TITLE_FETCH_TIMEOUT, request.send())
|
||||
tokio::time::timeout(PREVIEW_FETCH_TIMEOUT, request.send())
|
||||
.await
|
||||
.map_err(|_| "link preview title request timed out".to_string())?
|
||||
.map_err(|error| format!("link preview title request failed: {error}"))?;
|
||||
.map_err(|_| "link preview request timed out".to_string())?
|
||||
.map_err(|error| format!("link preview request failed: {error}"))
|
||||
}
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let is_html = response
|
||||
fn is_html_response(response: &reqwest::Response) -> bool {
|
||||
response
|
||||
.headers()
|
||||
.get(CONTENT_TYPE)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| value.to_ascii_lowercase().contains("text/html"))
|
||||
.unwrap_or(true);
|
||||
if !is_html {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let body = read_limited_text(response).await?;
|
||||
Ok(extract_google_title(&body))
|
||||
}
|
||||
|
||||
fn is_supported_google_link(url: &Url) -> bool {
|
||||
if url.scheme() != "https" {
|
||||
return false;
|
||||
}
|
||||
|
||||
let Some(host) = url.host_str().map(|host| host.to_ascii_lowercase()) else {
|
||||
return false;
|
||||
};
|
||||
let segments = url
|
||||
.path_segments()
|
||||
.map(|segments| segments.collect::<Vec<_>>())
|
||||
.unwrap_or_default();
|
||||
|
||||
match host.trim_start_matches("www.") {
|
||||
"docs.google.com" => {
|
||||
matches!(
|
||||
segments.as_slice(),
|
||||
["document", "d", _, ..]
|
||||
| ["spreadsheets", "d", _, ..]
|
||||
| ["presentation", "d", _, ..]
|
||||
)
|
||||
}
|
||||
"drive.google.com" => {
|
||||
matches!(segments.as_slice(), ["file", "d", _, ..])
|
||||
|| matches!(segments.as_slice(), ["drive", "folders", _, ..])
|
||||
|| (segments.first() == Some(&"open")
|
||||
&& url.query_pairs().any(|(key, _)| key == "id"))
|
||||
}
|
||||
_ => false,
|
||||
}
|
||||
.map(|value| {
|
||||
let mime = value.split(';').next().unwrap_or_default().trim();
|
||||
mime.eq_ignore_ascii_case("text/html")
|
||||
|| mime.eq_ignore_ascii_case("application/xhtml+xml")
|
||||
})
|
||||
.unwrap_or(false)
|
||||
&& response
|
||||
.headers()
|
||||
.get(CONTENT_LENGTH)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.and_then(|value| value.parse::<usize>().ok())
|
||||
.is_none_or(|size| size <= MAX_PREVIEW_FETCH_BYTES)
|
||||
}
|
||||
|
||||
async fn read_limited_text(response: reqwest::Response) -> Result<String, String> {
|
||||
@@ -92,11 +159,9 @@ async fn read_limited_text(response: reqwest::Response) -> Result<String, String
|
||||
let mut bytes = Vec::new();
|
||||
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = chunk.map_err(|error| format!("reading title response failed: {error}"))?;
|
||||
if bytes.len() + chunk.len() > MAX_TITLE_FETCH_BYTES {
|
||||
let remaining = MAX_TITLE_FETCH_BYTES.saturating_sub(bytes.len());
|
||||
bytes.extend_from_slice(&chunk[..remaining]);
|
||||
break;
|
||||
let chunk = chunk.map_err(|error| format!("reading link preview failed: {error}"))?;
|
||||
if bytes.len() + chunk.len() > MAX_PREVIEW_FETCH_BYTES {
|
||||
return Err("link preview response exceeded the size limit".to_string());
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
@@ -104,13 +169,18 @@ async fn read_limited_text(response: reqwest::Response) -> Result<String, String
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn extract_google_title(html: &str) -> Option<String> {
|
||||
extract_meta_title(html)
|
||||
fn extract_link_preview_metadata(html: &str) -> Option<LinkPreviewMetadata> {
|
||||
let title = extract_meta_content(html, "property", "og:title")
|
||||
.or_else(|| extract_meta_content(html, "name", "twitter:title"))
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.and_then(|title| normalize_google_title(&title))
|
||||
.and_then(|value| normalize_metadata_text(&value))?;
|
||||
let site_name = extract_meta_content(html, "property", "og:site_name")
|
||||
.and_then(|value| normalize_metadata_text(&value));
|
||||
|
||||
Some(LinkPreviewMetadata { title, site_name })
|
||||
}
|
||||
|
||||
fn extract_meta_title(html: &str) -> Option<String> {
|
||||
fn extract_meta_content(html: &str, key_attr: &str, key_value: &str) -> Option<String> {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
|
||||
@@ -121,14 +191,11 @@ fn extract_meta_title(html: &str) -> Option<String> {
|
||||
};
|
||||
let end = start + relative_end + 1;
|
||||
let tag = &html[start..end];
|
||||
let lower_tag = &lower[start..end];
|
||||
|
||||
if lower_tag.contains("og:title") || lower_tag.contains("twitter:title") {
|
||||
if attr_value(tag, key_attr).is_some_and(|value| value.eq_ignore_ascii_case(key_value)) {
|
||||
if let Some(content) = attr_value(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
|
||||
search_from = end;
|
||||
}
|
||||
|
||||
@@ -140,7 +207,7 @@ fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let start = lower.find("<title")?;
|
||||
let content_start = start + lower[start..].find('>')? + 1;
|
||||
let content_end = content_start + lower[content_start..].find("</title>")?;
|
||||
Some(html[content_start..content_end].to_string())
|
||||
Some(decode_html_entities(&html[content_start..content_end]))
|
||||
}
|
||||
|
||||
fn attr_value(tag: &str, attr: &str) -> Option<String> {
|
||||
@@ -157,62 +224,49 @@ fn attr_value(tag: &str, attr: &str) -> Option<String> {
|
||||
&& !matches!(after, Some(c) if c.is_ascii_alphanumeric() || c == '-' || c == '_');
|
||||
|
||||
if has_name_boundary {
|
||||
let lower_rest = &lower[name_end..];
|
||||
let equals_offset = lower_rest.find('=')?;
|
||||
let value_start = name_end + equals_offset + 1;
|
||||
let value = tag[value_start..].trim_start();
|
||||
let rest = &tag[name_end..];
|
||||
let equals_offset = rest.find('=')?;
|
||||
let value = rest[equals_offset + 1..].trim_start();
|
||||
let quote = value.chars().next()?;
|
||||
|
||||
if quote == '"' || quote == '\'' {
|
||||
let value_body = &value[quote.len_utf8()..];
|
||||
let value_end = value_body.find(quote)?;
|
||||
return Some(decode_html_entities(&value_body[..value_end]));
|
||||
}
|
||||
|
||||
let value_end = value
|
||||
.find(|c: char| c.is_ascii_whitespace() || c == '>')
|
||||
.unwrap_or(value.len());
|
||||
return Some(decode_html_entities(&value[..value_end]));
|
||||
}
|
||||
|
||||
search_from = name_end;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn normalize_google_title(raw_title: &str) -> Option<String> {
|
||||
let mut title = decode_html_entities(raw_title)
|
||||
fn normalize_metadata_text(raw: &str) -> Option<String> {
|
||||
let mut normalized = decode_html_entities(raw)
|
||||
.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
for suffix in [
|
||||
" - Google Docs",
|
||||
" - Google Sheets",
|
||||
" - Google Slides",
|
||||
" - Google Drive",
|
||||
] {
|
||||
if let Some(stripped) = title.strip_suffix(suffix) {
|
||||
title = stripped.trim().to_string();
|
||||
if let Some(stripped) = normalized.strip_suffix(suffix) {
|
||||
normalized = stripped.trim().to_string();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
match title.as_str() {
|
||||
""
|
||||
| "Document"
|
||||
| "Spreadsheet"
|
||||
| "Presentation"
|
||||
| "Drive file"
|
||||
| "Drive folder"
|
||||
| "Google Docs"
|
||||
| "Google Sheets"
|
||||
| "Google Slides"
|
||||
| "Google Drive"
|
||||
| "Sign in - Google Accounts" => None,
|
||||
_ => Some(title.chars().take(180).collect()),
|
||||
if matches!(
|
||||
normalized.as_str(),
|
||||
"" | "Sign in - Google Accounts" | "Google Docs" | "Google Sheets" | "Google Slides"
|
||||
) {
|
||||
return None;
|
||||
}
|
||||
Some(normalized.chars().take(MAX_METADATA_CHARS).collect())
|
||||
}
|
||||
|
||||
fn decode_html_entities(value: &str) -> String {
|
||||
@@ -231,71 +285,54 @@ fn decode_html_entities(value: &str) -> String {
|
||||
};
|
||||
let end = start + relative_end + 1;
|
||||
let entity = &decoded[start + 2..end - 1];
|
||||
let parsed = if let Some(hex) = entity
|
||||
let parsed = entity
|
||||
.strip_prefix('x')
|
||||
.or_else(|| entity.strip_prefix('X'))
|
||||
{
|
||||
u32::from_str_radix(hex, 16).ok()
|
||||
} else {
|
||||
entity.parse::<u32>().ok()
|
||||
};
|
||||
|
||||
.and_then(|hex| u32::from_str_radix(hex, 16).ok())
|
||||
.or_else(|| entity.parse::<u32>().ok());
|
||||
let Some(ch) = parsed.and_then(char::from_u32) else {
|
||||
break;
|
||||
};
|
||||
decoded.replace_range(start..end, &ch.to_string());
|
||||
}
|
||||
|
||||
decoded
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{extract_google_title, is_supported_google_link};
|
||||
use url::Url;
|
||||
use super::{extract_link_preview_metadata, LinkPreviewMetadata};
|
||||
|
||||
#[test]
|
||||
fn title_prefers_open_graph_title() {
|
||||
let html = r#"
|
||||
<html>
|
||||
<head>
|
||||
<meta property="og:title" content="Composer links & previews - Google Docs">
|
||||
<title>Fallback - Google Docs</title>
|
||||
</head>
|
||||
</html>
|
||||
"#;
|
||||
|
||||
fn metadata_prefers_open_graph_and_reads_site_name() {
|
||||
let html = r#"<meta content="Buzz" property="og:site_name">
|
||||
<meta content="Rich previews & cards" property="og:title">
|
||||
<meta name="twitter:title" content="Twitter fallback"><title>Fallback</title>"#;
|
||||
assert_eq!(
|
||||
extract_google_title(html).as_deref(),
|
||||
Some("Composer links & previews")
|
||||
extract_link_preview_metadata(html),
|
||||
Some(LinkPreviewMetadata {
|
||||
title: "Rich previews & cards".to_string(),
|
||||
site_name: Some("Buzz".to_string()),
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_ignores_generic_google_titles() {
|
||||
fn metadata_falls_back_to_twitter_then_title() {
|
||||
assert_eq!(
|
||||
extract_google_title("<title>Sign in - Google Accounts</title>"),
|
||||
None
|
||||
extract_link_preview_metadata("<meta content='Tweet title' name='twitter:title'>")
|
||||
.map(|metadata| metadata.title),
|
||||
Some("Tweet title".to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
extract_link_preview_metadata("<title> Plain title </title>")
|
||||
.map(|metadata| metadata.title),
|
||||
Some("Plain title".to_string())
|
||||
);
|
||||
assert_eq!(extract_google_title("<title>Google Docs</title>"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn supported_urls_are_google_file_links_only() {
|
||||
assert!(is_supported_google_link(
|
||||
&Url::parse("https://docs.google.com/document/d/abc/edit").unwrap()
|
||||
));
|
||||
assert!(is_supported_google_link(
|
||||
&Url::parse("https://docs.google.com/spreadsheets/d/abc/edit").unwrap()
|
||||
));
|
||||
assert!(is_supported_google_link(
|
||||
&Url::parse("https://drive.google.com/file/d/abc/view").unwrap()
|
||||
));
|
||||
assert!(!is_supported_google_link(
|
||||
&Url::parse("https://example.com/document/d/abc/edit").unwrap()
|
||||
));
|
||||
assert!(!is_supported_google_link(
|
||||
&Url::parse("http://docs.google.com/document/d/abc/edit").unwrap()
|
||||
));
|
||||
fn metadata_requires_a_non_empty_title() {
|
||||
assert_eq!(extract_link_preview_metadata("<title> </title>"), None);
|
||||
assert_eq!(extract_link_preview_metadata("<html></html>"), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -705,7 +705,7 @@ pub fn run() {
|
||||
get_relay_ws_url,
|
||||
get_relay_http_url,
|
||||
get_media_proxy_port,
|
||||
fetch_link_preview_title,
|
||||
fetch_link_preview_metadata,
|
||||
discover_acp_auth_methods,
|
||||
discover_acp_providers,
|
||||
discover_git_bash_prerequisite,
|
||||
|
||||
@@ -13,6 +13,7 @@ import { getIdentity } from "@/shared/api/tauriIdentity";
|
||||
import { clearTrayAgentActivity } from "@/shared/api/trayMenu";
|
||||
import { getOverrides } from "@/shared/features";
|
||||
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
|
||||
import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
|
||||
import {
|
||||
clearAllDrafts,
|
||||
@@ -65,6 +66,7 @@ function resetCommunityState({
|
||||
}
|
||||
resetSidebarRelayConnectionCardState();
|
||||
resetMediaCaches();
|
||||
resetLinkPreviewMetadataCache();
|
||||
resetVideoPlayerState();
|
||||
resetRenderScopedReactionHydration();
|
||||
clearSearchHitEventCache();
|
||||
|
||||
@@ -198,7 +198,7 @@ test("extractSupportedLinkPreviews skips markdown image link URLs", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
|
||||
test("extractSupportedLinkPreviews treats other absolute HTTPS URLs as generic", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
@@ -207,7 +207,7 @@ test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
|
||||
"(https://github.com/block/sprout/pull/2)",
|
||||
].join(" "),
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #2"],
|
||||
["evil-github.com", "example.com", "block/sprout #2"],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -252,3 +252,39 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
|
||||
);
|
||||
assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview parses generic HTTPS URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview("https://example.com/articles/rich-previews"),
|
||||
{
|
||||
kind: "generic-link",
|
||||
href: "https://example.com/articles/rich-previews",
|
||||
provider: "example.com",
|
||||
title: "example.com",
|
||||
typeLabel: "link",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview rejects generic HTTP URLs", () => {
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview("http://example.com/articles/rich-previews"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews finds generic links and preserves exclusions", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"Read https://example.com/article first.",
|
||||
"`https://hidden.example.com/secret`",
|
||||
"then [the details](https://docs.example.org/details)",
|
||||
].join(" "),
|
||||
).map(({ kind, title }) => ({ kind, title })),
|
||||
[
|
||||
{ kind: "generic-link", title: "example.com" },
|
||||
{ kind: "generic-link", title: "the details" },
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,18 +7,13 @@ export type SupportedLinkPreviewKind =
|
||||
| "google-drive-folder"
|
||||
| "google-docs-document"
|
||||
| "google-sheets-spreadsheet"
|
||||
| "google-slides-presentation";
|
||||
| "google-slides-presentation"
|
||||
| "generic-link";
|
||||
|
||||
export type SupportedLinkPreview = {
|
||||
kind: SupportedLinkPreviewKind;
|
||||
href: string;
|
||||
provider:
|
||||
| "GitHub"
|
||||
| "Linear"
|
||||
| "Google Drive"
|
||||
| "Google Docs"
|
||||
| "Google Sheets"
|
||||
| "Google Slides";
|
||||
provider: string;
|
||||
title: string;
|
||||
typeLabel:
|
||||
| "PR"
|
||||
@@ -28,13 +23,14 @@ export type SupportedLinkPreview = {
|
||||
| "folder"
|
||||
| "document"
|
||||
| "spreadsheet"
|
||||
| "presentation";
|
||||
| "presentation"
|
||||
| "link";
|
||||
};
|
||||
|
||||
const SUPPORTED_URL_RE =
|
||||
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
|
||||
/(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
|
||||
const MARKDOWN_SUPPORTED_LINK_RE =
|
||||
/!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
|
||||
/!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
|
||||
const MAX_PREVIEWS = 8;
|
||||
|
||||
type HiddenRange = {
|
||||
@@ -431,12 +427,27 @@ export function parseSupportedLinkPreview(
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
const recognized =
|
||||
parseGithubLink(parsed) ??
|
||||
parseLinearIssue(parsed) ??
|
||||
parseGoogleDriveLink(parsed) ??
|
||||
parseGoogleDocsLink(parsed)
|
||||
);
|
||||
parseGoogleDocsLink(parsed);
|
||||
if (recognized) return recognized;
|
||||
const hostname = normalizeHostname(parsed);
|
||||
if (
|
||||
parsed.protocol !== "https:" ||
|
||||
[
|
||||
"github.com",
|
||||
"linear.app",
|
||||
"drive.google.com",
|
||||
"docs.google.com",
|
||||
].includes(hostname)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const provider = hostname;
|
||||
return createPreview("generic-link", parsed, provider, "link", provider);
|
||||
}
|
||||
|
||||
export function isSupportedLinkAutolinkLabel(
|
||||
|
||||
@@ -4,61 +4,64 @@ import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
import type { SupportedLinkPreview } from "./linkPreview";
|
||||
|
||||
const GOOGLE_FALLBACK_TITLES = new Set([
|
||||
"Drive file",
|
||||
"Drive folder",
|
||||
"Document",
|
||||
"Spreadsheet",
|
||||
"Presentation",
|
||||
]);
|
||||
type LinkPreviewMetadata = {
|
||||
title: string;
|
||||
siteName: string | null;
|
||||
};
|
||||
|
||||
const titleCache = new Map<string, Promise<string | null> | string | null>();
|
||||
const metadataCache = new Map<
|
||||
string,
|
||||
Promise<LinkPreviewMetadata | null> | LinkPreviewMetadata | null
|
||||
>();
|
||||
|
||||
function fetchLinkPreviewTitle(href: string): Promise<string | null> {
|
||||
return invokeTauri<string | null>("fetch_link_preview_title", { href });
|
||||
/** Clear ephemeral metadata when the active relay/community changes. */
|
||||
export function resetLinkPreviewMetadataCache(): void {
|
||||
metadataCache.clear();
|
||||
}
|
||||
|
||||
function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
|
||||
return (
|
||||
preview.kind.startsWith("google-") &&
|
||||
GOOGLE_FALLBACK_TITLES.has(preview.title)
|
||||
function fetchLinkPreviewMetadata(
|
||||
href: string,
|
||||
): Promise<LinkPreviewMetadata | null> {
|
||||
return invokeTauri<LinkPreviewMetadata | null>(
|
||||
"fetch_link_preview_metadata",
|
||||
{
|
||||
href,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function cacheTitle(href: string): Promise<string | null> {
|
||||
const cached = titleCache.get(href);
|
||||
function cacheMetadata(href: string): Promise<LinkPreviewMetadata | null> {
|
||||
const cached = metadataCache.get(href);
|
||||
if (cached instanceof Promise) return cached;
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const promise = fetchLinkPreviewTitle(href)
|
||||
.then((title) => {
|
||||
titleCache.set(href, title);
|
||||
return title;
|
||||
const promise = fetchLinkPreviewMetadata(href)
|
||||
.then((metadata) => {
|
||||
metadataCache.set(href, metadata);
|
||||
return metadata;
|
||||
})
|
||||
.catch(() => {
|
||||
titleCache.set(href, null);
|
||||
metadataCache.set(href, null);
|
||||
return null;
|
||||
});
|
||||
titleCache.set(href, promise);
|
||||
metadataCache.set(href, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function useResolvedLinkPreviews(
|
||||
previews: SupportedLinkPreview[],
|
||||
): SupportedLinkPreview[] {
|
||||
const [resolvedTitles, setResolvedTitles] = React.useState<
|
||||
Record<string, string>
|
||||
const [resolvedMetadata, setResolvedMetadata] = React.useState<
|
||||
Record<string, LinkPreviewMetadata>
|
||||
>({});
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false;
|
||||
const pending = previews.filter(shouldResolveTitle);
|
||||
if (pending.length === 0) return undefined;
|
||||
|
||||
for (const preview of pending) {
|
||||
const cached = titleCache.get(preview.href);
|
||||
if (typeof cached === "string" && cached) {
|
||||
setResolvedTitles((current) =>
|
||||
for (const preview of previews) {
|
||||
const cached = metadataCache.get(preview.href);
|
||||
if (cached && !(cached instanceof Promise)) {
|
||||
setResolvedMetadata((current) =>
|
||||
current[preview.href] === cached
|
||||
? current
|
||||
: { ...current, [preview.href]: cached },
|
||||
@@ -66,12 +69,12 @@ export function useResolvedLinkPreviews(
|
||||
continue;
|
||||
}
|
||||
|
||||
void cacheTitle(preview.href).then((title) => {
|
||||
if (cancelled || !title) return;
|
||||
setResolvedTitles((current) =>
|
||||
current[preview.href] === title
|
||||
void cacheMetadata(preview.href).then((metadata) => {
|
||||
if (cancelled || !metadata) return;
|
||||
setResolvedMetadata((current) =>
|
||||
current[preview.href] === metadata
|
||||
? current
|
||||
: { ...current, [preview.href]: title },
|
||||
: { ...current, [preview.href]: metadata },
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -84,9 +87,17 @@ export function useResolvedLinkPreviews(
|
||||
return React.useMemo(
|
||||
() =>
|
||||
previews.map((preview) => {
|
||||
const title = resolvedTitles[preview.href];
|
||||
return title ? { ...preview, title } : preview;
|
||||
const metadata = resolvedMetadata[preview.href];
|
||||
if (!metadata) return preview;
|
||||
return {
|
||||
...preview,
|
||||
title: metadata.title,
|
||||
provider:
|
||||
preview.kind === "generic-link" && metadata.siteName
|
||||
? metadata.siteName
|
||||
: preview.provider,
|
||||
};
|
||||
}),
|
||||
[previews, resolvedTitles],
|
||||
[previews, resolvedMetadata],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -106,6 +106,8 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
|
||||
return <GoogleSheetsLogo className="h-4 w-4" />;
|
||||
case "google-slides-presentation":
|
||||
return <GoogleSlidesLogo className="h-4 w-4" />;
|
||||
case "generic-link":
|
||||
return <ExternalLink aria-hidden="true" className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user