Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
329 changes: 183 additions & 146 deletions desktop/src-tauri/src/commands/link_preview.rs

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -714,7 +714,7 @@ pub fn run() {
get_relay_ws_url,
get_relay_http_url,
get_media_proxy_port,
fetch_link_preview_title,
fetch_link_preview_metadata,
discover_acp_auth_methods,
discover_acp_providers,
discover_git_bash_prerequisite,
Expand Down
2 changes: 2 additions & 0 deletions desktop/src/features/communities/useCommunityInit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
import { getIdentity } from "@/shared/api/tauriIdentity";
import { getOverrides } from "@/shared/features";
import { resetMediaCaches } from "@/shared/lib/mediaUrl";
import { resetLinkPreviewMetadataCache } from "@/shared/lib/useResolvedLinkPreviews";
import { clearSearchHitEventCache } from "@/app/navigation/searchHitEventCache";
import {
clearAllDrafts,
Expand Down Expand Up @@ -59,6 +60,7 @@ function resetCommunityState({
}
resetSidebarRelayConnectionCardState();
resetMediaCaches();
resetLinkPreviewMetadataCache();
resetVideoPlayerState();
resetRenderScopedReactionHydration();
clearSearchHitEventCache();
Expand Down
40 changes: 38 additions & 2 deletions desktop/src/shared/lib/linkPreview.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -198,7 +198,7 @@ test("extractSupportedLinkPreviews skips markdown image link URLs", () => {
);
});

test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
test("extractSupportedLinkPreviews treats other absolute HTTPS URLs as generic", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
[
Expand All @@ -207,7 +207,7 @@ test("extractSupportedLinkPreviews requires bare URL boundaries", () => {
"(https://github.com/block/sprout/pull/2)",
].join(" "),
).map((preview) => preview.title),
["block/sprout #2"],
["evil-github.com", "example.com", "block/sprout #2"],
);
});

Expand Down Expand Up @@ -252,3 +252,39 @@ test("isSupportedLinkAutolinkLabel matches normalized bare URL labels", () => {
);
assert.equal(isSupportedLinkAutolinkLabel("review this", preview), false);
});

test("parseSupportedLinkPreview parses generic HTTPS URLs", () => {
assert.deepEqual(
parseSupportedLinkPreview("https://example.com/articles/rich-previews"),
{
kind: "generic-link",
href: "https://example.com/articles/rich-previews",
provider: "example.com",
title: "example.com",
typeLabel: "link",
},
);
});

test("parseSupportedLinkPreview rejects generic HTTP URLs", () => {
assert.equal(
parseSupportedLinkPreview("http://example.com/articles/rich-previews"),
null,
);
});

test("extractSupportedLinkPreviews finds generic links and preserves exclusions", () => {
assert.deepEqual(
extractSupportedLinkPreviews(
[
"Read https://example.com/article first.",
"`https://hidden.example.com/secret`",
"then [the details](https://docs.example.org/details)",
].join(" "),
).map(({ kind, title }) => ({ kind, title })),
[
{ kind: "generic-link", title: "example.com" },
{ kind: "generic-link", title: "the details" },
],
);
});
39 changes: 25 additions & 14 deletions desktop/src/shared/lib/linkPreview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,13 @@ export type SupportedLinkPreviewKind =
| "google-drive-folder"
| "google-docs-document"
| "google-sheets-spreadsheet"
| "google-slides-presentation";
| "google-slides-presentation"
| "generic-link";

