mirror of
https://github.com/block/buzz.git
synced 2026-11-09 02:41:57 +00:00
Add rich link previews (#1334)
This commit is contained in:
301
desktop/src-tauri/src/commands/link_preview.rs
Normal file
301
desktop/src-tauri/src/commands/link_preview.rs
Normal file
@@ -0,0 +1,301 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use reqwest::{
|
||||
header::{ACCEPT, CONTENT_TYPE, USER_AGENT},
|
||||
redirect::Policy,
|
||||
};
|
||||
use url::Url;
|
||||
|
||||
const MAX_TITLE_FETCH_BYTES: usize = 256 * 1024;
|
||||
const TITLE_FETCH_TIMEOUT: Duration = Duration::from_secs(4);
|
||||
|
||||
#[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);
|
||||
}
|
||||
|
||||
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}"))?;
|
||||
|
||||
let request = client
|
||||
.get(url.as_str())
|
||||
.header(
|
||||
ACCEPT,
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
|
||||
)
|
||||
.header(USER_AGENT, "Buzz Desktop link preview");
|
||||
|
||||
let response = tokio::time::timeout(TITLE_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}"))?;
|
||||
|
||||
if !response.status().is_success() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let is_html = 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,
|
||||
}
|
||||
}
|
||||
|
||||
async fn read_limited_text(response: reqwest::Response) -> Result<String, String> {
|
||||
let mut stream = response.bytes_stream();
|
||||
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;
|
||||
}
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
|
||||
Ok(String::from_utf8_lossy(&bytes).into_owned())
|
||||
}
|
||||
|
||||
fn extract_google_title(html: &str) -> Option<String> {
|
||||
extract_meta_title(html)
|
||||
.or_else(|| extract_title_tag(html))
|
||||
.and_then(|title| normalize_google_title(&title))
|
||||
}
|
||||
|
||||
fn extract_meta_title(html: &str) -> Option<String> {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(relative_start) = lower[search_from..].find("<meta") {
|
||||
let start = search_from + relative_start;
|
||||
let Some(relative_end) = lower[start..].find('>') else {
|
||||
break;
|
||||
};
|
||||
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 let Some(content) = attr_value(tag, "content") {
|
||||
return Some(content);
|
||||
}
|
||||
}
|
||||
|
||||
search_from = end;
|
||||
}
|
||||
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_title_tag(html: &str) -> Option<String> {
|
||||
let lower = html.to_ascii_lowercase();
|
||||
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())
|
||||
}
|
||||
|
||||
fn attr_value(tag: &str, attr: &str) -> Option<String> {
|
||||
let lower = tag.to_ascii_lowercase();
|
||||
let attr = attr.to_ascii_lowercase();
|
||||
let mut search_from = 0;
|
||||
|
||||
while let Some(relative_start) = lower[search_from..].find(&attr) {
|
||||
let name_start = search_from + relative_start;
|
||||
let name_end = name_start + attr.len();
|
||||
let before = lower[..name_start].chars().last();
|
||||
let after = lower[name_end..].chars().next();
|
||||
let has_name_boundary = !matches!(before, Some(c) if c.is_ascii_alphanumeric() || c == '-' || c == '_')
|
||||
&& !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 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)
|
||||
.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();
|
||||
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()),
|
||||
}
|
||||
}
|
||||
|
||||
fn decode_html_entities(value: &str) -> String {
|
||||
let mut decoded = value
|
||||
.replace("&", "&")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">");
|
||||
|
||||
while let Some(start) = decoded.find("&#") {
|
||||
let Some(relative_end) = decoded[start..].find(';') else {
|
||||
break;
|
||||
};
|
||||
let end = start + relative_end + 1;
|
||||
let entity = &decoded[start + 2..end - 1];
|
||||
let parsed = if let Some(hex) = entity
|
||||
.strip_prefix('x')
|
||||
.or_else(|| entity.strip_prefix('X'))
|
||||
{
|
||||
u32::from_str_radix(hex, 16).ok()
|
||||
} 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;
|
||||
|
||||
#[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>
|
||||
"#;
|
||||
|
||||
assert_eq!(
|
||||
extract_google_title(html).as_deref(),
|
||||
Some("Composer links & previews")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn title_ignores_generic_google_titles() {
|
||||
assert_eq!(
|
||||
extract_google_title("<title>Sign in - Google Accounts</title>"),
|
||||
None
|
||||
);
|
||||
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()
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ mod export_util;
|
||||
mod identity;
|
||||
mod identity_archive;
|
||||
mod legacy_storage;
|
||||
mod link_preview;
|
||||
mod media;
|
||||
mod media_download;
|
||||
mod media_transcode;
|
||||
@@ -41,6 +42,7 @@ pub use engrams::*;
|
||||
pub use identity::*;
|
||||
pub use identity_archive::*;
|
||||
pub use legacy_storage::*;
|
||||
pub use link_preview::*;
|
||||
pub use media::*;
|
||||
pub use media_download::*;
|
||||
#[cfg(feature = "mesh-llm")]
|
||||
|
||||
@@ -445,6 +445,7 @@ pub fn run() {
|
||||
get_relay_ws_url,
|
||||
get_relay_http_url,
|
||||
get_media_proxy_port,
|
||||
fetch_link_preview_title,
|
||||
discover_acp_providers,
|
||||
install_acp_runtime,
|
||||
discover_managed_agent_prereqs,
|
||||
|
||||
@@ -14,6 +14,15 @@ type LinkInteractionExtensionOptions = {
|
||||
|
||||
export const linkInteractionKey = new PluginKey("linkInteraction");
|
||||
|
||||
const PASTED_LINK_AT_END_RE =
|
||||
/(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i;
|
||||
|
||||
function isPasteEndingWithLink(text: string): boolean {
|
||||
const trimmedEnd = text.trimEnd();
|
||||
if (!trimmedEnd || trimmedEnd.length !== text.length) return false;
|
||||
return PASTED_LINK_AT_END_RE.test(trimmedEnd);
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralises composer link interactions that depend on ProseMirror editor
|
||||
* state: click interception, click-vs-drag selection preservation, and active
|
||||
@@ -27,6 +36,8 @@ export function createLinkInteractionExtension({
|
||||
name: "linkInteraction",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
let suppressSelectionCardUntil = 0;
|
||||
|
||||
return [
|
||||
new Plugin({
|
||||
key: linkInteractionKey,
|
||||
@@ -42,11 +53,22 @@ export function createLinkInteractionExtension({
|
||||
) {
|
||||
return;
|
||||
}
|
||||
notifyLinkSelection(nextState, getSelectionChangeHandler);
|
||||
notifyLinkSelection(nextState, getSelectionChangeHandler, {
|
||||
suppressLinkedSelection:
|
||||
Date.now() < suppressSelectionCardUntil,
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
props: {
|
||||
handlePaste(_view, event) {
|
||||
const pastedText =
|
||||
event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (isPasteEndingWithLink(pastedText)) {
|
||||
suppressSelectionCardUntil = Date.now() + 500;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
handleDOMEvents: {
|
||||
// Native anchor default can still win in the WebView before
|
||||
// ProseMirror's semantic click hook runs, so intercept editor
|
||||
@@ -98,6 +120,7 @@ export function createLinkInteractionExtension({
|
||||
function notifyLinkSelection(
|
||||
state: EditorState,
|
||||
getSelectionChangeHandler: LinkInteractionExtensionOptions["getSelectionChangeHandler"],
|
||||
options?: { suppressLinkedSelection?: boolean },
|
||||
) {
|
||||
const handler = getSelectionChangeHandler();
|
||||
if (!handler) return;
|
||||
@@ -105,7 +128,8 @@ function notifyLinkSelection(
|
||||
handler(null);
|
||||
return;
|
||||
}
|
||||
handler(resolveLinkAt(state, state.selection.from));
|
||||
const info = resolveLinkAt(state, state.selection.from);
|
||||
handler(options?.suppressLinkedSelection && info ? null : info);
|
||||
}
|
||||
|
||||
function handleLinkClick({
|
||||
|
||||
@@ -6,7 +6,7 @@ import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Extension, type KeyboardShortcutCommand } from "@tiptap/core";
|
||||
import { Selection, TextSelection } from "@tiptap/pm/state";
|
||||
import { Plugin, Selection, TextSelection } from "@tiptap/pm/state";
|
||||
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import type { CustomEmoji } from "@/shared/lib/remarkCustomEmoji";
|
||||
@@ -94,6 +94,63 @@ export type RichTextEditorOptions = {
|
||||
onLinkSelectionChange?: (info: LinkSelectionInfo | null) => void;
|
||||
};
|
||||
|
||||
const PASTED_LINK_AT_END_RE =
|
||||
/(?:^|\s)((?:https?:\/\/|www\.)[^\s]+|(?:github\.com|linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s]+)$/i;
|
||||
|
||||
function shouldAppendSpaceAfterPaste(text: string): boolean {
|
||||
const trimmedEnd = text.trimEnd();
|
||||
if (!trimmedEnd || trimmedEnd.length !== text.length) return false;
|
||||
return PASTED_LINK_AT_END_RE.test(trimmedEnd);
|
||||
}
|
||||
|
||||
const LinkPasteTrailingSpace = Extension.create({
|
||||
name: "linkPasteTrailingSpace",
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
new Plugin({
|
||||
props: {
|
||||
handlePaste(view, event) {
|
||||
const pastedText = event.clipboardData?.getData("text/plain") ?? "";
|
||||
if (!shouldAppendSpaceAfterPaste(pastedText)) return false;
|
||||
|
||||
window.setTimeout(() => {
|
||||
if (!view.dom.isConnected) return;
|
||||
const { state } = view;
|
||||
if (!state.selection.empty) return;
|
||||
|
||||
const from = state.selection.from;
|
||||
if (from < state.doc.content.size) {
|
||||
const nextText = state.doc.textBetween(
|
||||
from,
|
||||
Math.min(state.doc.content.size, from + 1),
|
||||
"\n",
|
||||
"\n",
|
||||
);
|
||||
if (/^\s$/.test(nextText)) return;
|
||||
}
|
||||
|
||||
let transaction = state.tr.insertText(" ", from, from);
|
||||
const linkMark = state.schema.marks.link;
|
||||
if (linkMark) {
|
||||
transaction = transaction.removeMark(from, from + 1, linkMark);
|
||||
}
|
||||
transaction = transaction.setSelection(
|
||||
TextSelection.create(transaction.doc, from + 1),
|
||||
);
|
||||
transaction.setStoredMarks([]);
|
||||
view.dispatch(transaction.scrollIntoView());
|
||||
view.focus();
|
||||
}, 0);
|
||||
|
||||
return false;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
|
||||
/**
|
||||
* Creates and manages a Tiptap editor configured for Markdown output.
|
||||
*
|
||||
@@ -342,6 +399,7 @@ export function useRichTextEditor({
|
||||
class: "text-primary underline underline-offset-4 cursor-text",
|
||||
},
|
||||
}),
|
||||
LinkPasteTrailingSpace,
|
||||
createLinkInteractionExtension({
|
||||
getEditLinkHandler: () => onEditLinkRef.current,
|
||||
getSelectionChangeHandler: () => onLinkSelectionChangeRef.current,
|
||||
|
||||
254
desktop/src/shared/lib/linkPreview.test.mjs
Normal file
254
desktop/src/shared/lib/linkPreview.test.mjs
Normal file
@@ -0,0 +1,254 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
extractSupportedLinkPreviews,
|
||||
isSupportedLinkAutolinkLabel,
|
||||
parseSupportedLinkPreview,
|
||||
} from "./linkPreview.ts";
|
||||
|
||||
test("parseSupportedLinkPreview parses GitHub pull request URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview("https://github.com/block/sprout/pull/1234"),
|
||||
{
|
||||
kind: "github-pull-request",
|
||||
href: "https://github.com/block/sprout/pull/1234",
|
||||
provider: "GitHub",
|
||||
title: "block/sprout #1234",
|
||||
typeLabel: "PR",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview parses GitHub repository URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview("https://github.com/block/sprout"),
|
||||
{
|
||||
kind: "github-repository",
|
||||
href: "https://github.com/block/sprout",
|
||||
provider: "GitHub",
|
||||
title: "block/sprout",
|
||||
typeLabel: "repo",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview trims markdown punctuation around GitHub URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview("https://github.com/block/sprout/pull/1234)."),
|
||||
{
|
||||
kind: "github-pull-request",
|
||||
href: "https://github.com/block/sprout/pull/1234",
|
||||
provider: "GitHub",
|
||||
title: "block/sprout #1234",
|
||||
typeLabel: "PR",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview ignores unsupported GitHub URLs", () => {
|
||||
assert.equal(
|
||||
parseSupportedLinkPreview("https://github.com/block/sprout/tree/main"),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview parses Linear issue URLs", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview(
|
||||
"https://linear.app/buzz/issue/BUG-321/fix-link-previews",
|
||||
),
|
||||
{
|
||||
kind: "linear-issue",
|
||||
href: "https://linear.app/buzz/issue/BUG-321/fix-link-previews",
|
||||
provider: "Linear",
|
||||
title: "BUG-321",
|
||||
typeLabel: "issue",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview normalizes Linear issue URL variants", () => {
|
||||
assert.deepEqual(
|
||||
parseSupportedLinkPreview("linear.app/buzz/issue/a-7/fix-link-previews"),
|
||||
{
|
||||
kind: "linear-issue",
|
||||
href: "https://linear.app/buzz/issue/a-7/fix-link-previews",
|
||||
provider: "Linear",
|
||||
title: "A-7",
|
||||
typeLabel: "issue",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test("parseSupportedLinkPreview parses Google app URLs", () => {
|
||||
assert.deepEqual(
|
||||
[
|
||||
"https://drive.google.com/file/d/abc123/view",
|
||||
"https://drive.google.com/drive/folders/folder123",
|
||||
"https://docs.google.com/document/d/doc123/edit",
|
||||
"https://docs.google.com/spreadsheets/d/sheet123/edit",
|
||||
"https://docs.google.com/presentation/d/slides123/edit",
|
||||
].map((href) => parseSupportedLinkPreview(href)?.kind),
|
||||
[
|
||||
"google-drive-file",
|
||||
"google-drive-folder",
|
||||
"google-docs-document",
|
||||
"google-sheets-spreadsheet",
|
||||
"google-slides-presentation",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews returns unique supported links in order", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"See github.com/block/sprout/pull/1",
|
||||
"and https://linear.app/buzz/issue/BUG-2/fix-preview",
|
||||
"then https://github.com/block/sprout/pull/1 again.",
|
||||
"plus https://docs.google.com/document/d/doc123/edit",
|
||||
].join(" "),
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #1", "BUG-2", "Document"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews handles markdown link serialization", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
"[https://github.com/block/sprout/pull/44](https://github.com/block/sprout/pull/44)",
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #44"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews uses useful markdown labels as titles", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
"[Composer attachment polish](https://docs.google.com/document/d/doc123/edit)",
|
||||
),
|
||||
[
|
||||
{
|
||||
kind: "google-docs-document",
|
||||
href: "https://docs.google.com/document/d/doc123/edit",
|
||||
provider: "Google Docs",
|
||||
title: "Composer attachment polish",
|
||||
typeLabel: "document",
|
||||
},
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews includes multiple supported Google links", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"https://docs.google.com/document/d/doc123/edit",
|
||||
"https://docs.google.com/spreadsheets/d/sheet123/edit",
|
||||
"https://docs.google.com/presentation/d/slides123/edit",
|
||||
].join(" "),
|
||||
).map((preview) => preview.kind),
|
||||
[
|
||||
"google-docs-document",
|
||||
"google-sheets-spreadsheet",
|
||||
"google-slides-presentation",
|
||||
],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews skips URLs inside inline and fenced code", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"`https://github.com/block/sprout/pull/1`",
|
||||
"```",
|
||||
"https://linear.app/buzz/issue/BUG-2/fix-preview",
|
||||
"```",
|
||||
"https://github.com/block/sprout/pull/3",
|
||||
].join("\n"),
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #3"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews skips URLs inside indented code", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
" https://docs.google.com/document/d/hidden/edit",
|
||||
"\tgithub.com/block/sprout/pull/4",
|
||||
"https://github.com/block/sprout/pull/5",
|
||||
].join("\n"),
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #5"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews skips markdown image link URLs", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"",
|
||||
"",
|
||||
"[Composer attachment polish](https://docs.google.com/document/d/doc456/edit)",
|
||||
].join("\n"),
|
||||
).map((preview) => preview.title),
|
||||
["Composer attachment polish"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"https://evil-github.com/block/sprout/pull/1",
|
||||
"https://example.com/go/https://docs.google.com/document/d/doc123/edit",
|
||||
"(https://github.com/block/sprout/pull/2)",
|
||||
].join(" "),
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #2"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews skips links inside inline spoilers", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"Keep",
|
||||
"||[roadmap](https://docs.google.com/document/d/hidden/edit)||",
|
||||
"hidden, but show https://github.com/block/sprout/pull/7",
|
||||
].join(" "),
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #7"],
|
||||
);
|
||||
});
|
||||
|
||||
test("extractSupportedLinkPreviews skips links inside block spoilers", () => {
|
||||
assert.deepEqual(
|
||||
extractSupportedLinkPreviews(
|
||||
[
|
||||
"||",
|
||||
"",
|
||||
"https://linear.app/buzz/issue/BUG-99/hidden-spoiler-link",
|
||||
"",
|
||||
"||",
|
||||
"https://github.com/block/sprout/pull/8",
|
||||
].join("\n"),
|
||||
).map((preview) => preview.title),
|
||||
["block/sprout #8"],
|
||||
);
|
||||
});
|
||||
|
||||
test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
|
||||
const preview = parseSupportedLinkPreview("github.com/block/sprout/pull/5");
|
||||
assert.ok(preview);
|
||||
assert.equal(
|
||||
isSupportedLinkAutolinkLabel(
|
||||
"https://github.com/block/sprout/pull/5",
|
||||
preview,
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
|
||||
});
|
||||
526
desktop/src/shared/lib/linkPreview.ts
Normal file
526
desktop/src/shared/lib/linkPreview.ts
Normal file
@@ -0,0 +1,526 @@
|
||||
export type SupportedLinkPreviewKind =
|
||||
| "github-pull-request"
|
||||
| "github-issue"
|
||||
| "github-repository"
|
||||
| "linear-issue"
|
||||
| "google-drive-file"
|
||||
| "google-drive-folder"
|
||||
| "google-docs-document"
|
||||
| "google-sheets-spreadsheet"
|
||||
| "google-slides-presentation";
|
||||
|
||||
export type SupportedLinkPreview = {
|
||||
kind: SupportedLinkPreviewKind;
|
||||
href: string;
|
||||
provider:
|
||||
| "GitHub"
|
||||
| "Linear"
|
||||
| "Google Drive"
|
||||
| "Google Docs"
|
||||
| "Google Sheets"
|
||||
| "Google Slides";
|
||||
title: string;
|
||||
typeLabel:
|
||||
| "PR"
|
||||
| "issue"
|
||||
| "repo"
|
||||
| "file"
|
||||
| "folder"
|
||||
| "document"
|
||||
| "spreadsheet"
|
||||
| "presentation";
|
||||
};
|
||||
|
||||
const SUPPORTED_URL_RE =
|
||||
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?: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;
|
||||
const MAX_PREVIEWS = 8;
|
||||
|
||||
type HiddenRange = {
|
||||
start: number;
|
||||
end: number;
|
||||
};
|
||||
|
||||
function maskRanges(content: string, ranges: HiddenRange[]): string {
|
||||
if (ranges.length === 0) return content;
|
||||
|
||||
const merged: HiddenRange[] = [];
|
||||
for (const range of [...ranges].sort((a, b) => a.start - b.start)) {
|
||||
const last = merged[merged.length - 1];
|
||||
if (last && range.start <= last.end) {
|
||||
last.end = Math.max(last.end, range.end);
|
||||
} else {
|
||||
merged.push({ ...range });
|
||||
}
|
||||
}
|
||||
|
||||
let masked = "";
|
||||
let cursor = 0;
|
||||
for (const range of merged) {
|
||||
masked += content.slice(cursor, range.start);
|
||||
masked += content.slice(range.start, range.end).replace(/[^\n]/g, " ");
|
||||
cursor = range.end;
|
||||
}
|
||||
|
||||
return masked + content.slice(cursor);
|
||||
}
|
||||
|
||||
function isIndexInRanges(index: number, ranges: HiddenRange[]): boolean {
|
||||
return ranges.some((range) => index >= range.start && index < range.end);
|
||||
}
|
||||
|
||||
function overlapsRange(
|
||||
start: number,
|
||||
end: number,
|
||||
ranges: HiddenRange[],
|
||||
): boolean {
|
||||
return ranges.some((range) => start < range.end && end > range.start);
|
||||
}
|
||||
|
||||
function collectCodeRanges(content: string): HiddenRange[] {
|
||||
const ranges: HiddenRange[] = [];
|
||||
for (const match of content.matchAll(/```[\s\S]*?```|~~~[\s\S]*?~~~/g)) {
|
||||
ranges.push({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/`[^`\n]*`/g)) {
|
||||
ranges.push({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
for (const match of content.matchAll(/^(?: {4}|\t).*(?:\n|$)/gm)) {
|
||||
ranges.push({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function collectMarkdownImageLinkRanges(content: string): HiddenRange[] {
|
||||
const ranges: HiddenRange[] = [];
|
||||
|
||||
for (const match of content.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
|
||||
if (!match[0]?.startsWith("!")) continue;
|
||||
ranges.push({
|
||||
start: match.index ?? 0,
|
||||
end: (match.index ?? 0) + match[0].length,
|
||||
});
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function collectBlockSpoilerRanges(
|
||||
content: string,
|
||||
excludedRanges: HiddenRange[],
|
||||
): HiddenRange[] {
|
||||
const ranges: HiddenRange[] = [];
|
||||
let openStart: number | null = null;
|
||||
let lineStart = 0;
|
||||
|
||||
while (lineStart < content.length) {
|
||||
const newlineIndex = content.indexOf("\n", lineStart);
|
||||
const lineEnd =
|
||||
newlineIndex === -1 ? content.length : newlineIndex + "\n".length;
|
||||
const line = content.slice(
|
||||
lineStart,
|
||||
newlineIndex === -1 ? lineEnd : newlineIndex,
|
||||
);
|
||||
|
||||
if (
|
||||
line.trim() === "||" &&
|
||||
!overlapsRange(lineStart, lineEnd, excludedRanges)
|
||||
) {
|
||||
if (openStart == null) {
|
||||
openStart = lineStart;
|
||||
} else {
|
||||
ranges.push({ start: openStart, end: lineEnd });
|
||||
openStart = null;
|
||||
}
|
||||
}
|
||||
|
||||
lineStart = lineEnd;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function collectInlineSpoilerRanges(
|
||||
content: string,
|
||||
excludedRanges: HiddenRange[],
|
||||
): HiddenRange[] {
|
||||
const ranges: HiddenRange[] = [];
|
||||
let openStart: number | null = null;
|
||||
let index = 0;
|
||||
|
||||
while (index < content.length - 1) {
|
||||
if (
|
||||
content[index] === "|" &&
|
||||
content[index + 1] === "|" &&
|
||||
!isIndexInRanges(index, excludedRanges) &&
|
||||
!isIndexInRanges(index + 1, excludedRanges)
|
||||
) {
|
||||
if (openStart == null) {
|
||||
openStart = index;
|
||||
} else {
|
||||
ranges.push({ start: openStart, end: index + 2 });
|
||||
openStart = null;
|
||||
}
|
||||
index += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
index += 1;
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
function stripHiddenLinkPreviewContent(content: string): string {
|
||||
const codeRanges = collectCodeRanges(content);
|
||||
const imageLinkRanges = collectMarkdownImageLinkRanges(content);
|
||||
const nonSpoilerHiddenRanges = [...codeRanges, ...imageLinkRanges];
|
||||
const blockSpoilerRanges = collectBlockSpoilerRanges(
|
||||
content,
|
||||
nonSpoilerHiddenRanges,
|
||||
);
|
||||
const inlineSpoilerRanges = collectInlineSpoilerRanges(content, [
|
||||
...nonSpoilerHiddenRanges,
|
||||
...blockSpoilerRanges,
|
||||
]);
|
||||
|
||||
return maskRanges(content, [
|
||||
...nonSpoilerHiddenRanges,
|
||||
...blockSpoilerRanges,
|
||||
...inlineSpoilerRanges,
|
||||
]);
|
||||
}
|
||||
|
||||
function countChar(value: string, char: string): number {
|
||||
let count = 0;
|
||||
for (const current of value) {
|
||||
if (current === char) count += 1;
|
||||
}
|
||||
return count;
|
||||
}
|
||||
|
||||
function trimUrlCandidate(candidate: string): string {
|
||||
let value = candidate.replace(/[.,!?;:]+$/g, "");
|
||||
|
||||
const pairs: Array<[close: string, open: string]> = [
|
||||
[")", "("],
|
||||
["]", "["],
|
||||
["}", "{"],
|
||||
];
|
||||
|
||||
let changed = true;
|
||||
while (changed) {
|
||||
changed = false;
|
||||
for (const [close, open] of pairs) {
|
||||
if (
|
||||
value.endsWith(close) &&
|
||||
countChar(value, close) > countChar(value, open)
|
||||
) {
|
||||
value = value.slice(0, -1);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
function safeDecode(value: string): string {
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeHostname(parsed: URL): string {
|
||||
return parsed.hostname.toLowerCase().replace(/^www\./, "");
|
||||
}
|
||||
|
||||
function createPreview(
|
||||
kind: SupportedLinkPreviewKind,
|
||||
parsed: URL,
|
||||
provider: SupportedLinkPreview["provider"],
|
||||
typeLabel: SupportedLinkPreview["typeLabel"],
|
||||
title: string,
|
||||
): SupportedLinkPreview {
|
||||
return {
|
||||
kind,
|
||||
href: parsed.href,
|
||||
provider,
|
||||
title,
|
||||
typeLabel,
|
||||
};
|
||||
}
|
||||
|
||||
function parseGithubLink(parsed: URL): SupportedLinkPreview | null {
|
||||
if (normalizeHostname(parsed) !== "github.com") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = parsed.pathname.split("/").filter(Boolean).map(safeDecode);
|
||||
const [owner, repo, resource, number] = segments;
|
||||
if (!owner || !repo) return null;
|
||||
|
||||
const repoLabel = `${owner}/${repo}`;
|
||||
if (resource === undefined) {
|
||||
return createPreview(
|
||||
"github-repository",
|
||||
parsed,
|
||||
"GitHub",
|
||||
"repo",
|
||||
repoLabel,
|
||||
);
|
||||
}
|
||||
|
||||
if (/^\d+$/.test(number ?? "")) {
|
||||
if (resource === "pull") {
|
||||
return createPreview(
|
||||
"github-pull-request",
|
||||
parsed,
|
||||
"GitHub",
|
||||
"PR",
|
||||
`${repoLabel} #${number}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (resource === "issues") {
|
||||
return createPreview(
|
||||
"github-issue",
|
||||
parsed,
|
||||
"GitHub",
|
||||
"issue",
|
||||
`${repoLabel} #${number}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseLinearIssue(parsed: URL): SupportedLinkPreview | null {
|
||||
if (normalizeHostname(parsed) !== "linear.app") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = parsed.pathname.split("/").filter(Boolean).map(safeDecode);
|
||||
const issueSegmentIndex = segments.findIndex(
|
||||
(segment) => segment.toLowerCase() === "issue",
|
||||
);
|
||||
const workspace = segments[0];
|
||||
const issueId = segments[issueSegmentIndex + 1]?.toUpperCase();
|
||||
|
||||
if (
|
||||
!workspace ||
|
||||
issueSegmentIndex < 1 ||
|
||||
!issueId ||
|
||||
!/^[A-Z][A-Z0-9]*-\d+$/.test(issueId)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return createPreview("linear-issue", parsed, "Linear", "issue", issueId);
|
||||
}
|
||||
|
||||
function parseGoogleDriveLink(parsed: URL): SupportedLinkPreview | null {
|
||||
if (normalizeHostname(parsed) !== "drive.google.com") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const folderSegmentIndex = segments.findIndex(
|
||||
(segment) => segment.toLowerCase() === "folders",
|
||||
);
|
||||
|
||||
if (folderSegmentIndex >= 0 && segments[folderSegmentIndex + 1]) {
|
||||
return createPreview(
|
||||
"google-drive-folder",
|
||||
parsed,
|
||||
"Google Drive",
|
||||
"folder",
|
||||
"Drive folder",
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
(segments[0] === "file" && segments[1] === "d" && segments[2]) ||
|
||||
(segments[0] === "open" && parsed.searchParams.has("id"))
|
||||
) {
|
||||
return createPreview(
|
||||
"google-drive-file",
|
||||
parsed,
|
||||
"Google Drive",
|
||||
"file",
|
||||
"Drive file",
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseGoogleDocsLink(parsed: URL): SupportedLinkPreview | null {
|
||||
if (normalizeHostname(parsed) !== "docs.google.com") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const segments = parsed.pathname.split("/").filter(Boolean);
|
||||
const [resource, dSegment, id] = segments;
|
||||
if (dSegment !== "d" || !id) return null;
|
||||
|
||||
if (resource === "document") {
|
||||
return createPreview(
|
||||
"google-docs-document",
|
||||
parsed,
|
||||
"Google Docs",
|
||||
"document",
|
||||
"Document",
|
||||
);
|
||||
}
|
||||
|
||||
if (resource === "spreadsheets") {
|
||||
return createPreview(
|
||||
"google-sheets-spreadsheet",
|
||||
parsed,
|
||||
"Google Sheets",
|
||||
"spreadsheet",
|
||||
"Spreadsheet",
|
||||
);
|
||||
}
|
||||
|
||||
if (resource === "presentation") {
|
||||
return createPreview(
|
||||
"google-slides-presentation",
|
||||
parsed,
|
||||
"Google Slides",
|
||||
"presentation",
|
||||
"Presentation",
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Parse a supported external URL into a compact preview. */
|
||||
export function parseSupportedLinkPreview(
|
||||
href: string,
|
||||
): SupportedLinkPreview | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
const candidate = trimUrlCandidate(href);
|
||||
parsed = new URL(
|
||||
/^https?:\/\//i.test(candidate) ? candidate : `https://${candidate}`,
|
||||
);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
parseGithubLink(parsed) ??
|
||||
parseLinearIssue(parsed) ??
|
||||
parseGoogleDriveLink(parsed) ??
|
||||
parseGoogleDocsLink(parsed)
|
||||
);
|
||||
}
|
||||
|
||||
export function isSupportedLinkAutolinkLabel(
|
||||
label: string,
|
||||
preview: SupportedLinkPreview,
|
||||
): boolean {
|
||||
return parseSupportedLinkPreview(label)?.href === preview.href;
|
||||
}
|
||||
|
||||
function titleFromMarkdownLabel(
|
||||
label: string,
|
||||
preview: SupportedLinkPreview,
|
||||
): string | null {
|
||||
const title = label.replace(/\s+/g, " ").trim();
|
||||
if (!title || isSupportedLinkAutolinkLabel(title, preview)) {
|
||||
return null;
|
||||
}
|
||||
return title;
|
||||
}
|
||||
|
||||
function withTitle(
|
||||
preview: SupportedLinkPreview,
|
||||
title: string | null,
|
||||
): SupportedLinkPreview {
|
||||
return title ? { ...preview, title } : preview;
|
||||
}
|
||||
|
||||
type LinkPreviewCandidate = {
|
||||
href: string;
|
||||
index: number;
|
||||
label?: string;
|
||||
order: number;
|
||||
};
|
||||
|
||||
/** Extract supported link previews from message text, preserving first-seen order. */
|
||||
export function extractSupportedLinkPreviews(
|
||||
content: string,
|
||||
): SupportedLinkPreview[] {
|
||||
const previews: SupportedLinkPreview[] = [];
|
||||
const seen = new Set<string>();
|
||||
const searchable = stripHiddenLinkPreviewContent(content);
|
||||
const candidates: LinkPreviewCandidate[] = [];
|
||||
let order = 0;
|
||||
|
||||
for (const match of searchable.matchAll(MARKDOWN_SUPPORTED_LINK_RE)) {
|
||||
if (match[0]?.startsWith("!")) continue;
|
||||
candidates.push({
|
||||
href: match[2],
|
||||
index: match.index ?? 0,
|
||||
label: match[1],
|
||||
order,
|
||||
});
|
||||
order += 1;
|
||||
}
|
||||
|
||||
for (const match of searchable.matchAll(SUPPORTED_URL_RE)) {
|
||||
const prefix = match[1] ?? "";
|
||||
const href = match[2];
|
||||
if (!href) continue;
|
||||
candidates.push({
|
||||
href,
|
||||
index: (match.index ?? 0) + prefix.length,
|
||||
order,
|
||||
});
|
||||
order += 1;
|
||||
}
|
||||
|
||||
candidates.sort((a, b) => a.index - b.index || a.order - b.order);
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const preview = parseSupportedLinkPreview(candidate.href);
|
||||
if (!preview || seen.has(preview.href)) continue;
|
||||
|
||||
seen.add(preview.href);
|
||||
previews.push(
|
||||
withTitle(
|
||||
preview,
|
||||
candidate.label
|
||||
? titleFromMarkdownLabel(candidate.label, preview)
|
||||
: null,
|
||||
),
|
||||
);
|
||||
if (previews.length >= MAX_PREVIEWS) break;
|
||||
}
|
||||
|
||||
return previews;
|
||||
}
|
||||
92
desktop/src/shared/lib/useResolvedLinkPreviews.ts
Normal file
92
desktop/src/shared/lib/useResolvedLinkPreviews.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
import type { SupportedLinkPreview } from "./linkPreview";
|
||||
|
||||
const GOOGLE_FALLBACK_TITLES = new Set([
|
||||
"Drive file",
|
||||
"Drive folder",
|
||||
"Document",
|
||||
"Spreadsheet",
|
||||
"Presentation",
|
||||
]);
|
||||
|
||||
const titleCache = new Map<string, Promise<string | null> | string | null>();
|
||||
|
||||
function fetchLinkPreviewTitle(href: string): Promise<string | null> {
|
||||
return invokeTauri<string | null>("fetch_link_preview_title", { href });
|
||||
}
|
||||
|
||||
function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
|
||||
return (
|
||||
preview.kind.startsWith("google-") &&
|
||||
GOOGLE_FALLBACK_TITLES.has(preview.title)
|
||||
);
|
||||
}
|
||||
|
||||
function cacheTitle(href: string): Promise<string | null> {
|
||||
const cached = titleCache.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;
|
||||
})
|
||||
.catch(() => {
|
||||
titleCache.set(href, null);
|
||||
return null;
|
||||
});
|
||||
titleCache.set(href, promise);
|
||||
return promise;
|
||||
}
|
||||
|
||||
export function useResolvedLinkPreviews(
|
||||
previews: SupportedLinkPreview[],
|
||||
): SupportedLinkPreview[] {
|
||||
const [resolvedTitles, setResolvedTitles] = React.useState<
|
||||
Record<string, string>
|
||||
>({});
|
||||
|
||||
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) =>
|
||||
current[preview.href] === cached
|
||||
? current
|
||||
: { ...current, [preview.href]: cached },
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
void cacheTitle(preview.href).then((title) => {
|
||||
if (cancelled || !title) return;
|
||||
setResolvedTitles((current) =>
|
||||
current[preview.href] === title
|
||||
? current
|
||||
: { ...current, [preview.href]: title },
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [previews]);
|
||||
|
||||
return React.useMemo(
|
||||
() =>
|
||||
previews.map((preview) => {
|
||||
const title = resolvedTitles[preview.href];
|
||||
return title ? { ...preview, title } : preview;
|
||||
}),
|
||||
[previews, resolvedTitles],
|
||||
);
|
||||
}
|
||||
@@ -879,6 +879,73 @@
|
||||
cursor: text;
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="linear.app"],
|
||||
.message-markdown .linear-link {
|
||||
--buzz-linear-color: #5e6ad2;
|
||||
}
|
||||
|
||||
.dark .rich-text-composer .tiptap a[href*="linear.app"],
|
||||
.dark .message-markdown .linear-link {
|
||||
--buzz-linear-color: #828fff;
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="linear.app"],
|
||||
.message-markdown .linear-link {
|
||||
color: var(--buzz-linear-color);
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="linear.app"]:hover,
|
||||
.message-markdown .linear-link:hover {
|
||||
color: var(--buzz-linear-color);
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="github.com"] {
|
||||
--buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12'/%3E%3C/svg%3E");
|
||||
--buzz-composer-link-icon-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="linear.app"] {
|
||||
--buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z'/%3E%3C/svg%3E");
|
||||
--buzz-composer-link-icon-color: var(--buzz-linear-color);
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="drive.google.com"] {
|
||||
--buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z'/%3E%3C/svg%3E");
|
||||
--buzz-composer-link-icon-color: #4285f4;
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="docs.google.com/document"] {
|
||||
--buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z'/%3E%3C/svg%3E");
|
||||
--buzz-composer-link-icon-color: #4285f4;
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="docs.google.com/spreadsheets"] {
|
||||
--buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z'/%3E%3C/svg%3E");
|
||||
--buzz-composer-link-icon-color: #34a853;
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="docs.google.com/presentation"] {
|
||||
--buzz-composer-link-icon: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath d='M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z'/%3E%3C/svg%3E");
|
||||
--buzz-composer-link-icon-color: #fbbc04;
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap a[href*="github.com"]::before,
|
||||
.rich-text-composer .tiptap a[href*="linear.app"]::before,
|
||||
.rich-text-composer .tiptap a[href*="drive.google.com"]::before,
|
||||
.rich-text-composer .tiptap a[href*="docs.google.com/document"]::before,
|
||||
.rich-text-composer .tiptap a[href*="docs.google.com/spreadsheets"]::before,
|
||||
.rich-text-composer .tiptap a[href*="docs.google.com/presentation"]::before {
|
||||
background-color: var(--buzz-composer-link-icon-color, currentColor);
|
||||
content: "";
|
||||
display: inline-block;
|
||||
height: 0.875em;
|
||||
margin-right: 0.25em;
|
||||
mask: var(--buzz-composer-link-icon) center / contain no-repeat;
|
||||
-webkit-mask: var(--buzz-composer-link-icon) center / contain no-repeat;
|
||||
transform: translateY(0.1em);
|
||||
width: 0.875em;
|
||||
}
|
||||
|
||||
.rich-text-composer .tiptap hr {
|
||||
border-color: hsl(var(--border) / 0.8);
|
||||
margin: 0.5rem 0;
|
||||
@@ -901,6 +968,58 @@
|
||||
--agent-icon-gap: 0.125rem;
|
||||
}
|
||||
|
||||
.message-markdown p:empty {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.message-markdown [data-link-preview] {
|
||||
--buzz-link-preview-icon-bg: hsl(var(--muted));
|
||||
--buzz-link-preview-icon-color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.dark .message-markdown [data-link-preview] {
|
||||
--buzz-link-preview-icon-bg: #24292f;
|
||||
}
|
||||
|
||||
.message-markdown [data-link-preview="github-issue"],
|
||||
.message-markdown [data-link-preview="github-pull-request"],
|
||||
.message-markdown [data-link-preview="github-repository"] {
|
||||
--buzz-link-preview-icon-color: #24292f;
|
||||
}
|
||||
|
||||
.dark .message-markdown [data-link-preview="github-issue"],
|
||||
.dark .message-markdown [data-link-preview="github-pull-request"],
|
||||
.dark .message-markdown [data-link-preview="github-repository"] {
|
||||
--buzz-link-preview-icon-color: #fff;
|
||||
}
|
||||
|
||||
.message-markdown [data-link-preview="linear-issue"] {
|
||||
--buzz-link-preview-icon-color: #5e6ad2;
|
||||
}
|
||||
|
||||
.dark .message-markdown [data-link-preview="linear-issue"] {
|
||||
--buzz-link-preview-icon-color: #828fff;
|
||||
}
|
||||
|
||||
.message-markdown [data-link-preview="google-drive-file"],
|
||||
.message-markdown [data-link-preview="google-drive-folder"],
|
||||
.message-markdown [data-link-preview="google-docs-document"] {
|
||||
--buzz-link-preview-icon-color: #4285f4;
|
||||
}
|
||||
|
||||
.message-markdown [data-link-preview="google-sheets-spreadsheet"] {
|
||||
--buzz-link-preview-icon-color: #34a853;
|
||||
}
|
||||
|
||||
.message-markdown [data-link-preview="google-slides-presentation"] {
|
||||
--buzz-link-preview-icon-color: #fbbc04;
|
||||
}
|
||||
|
||||
.message-markdown .link-preview-media {
|
||||
background-color: var(--buzz-link-preview-icon-bg);
|
||||
color: var(--buzz-link-preview-icon-color);
|
||||
}
|
||||
|
||||
.message-markdown .mention-chip,
|
||||
.message-markdown .inline-code-chip,
|
||||
.message-markdown :not(pre) > code {
|
||||
|
||||
196
desktop/src/shared/ui/attachment.tsx
Normal file
196
desktop/src/shared/ui/attachment.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import type * as React from "react";
|
||||
import { Slot } from "@radix-ui/react-slot";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { Button, type ButtonProps } from "@/shared/ui/button";
|
||||
|
||||
type AttachmentProps = React.ComponentProps<"div"> & {
|
||||
orientation?: "horizontal" | "vertical";
|
||||
size?: "default" | "sm" | "xs";
|
||||
state?: "idle" | "uploading" | "processing" | "error" | "done";
|
||||
};
|
||||
|
||||
function Attachment({
|
||||
className,
|
||||
orientation = "horizontal",
|
||||
size = "default",
|
||||
state = "done",
|
||||
...props
|
||||
}: AttachmentProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group/attachment relative flex min-w-0 gap-3 overflow-hidden rounded-lg border border-border/70 bg-muted/30 text-left transition-colors",
|
||||
"hover:border-border hover:bg-muted/50 data-[state=error]:border-destructive/40 data-[state=error]:bg-destructive/10",
|
||||
"focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
|
||||
orientation === "horizontal" && "items-center",
|
||||
orientation === "vertical" && "flex-col",
|
||||
size === "default" && "px-3 py-2.5",
|
||||
size === "sm" && "px-2.5 py-2",
|
||||
size === "xs" && "gap-2 px-2 py-1.5",
|
||||
className,
|
||||
)}
|
||||
data-orientation={orientation}
|
||||
data-slot="attachment"
|
||||
data-state={state}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AttachmentMediaProps = React.ComponentProps<"div"> & {
|
||||
variant?: "icon" | "image";
|
||||
};
|
||||
|
||||
function AttachmentMedia({
|
||||
className,
|
||||
variant = "icon",
|
||||
...props
|
||||
}: AttachmentMediaProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex shrink-0 items-center justify-center overflow-hidden rounded-md bg-background text-muted-foreground",
|
||||
variant === "icon" && "h-9 w-9",
|
||||
variant === "image" && "aspect-square h-12 w-12",
|
||||
"[&_svg]:h-4 [&_svg]:w-4",
|
||||
className,
|
||||
)}
|
||||
data-slot="attachment-media"
|
||||
data-variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentContent({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn("min-w-0 flex-1", className)}
|
||||
data-slot="attachment-content"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentTitle({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-sm font-semibold leading-5 text-foreground group-data-[state=processing]/attachment:animate-pulse group-data-[state=uploading]/attachment:animate-pulse",
|
||||
className,
|
||||
)}
|
||||
data-slot="attachment-title"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"truncate text-xs leading-4 text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="attachment-description"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentActions({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"relative z-20 flex shrink-0 items-center gap-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="attachment-actions"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AttachmentActionProps = ButtonProps & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function AttachmentAction({
|
||||
className,
|
||||
size = "icon-xs",
|
||||
variant = "ghost",
|
||||
...props
|
||||
}: AttachmentActionProps) {
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
"relative z-20 text-muted-foreground hover:bg-muted hover:text-foreground",
|
||||
className,
|
||||
)}
|
||||
data-slot="attachment-action"
|
||||
size={size}
|
||||
variant={variant}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type AttachmentTriggerProps = React.ComponentProps<"button"> & {
|
||||
asChild?: boolean;
|
||||
};
|
||||
|
||||
function AttachmentTrigger({
|
||||
asChild,
|
||||
className,
|
||||
type = "button",
|
||||
...props
|
||||
}: AttachmentTriggerProps) {
|
||||
const Comp = asChild ? Slot : "button";
|
||||
|
||||
return (
|
||||
<Comp
|
||||
className={cn(
|
||||
"absolute inset-0 z-10 rounded-lg focus-visible:outline-hidden focus-visible:ring-1 focus-visible:ring-ring",
|
||||
className,
|
||||
)}
|
||||
data-slot="attachment-trigger"
|
||||
type={asChild ? undefined : type}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AttachmentGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex min-w-0 gap-2 overflow-x-auto overscroll-x-contain pb-1",
|
||||
className,
|
||||
)}
|
||||
data-slot="attachment-group"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
Attachment,
|
||||
AttachmentAction,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentDescription,
|
||||
AttachmentGroup,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
};
|
||||
158
desktop/src/shared/ui/link-preview-attachment.tsx
Normal file
158
desktop/src/shared/ui/link-preview-attachment.tsx
Normal file
@@ -0,0 +1,158 @@
|
||||
import { ExternalLink } from "lucide-react";
|
||||
|
||||
import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
Attachment,
|
||||
AttachmentActions,
|
||||
AttachmentContent,
|
||||
AttachmentMedia,
|
||||
AttachmentTitle,
|
||||
AttachmentTrigger,
|
||||
} from "@/shared/ui/attachment";
|
||||
|
||||
function LinearLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M2.886 4.18A11.982 11.982 0 0 1 11.99 0C18.624 0 24 5.376 24 12.009c0 3.64-1.62 6.903-4.18 9.105L2.887 4.18ZM1.817 5.626l16.556 16.556c-.524.33-1.075.62-1.65.866L.951 7.277c.247-.575.537-1.126.866-1.65ZM.322 9.163l14.515 14.515c-.71.172-1.443.282-2.195.322L0 11.358a12 12 0 0 1 .322-2.195Zm-.17 4.862 9.823 9.824a12.02 12.02 0 0 1-9.824-9.824Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GitHubLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDriveLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12.01 1.485c-2.082 0-3.754.02-3.743.047.01.02 1.708 3.001 3.774 6.62l3.76 6.574h3.76c2.081 0 3.753-.02 3.742-.047-.005-.02-1.708-3.001-3.775-6.62l-3.76-6.574zm-4.76 1.73a789.828 789.861 0 0 0-3.63 6.319L0 15.868l1.89 3.298 1.885 3.297 3.62-6.335 3.618-6.33-1.88-3.287C8.1 4.704 7.255 3.22 7.25 3.214zm2.259 12.653-.203.348c-.114.198-.96 1.672-1.88 3.287a423.93 423.948 0 0 1-1.698 2.97c-.01.026 3.24.042 7.222.042h7.244l1.796-3.157c.992-1.734 1.85-3.23 1.906-3.323l.104-.167h-7.249z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleDocsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M14.727 6.727H14V0H4.91c-.905 0-1.637.732-1.637 1.636v20.728c0 .904.732 1.636 1.636 1.636h14.182c.904 0 1.636-.732 1.636-1.636V6.727h-6zm-.545 10.455H7.09v-1.364h7.09v1.364zm2.727-3.273H7.091v-1.364h9.818v1.364zm0-3.273H7.091V9.273h9.818v1.363zM14.727 6h6l-6-6v6z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSheetsLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M11.318 12.545H7.91v-1.909h3.41v1.91zM14.728 0v6h6l-6-6zm1.363 10.636h-3.41v1.91h3.41v-1.91zm0 3.273h-3.41v1.91h3.41v-1.91zM20.727 6.5v15.864c0 .904-.732 1.636-1.636 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.318v6.5h6.5zm-3.273 2.773H6.545v7.909h10.91v-7.91zm-6.136 4.636H7.91v1.91h3.41v-1.91z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function GoogleSlidesLogo({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
aria-hidden="true"
|
||||
className={className}
|
||||
fill="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M16.09 15.273H7.91v-4.637h8.18v4.637zm1.728-8.523h2.91v15.614c0 .904-.733 1.636-1.637 1.636H4.909a1.636 1.636 0 0 1-1.636-1.636V1.636C3.273.732 4.005 0 4.909 0h9.068v6.75h3.841zm-.363 2.523H6.545v7.363h10.91V9.273zm-2.728-5.979V6h6.001l-6-6v3.294z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
|
||||
switch (preview.kind) {
|
||||
case "github-issue":
|
||||
case "github-pull-request":
|
||||
case "github-repository":
|
||||
return <GitHubLogo className="h-4 w-4" />;
|
||||
case "linear-issue":
|
||||
return <LinearLogo className="h-4 w-4" />;
|
||||
case "google-drive-file":
|
||||
case "google-drive-folder":
|
||||
return <GoogleDriveLogo className="h-4 w-4" />;
|
||||
case "google-docs-document":
|
||||
return <GoogleDocsLogo className="h-4 w-4" />;
|
||||
case "google-sheets-spreadsheet":
|
||||
return <GoogleSheetsLogo className="h-4 w-4" />;
|
||||
case "google-slides-presentation":
|
||||
return <GoogleSlidesLogo className="h-4 w-4" />;
|
||||
}
|
||||
}
|
||||
|
||||
export function LinkPreviewAttachment({
|
||||
className,
|
||||
preview,
|
||||
}: {
|
||||
className?: string;
|
||||
preview: SupportedLinkPreview;
|
||||
}) {
|
||||
return (
|
||||
<Attachment
|
||||
className={cn(
|
||||
"w-80 max-w-full shrink-0 no-underline shadow-none",
|
||||
className,
|
||||
)}
|
||||
data-link-preview={preview.kind}
|
||||
>
|
||||
<AttachmentMedia className="link-preview-media">
|
||||
<LinkPreviewLogo preview={preview} />
|
||||
</AttachmentMedia>
|
||||
<AttachmentContent>
|
||||
<div className="truncate text-xs font-medium leading-4 text-muted-foreground">
|
||||
{preview.provider}
|
||||
<span aria-hidden="true"> · </span>
|
||||
{preview.typeLabel}
|
||||
</div>
|
||||
<AttachmentTitle>{preview.title}</AttachmentTitle>
|
||||
</AttachmentContent>
|
||||
<AttachmentActions>
|
||||
<ExternalLink
|
||||
aria-hidden="true"
|
||||
className="h-4 w-4 text-muted-foreground opacity-0 transition-opacity group-hover/attachment:opacity-100 group-focus-within/attachment:opacity-100"
|
||||
/>
|
||||
</AttachmentActions>
|
||||
<AttachmentTrigger asChild>
|
||||
<a
|
||||
aria-label={`Open ${preview.provider} ${preview.typeLabel}: ${preview.title}`}
|
||||
href={preview.href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
<span className="sr-only">
|
||||
Open {preview.provider} {preview.typeLabel}: {preview.title}
|
||||
</span>
|
||||
</a>
|
||||
</AttachmentTrigger>
|
||||
</Attachment>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,12 @@ import type { Channel } from "@/shared/api/types";
|
||||
import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext";
|
||||
import { copyCodeBlockToClipboard } from "@/shared/lib/codeBlockClipboard";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import {
|
||||
extractSupportedLinkPreviews,
|
||||
isSupportedLinkAutolinkLabel,
|
||||
parseSupportedLinkPreview,
|
||||
} from "@/shared/lib/linkPreview";
|
||||
import { useResolvedLinkPreviews } from "@/shared/lib/useResolvedLinkPreviews";
|
||||
import { rewriteRelayUrl } from "@/shared/lib/mediaUrl";
|
||||
import rehypeImageGallery from "@/shared/lib/rehypeImageGallery";
|
||||
import rehypeSearchHighlight from "@/shared/lib/rehypeSearchHighlight";
|
||||
@@ -51,6 +57,8 @@ import remarkMentions from "@/shared/lib/remarkMentions";
|
||||
import remarkSpoilers from "@/shared/lib/remarkSpoilers";
|
||||
import remarkMessageLinks from "@/features/messages/lib/remarkMessageLinks";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { AttachmentGroup } from "@/shared/ui/attachment";
|
||||
import { LinkPreviewAttachment } from "@/shared/ui/link-preview-attachment";
|
||||
import {
|
||||
INLINE_CODE_CHIP_CLASS,
|
||||
MENTION_CHIP_BASE_CLASSES,
|
||||
@@ -210,6 +218,7 @@ type MarkdownRuntime = {
|
||||
agentMentionPubkeysByName?: Record<string, string>;
|
||||
channels: Channel[];
|
||||
imetaByUrl?: ImetaLookup;
|
||||
linkPreviewHrefs: ReadonlySet<string>;
|
||||
mentionPubkeysByName?: Record<string, string>;
|
||||
onOpenChannel: (channelId: string) => void;
|
||||
onOpenMessageLink: (link: ParsedMessageLink) => void;
|
||||
@@ -2210,7 +2219,8 @@ function createMarkdownComponents(
|
||||
</SpoilerInline>
|
||||
),
|
||||
a: ({ children, href, ...props }) => {
|
||||
const { imetaByUrl, onOpenMessageLink } = runtimeRef.current;
|
||||
const { imetaByUrl, linkPreviewHrefs, onOpenMessageLink } =
|
||||
runtimeRef.current;
|
||||
if (!interactive) {
|
||||
return <span className="font-medium text-current">{children}</span>;
|
||||
}
|
||||
@@ -2222,13 +2232,15 @@ function createMarkdownComponents(
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
const label = getReactNodeText(children);
|
||||
|
||||
// Generic file attachment: a `[filename](url)` link whose href matches an
|
||||
// imeta entry with a non-image, non-video MIME. Render a download card
|
||||
// instead of a plain link. (Media uses the `img` renderer, not this path.)
|
||||
const card = resolveFileCard(
|
||||
href ? imetaByUrl?.get(href) : undefined,
|
||||
href,
|
||||
getReactNodeText(children),
|
||||
label,
|
||||
);
|
||||
if (card) {
|
||||
return (
|
||||
@@ -2246,7 +2258,7 @@ function createMarkdownComponents(
|
||||
if (href) {
|
||||
const messageLinkTarget = resolveMessageLinkRenderTarget({
|
||||
href,
|
||||
label: getReactNodeText(children),
|
||||
label,
|
||||
});
|
||||
if (messageLinkTarget.kind !== "none") {
|
||||
if (messageLinkTarget.kind === "pill") {
|
||||
@@ -2278,10 +2290,26 @@ function createMarkdownComponents(
|
||||
// Malformed message deep link — fall through to the default
|
||||
// anchor (renders as a normal external link).
|
||||
}
|
||||
|
||||
const supportedLinkPreview = href
|
||||
? parseSupportedLinkPreview(href)
|
||||
: null;
|
||||
const isLinearLink = supportedLinkPreview?.kind === "linear-issue";
|
||||
if (
|
||||
supportedLinkPreview &&
|
||||
linkPreviewHrefs.has(supportedLinkPreview.href) &&
|
||||
isSupportedLinkAutolinkLabel(label, supportedLinkPreview)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<a
|
||||
{...props}
|
||||
className="font-medium text-primary underline underline-offset-4 transition-colors hover:text-primary/80"
|
||||
className={cn(
|
||||
"font-medium underline underline-offset-4 transition-colors",
|
||||
isLinearLink ? "linear-link" : "text-primary hover:text-primary/80",
|
||||
)}
|
||||
href={href}
|
||||
rel="noreferrer"
|
||||
target="_blank"
|
||||
@@ -2615,10 +2643,19 @@ function MarkdownInner({
|
||||
},
|
||||
[goChannel],
|
||||
);
|
||||
const linkPreviews = React.useMemo(
|
||||
() => (interactive ? extractSupportedLinkPreviews(content) : []),
|
||||
[content, interactive],
|
||||
);
|
||||
const linkPreviewHrefs = React.useMemo(
|
||||
() => new Set(linkPreviews.map((preview) => preview.href)),
|
||||
[linkPreviews],
|
||||
);
|
||||
const runtimeRef = useLatestRef<MarkdownRuntime>({
|
||||
agentMentionPubkeysByName,
|
||||
channels,
|
||||
imetaByUrl,
|
||||
linkPreviewHrefs,
|
||||
mentionPubkeysByName,
|
||||
onOpenChannel,
|
||||
onOpenMessageLink,
|
||||
@@ -2663,6 +2700,8 @@ function MarkdownInner({
|
||||
processedContent = `${processedContent}\u200B`;
|
||||
}
|
||||
|
||||
const resolvedLinkPreviews = useResolvedLinkPreviews(linkPreviews);
|
||||
|
||||
const markdownNode = (
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
@@ -2697,6 +2736,16 @@ function MarkdownInner({
|
||||
>
|
||||
<VideoReviewMarkdownContext.Provider value={videoReviewContext}>
|
||||
{markdownNode}
|
||||
{resolvedLinkPreviews.length > 0 ? (
|
||||
<AttachmentGroup
|
||||
className="max-w-full flex-wrap overflow-visible pb-0"
|
||||
data-link-preview-list=""
|
||||
>
|
||||
{resolvedLinkPreviews.map((preview) => (
|
||||
<LinkPreviewAttachment key={preview.href} preview={preview} />
|
||||
))}
|
||||
</AttachmentGroup>
|
||||
) : null}
|
||||
</VideoReviewMarkdownContext.Provider>
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user