Add rich link previews (#1334)

This commit is contained in:
klopez4212
2026-06-29 14:36:21 +01:00
committed by GitHub
parent 826d735fe6
commit bc925780ee
12 changed files with 1787 additions and 7 deletions

View 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("&amp;", "&")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&#x27;", "'")
.replace("&apos;", "'")
.replace("&lt;", "<")
.replace("&gt;", ">");
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 &amp; 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()
));
}
}

View File

@@ -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")]

View File

@@ -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,

View File

@@ -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({

View File

@@ -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,

View 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(
[
"![alt](https://docs.google.com/document/d/doc123/edit)",
"![alt](https://github.com/block/sprout)",
"[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);
});

View 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;
}

View 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],
);
}

View File

@@ -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 {

View 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,
};

View 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>
);
}

View File

@@ -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>
);