export type SupportedLinkPreview = {
kind: SupportedLinkPreviewKind;
href: string;
provider:
| "GitHub"
| "Linear"
| "Google Drive"
| "Google Docs"
| "Google Sheets"
| "Google Slides";
provider: string;
title: string;
typeLabel:
| "PR"
Expand All @@ -28,13 +23,14 @@ export type SupportedLinkPreview = {
| "folder"
| "document"
| "spreadsheet"
| "presentation";
| "presentation"
| "link";
};

const SUPPORTED_URL_RE =
/(^|[\s([{<>"'])((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
/(^|[\s([{<>"'])(https:\/\/[^\s<>"'\]]+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^\s<>"'\]]+)/gi;
const MARKDOWN_SUPPORTED_LINK_RE =
/!?\[([^\]\n]+)\]\(((?:https?:\/\/)?(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
/!?\[([^\]\n]+)\]\((https:\/\/[^)\s<>"']+|(?:(?:www\.)?github\.com|(?:www\.)?linear\.app|drive\.google\.com|docs\.google\.com)\/[^)\s<>"']+)\)/gi;
const MAX_PREVIEWS = 8;

type HiddenRange = {
Expand Down Expand Up @@ -431,12 +427,27 @@ export function parseSupportedLinkPreview(
return null;
}

return (
const recognized =
parseGithubLink(parsed) ??
parseLinearIssue(parsed) ??
parseGoogleDriveLink(parsed) ??
parseGoogleDocsLink(parsed)
);
parseGoogleDocsLink(parsed);
if (recognized) return recognized;
const hostname = normalizeHostname(parsed);
if (
parsed.protocol !== "https:" ||
[
"github.com",
"linear.app",
"drive.google.com",
"docs.google.com",
].includes(hostname)
) {
return null;
}

const provider = hostname;
return createPreview("generic-link", parsed, provider, "link", provider);
}

export function isSupportedLinkAutolinkLabel(
Expand Down
87 changes: 49 additions & 38 deletions desktop/src/shared/lib/useResolvedLinkPreviews.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,74 +4,77 @@ import { invokeTauri } from "@/shared/api/tauri";

import type { SupportedLinkPreview } from "./linkPreview";

const GOOGLE_FALLBACK_TITLES = new Set([
"Drive file",
"Drive folder",
"Document",
"Spreadsheet",
"Presentation",
]);
type LinkPreviewMetadata = {
title: string;
siteName: string | null;
};

const titleCache = new Map<string, Promise<string | null> | string | null>();
const metadataCache = new Map<
string,
Promise<LinkPreviewMetadata | null> | LinkPreviewMetadata | null
>();

function fetchLinkPreviewTitle(href: string): Promise<string | null> {
return invokeTauri<string | null>("fetch_link_preview_title", { href });
/** Clear ephemeral metadata when the active relay/community changes. */
export function resetLinkPreviewMetadataCache(): void {
metadataCache.clear();
}

function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
return (
preview.kind.startsWith("google-") &&
GOOGLE_FALLBACK_TITLES.has(preview.title)
function fetchLinkPreviewMetadata(
href: string,
): Promise<LinkPreviewMetadata | null> {
return invokeTauri<LinkPreviewMetadata | null>(
"fetch_link_preview_metadata",
{
href,
},
);
}

function cacheTitle(href: string): Promise<string | null> {
const cached = titleCache.get(href);
function cacheMetadata(href: string): Promise<LinkPreviewMetadata | null> {
const cached = metadataCache.get(href);
if (cached instanceof Promise) return cached;
if (cached !== undefined) return Promise.resolve(cached);

const promise = fetchLinkPreviewTitle(href)
.then((title) => {
titleCache.set(href, title);
return title;
const promise = fetchLinkPreviewMetadata(href)
.then((metadata) => {
metadataCache.set(href, metadata);
return metadata;
})
.catch(() => {
titleCache.set(href, null);
metadataCache.set(href, null);
return null;
});
titleCache.set(href, promise);
metadataCache.set(href, promise);
return promise;
}

export function useResolvedLinkPreviews(
previews: SupportedLinkPreview[],
): SupportedLinkPreview[] {
const [resolvedTitles, setResolvedTitles] = React.useState<
Record<string, string>
const [resolvedMetadata, setResolvedMetadata] = React.useState<
Record<string, LinkPreviewMetadata>
>({});

React.useEffect(() => {
let cancelled = false;
const pending = previews.filter(shouldResolveTitle);
if (pending.length === 0) return undefined;

for (const preview of pending) {
const cached = titleCache.get(preview.href);
if (typeof cached === "string" && cached) {
setResolvedTitles((current) =>
for (const preview of previews) {
const cached = metadataCache.get(preview.href);
if (cached && !(cached instanceof Promise)) {
setResolvedMetadata((current) =>
current[preview.href] === cached
? current
: { ...current, [preview.href]: cached },
);
continue;
}

void cacheTitle(preview.href).then((title) => {
if (cancelled || !title) return;
setResolvedTitles((current) =>
current[preview.href] === title
void cacheMetadata(preview.href).then((metadata) => {
if (cancelled || !metadata) return;
setResolvedMetadata((current) =>
current[preview.href] === metadata
? current
: { ...current, [preview.href]: title },
: { ...current, [preview.href]: metadata },
);
});
}
Expand All @@ -84,9 +87,17 @@ export function useResolvedLinkPreviews(
return React.useMemo(
() =>
previews.map((preview) => {
const title = resolvedTitles[preview.href];
return title ? { ...preview, title } : preview;
const metadata = resolvedMetadata[preview.href];
if (!metadata) return preview;
return {
...preview,
title: metadata.title,
provider:
preview.kind === "generic-link" && metadata.siteName
? metadata.siteName
: preview.provider,
};
}),
[previews, resolvedTitles],
[previews, resolvedMetadata],
);
}
4 changes: 3 additions & 1 deletion desktop/src/shared/ui/link-preview-attachment.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { ExternalLink } from "lucide-react";
import { ExternalLink, Globe } from "lucide-react";

import type { SupportedLinkPreview } from "@/shared/lib/linkPreview";
import { cn } from "@/shared/lib/cn";
Expand Down Expand Up @@ -106,6 +106,8 @@ function LinkPreviewLogo({ preview }: { preview: SupportedLinkPreview }) {
return <GoogleSheetsLogo className="h-4 w-4" />;
case "google-slides-presentation":
return <GoogleSlidesLogo className="h-4 w-4" />;
case "generic-link":
return <Globe aria-hidden="true" className="h-4 w-4" />;
}
}

Expand Down
2 changes: 1 addition & 1 deletion desktop/tests/e2e/project-commit-detail.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ test("commit detail opens from the commits feed with a diff", async ({
page.getByRole("button", { name: "Copy commit hash" }),
).toBeVisible();
await expect(
page.getByRole("link", { name: "project guide" }),
page.getByRole("link", { name: "project guide", exact: true }),
).toHaveAttribute("href", "https://example.com/project-guide");
await expect(
page.getByRole("button", { name: "Architecture" }),
Expand Down
Loading