mirror of
https://github.com/block/buzz.git
synced 2026-10-31 22:41:12 +00:00
Projects, repositories, issues, and pull requests had no shareable reference — the only way to point someone at one was to describe it. Each now offers "Copy link" in its row menu (plus a copy button in the project, issue, and pull request detail headers), yielding a `buzz://` deep link that renders as a preview card in chat and opens the entity in-app when clicked or opened from the OS. Completes the entity-link format for this: adds the `buzz://project` scheme on both sides (TS builders/parser and the buzz-cli mirror, with `projects create` emitting `link`), resolves repository and project card titles from their announcement events, and registers the four entity hosts with the Tauri deep-link handler. Coordinates whose d-tag falls outside the link charset simply have no share affordance, rather than producing a link the recipient cannot parse. Signed-off-by: Thomas Petersen <thomasp@squareup.com>
437 lines
12 KiB
TypeScript
437 lines
12 KiB
TypeScript
import * as React from "react";
|
|
|
|
import { invokeTauri } from "@/shared/api/tauri";
|
|
import { relayClient } from "@/shared/api/relayClient";
|
|
import {
|
|
KIND_GIT_ISSUE,
|
|
KIND_GIT_PULL_REQUEST,
|
|
KIND_PROJECT_ANNOUNCEMENT,
|
|
KIND_REPO_ANNOUNCEMENT,
|
|
} from "@/shared/constants/kinds";
|
|
|
|
import { isEntityLink, parseEntityLink } from "./entityLink";
|
|
import {
|
|
buzzEntityFallbackTitle,
|
|
type SupportedLinkPreview,
|
|
} from "./linkPreview";
|
|
|
|
type LinkPreviewImageFetchState =
|
|
| "none"
|
|
| "image"
|
|
| "transient_failure"
|
|
| "rejected";
|
|
|
|
export type LinkPreviewMetadata = {
|
|
title: string;
|
|
siteName: string | null;
|
|
description: string | null;
|
|
imageDataUrl: string | null;
|
|
imageDomain: string | null;
|
|
imageFetchState?: LinkPreviewImageFetchState;
|
|
imageRetryAfterMs?: number | null;
|
|
faviconDataUrl?: string | null;
|
|
};
|
|
|
|
type MetadataCacheEntry = {
|
|
expiresAt: number | null;
|
|
metadata: LinkPreviewMetadata | null;
|
|
};
|
|
|
|
type MetadataLoadResult = MetadataCacheEntry & {
|
|
key: string;
|
|
};
|
|
|
|
const DEFAULT_TRANSIENT_RETRY_MS = 30_000;
|
|
const NULL_METADATA_RETRY_MS = 5 * 60_000;
|
|
const MAX_CONCURRENT_METADATA_FETCHES = 2;
|
|
|
|
/**
|
|
* React may flush an interaction-triggered effect before the browser paints.
|
|
* Start uncached preview I/O after a frame plus a task boundary so the pasted
|
|
* text and loading card are visible before native IPC work begins.
|
|
*/
|
|
function scheduleAfterPaint(task: () => void): () => void {
|
|
let frameId: number | null = null;
|
|
let timeoutId: ReturnType<typeof setTimeout> | null = null;
|
|
const run = () => {
|
|
timeoutId = setTimeout(task, 0);
|
|
};
|
|
|
|
if (typeof requestAnimationFrame === "function") {
|
|
frameId = requestAnimationFrame(run);
|
|
} else {
|
|
run();
|
|
}
|
|
|
|
return () => {
|
|
if (frameId !== null) cancelAnimationFrame(frameId);
|
|
if (timeoutId !== null) clearTimeout(timeoutId);
|
|
};
|
|
}
|
|
|
|
function metadataCacheKey(href: string): string {
|
|
try {
|
|
const url = new URL(href);
|
|
url.hash = "";
|
|
return url.href;
|
|
} catch {
|
|
return href.split("#", 1)[0] ?? href;
|
|
}
|
|
}
|
|
|
|
function metadataExpiry(
|
|
metadata: LinkPreviewMetadata | null,
|
|
now: number,
|
|
): number | null {
|
|
if (metadata === null) return now + NULL_METADATA_RETRY_MS;
|
|
if (metadata.imageFetchState !== "transient_failure") return null;
|
|
const retryAfterMs =
|
|
typeof metadata.imageRetryAfterMs === "number" &&
|
|
Number.isFinite(metadata.imageRetryAfterMs)
|
|
? Math.max(1_000, metadata.imageRetryAfterMs)
|
|
: DEFAULT_TRANSIENT_RETRY_MS;
|
|
return now + retryAfterMs;
|
|
}
|
|
|
|
function createTaskScheduler(concurrency: number) {
|
|
const pending: Array<() => void> = [];
|
|
let active = 0;
|
|
|
|
const drain = () => {
|
|
while (active < concurrency) {
|
|
const run = pending.shift();
|
|
if (!run) return;
|
|
active += 1;
|
|
run();
|
|
}
|
|
};
|
|
|
|
return <T>(task: () => Promise<T>): Promise<T> =>
|
|
new Promise<T>((resolve, reject) => {
|
|
pending.push(() => {
|
|
void task()
|
|
.then(resolve, reject)
|
|
.finally(() => {
|
|
active -= 1;
|
|
drain();
|
|
});
|
|
});
|
|
drain();
|
|
});
|
|
}
|
|
|
|
function createMetadataLoader({
|
|
concurrency = MAX_CONCURRENT_METADATA_FETCHES,
|
|
fetcher,
|
|
now = Date.now,
|
|
}: {
|
|
concurrency?: number;
|
|
fetcher: (href: string) => Promise<LinkPreviewMetadata | null>;
|
|
now?: () => number;
|
|
}) {
|
|
const cache = new Map<
|
|
string,
|
|
MetadataCacheEntry | Promise<MetadataLoadResult>
|
|
>();
|
|
const schedule = createTaskScheduler(Math.max(1, concurrency));
|
|
let generation = 0;
|
|
|
|
const peek = (href: string): MetadataLoadResult | undefined => {
|
|
const key = metadataCacheKey(href);
|
|
const cached = cache.get(key);
|
|
if (!cached || cached instanceof Promise) return undefined;
|
|
if (cached.expiresAt !== null && cached.expiresAt <= now()) {
|
|
cache.delete(key);
|
|
return undefined;
|
|
}
|
|
return { key, ...cached };
|
|
};
|
|
|
|
const load = (href: string): Promise<MetadataLoadResult> => {
|
|
const key = metadataCacheKey(href);
|
|
const cached = cache.get(key);
|
|
if (cached instanceof Promise) return cached;
|
|
if (cached) {
|
|
if (cached.expiresAt === null || cached.expiresAt > now()) {
|
|
return Promise.resolve({ key, ...cached });
|
|
}
|
|
cache.delete(key);
|
|
}
|
|
|
|
const requestGeneration = generation;
|
|
const promise = schedule(() => fetcher(href))
|
|
.catch(() => null)
|
|
.then((metadata) => {
|
|
const entry = {
|
|
expiresAt: metadataExpiry(metadata, now()),
|
|
metadata,
|
|
};
|
|
if (requestGeneration === generation) {
|
|
cache.set(key, entry);
|
|
}
|
|
return { key, ...entry };
|
|
});
|
|
cache.set(key, promise);
|
|
return promise;
|
|
};
|
|
|
|
return {
|
|
deleteKey(key: string) {
|
|
cache.delete(key);
|
|
},
|
|
load,
|
|
peek,
|
|
reset() {
|
|
generation += 1;
|
|
cache.clear();
|
|
},
|
|
};
|
|
}
|
|
|
|
function fetchLinkPreviewMetadata(
|
|
href: string,
|
|
): Promise<LinkPreviewMetadata | null> {
|
|
return invokeTauri<LinkPreviewMetadata | null>(
|
|
"fetch_link_preview_metadata",
|
|
{
|
|
href,
|
|
},
|
|
);
|
|
}
|
|
|
|
const metadataLoader = createMetadataLoader({
|
|
fetcher: fetchLinkPreviewMetadata,
|
|
});
|
|
function buzzEntityMetadata(
|
|
title: string | null | undefined,
|
|
description?: string | null,
|
|
): LinkPreviewMetadata | null {
|
|
return title
|
|
? {
|
|
title,
|
|
siteName: "Buzz",
|
|
description: description || null,
|
|
imageDataUrl: null,
|
|
imageDomain: null,
|
|
}
|
|
: null;
|
|
}
|
|
|
|
/**
|
|
* Resolve the display name of an addressable coordinate (`kind:owner:d`) from
|
|
* its announcement event. Returns null when the relay has no such event, which
|
|
* drops the card rather than advertising a coordinate nobody can open.
|
|
*/
|
|
async function loadCoordinateMetadata(
|
|
kind: number,
|
|
owner: string,
|
|
dtag: string,
|
|
): Promise<LinkPreviewMetadata | null> {
|
|
const events = await relayClient.fetchEvents({
|
|
kinds: [kind],
|
|
authors: [owner],
|
|
"#d": [dtag],
|
|
limit: 1,
|
|
});
|
|
const event = events[0];
|
|
if (!event) return null;
|
|
|
|
const tag = (name: string) =>
|
|
event.tags.find((entry) => entry[0] === name)?.[1] || null;
|
|
return buzzEntityMetadata(tag("name") ?? dtag, tag("description"));
|
|
}
|
|
|
|
const entityTitleLoader = createMetadataLoader({
|
|
fetcher: async (href) => {
|
|
const parsed = parseEntityLink(href);
|
|
if (!parsed.ok) return null;
|
|
|
|
const link = parsed.value;
|
|
if (link.type === "repo") {
|
|
return loadCoordinateMetadata(
|
|
KIND_REPO_ANNOUNCEMENT,
|
|
link.owner,
|
|
link.dtag,
|
|
);
|
|
}
|
|
if (link.type === "project") {
|
|
return loadCoordinateMetadata(
|
|
KIND_PROJECT_ANNOUNCEMENT,
|
|
link.owner,
|
|
link.dtag,
|
|
);
|
|
}
|
|
|
|
const { id, owner, dtag } = link;
|
|
const expectedCoordinate = `30617:${owner}:${dtag}`;
|
|
const events = await relayClient.fetchEvents({
|
|
kinds: [link.type === "pr" ? KIND_GIT_PULL_REQUEST : KIND_GIT_ISSUE],
|
|
ids: [id],
|
|
limit: 1,
|
|
});
|
|
const event = events[0];
|
|
if (
|
|
!event?.tags.some(
|
|
(tag) => tag[0] === "a" && tag[1] === expectedCoordinate,
|
|
)
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
const subject = event.tags.find((tag) => tag[0] === "subject")?.[1];
|
|
return buzzEntityMetadata(subject || event.content.split("\n")[0]);
|
|
},
|
|
});
|
|
|
|
/** Clear ephemeral metadata when the active relay/community changes. */
|
|
export function resetLinkPreviewMetadataCache(): void {
|
|
metadataLoader.reset();
|
|
entityTitleLoader.reset();
|
|
}
|
|
|
|
export type LinkPreviewImageState = "pending" | "image" | "fallback" | "none";
|
|
|
|
export type ResolvedLinkPreview = SupportedLinkPreview & {
|
|
description?: string | null;
|
|
faviconDataUrl?: string | null;
|
|
imageState: LinkPreviewImageState;
|
|
/** Metadata extraction completed successfully; safe to snapshot after media uploads. */
|
|
snapshotReady?: boolean;
|
|
};
|
|
|
|
type ResolvedMetadataByHref = Record<
|
|
string,
|
|
LinkPreviewMetadata | null | undefined
|
|
>;
|
|
|
|
/** Only auto-generated titles may be replaced; explicit markdown labels win. */
|
|
export function shouldResolveTitle(preview: SupportedLinkPreview): boolean {
|
|
if (!isEntityLink(preview.href)) return true;
|
|
const parsed = parseEntityLink(preview.href);
|
|
return parsed.ok && preview.title === buzzEntityFallbackTitle(parsed.value);
|
|
}
|
|
|
|
export function resolveLinkPreview(
|
|
preview: SupportedLinkPreview,
|
|
metadata: LinkPreviewMetadata | null | undefined,
|
|
): ResolvedLinkPreview {
|
|
if (metadata === undefined) {
|
|
return { ...preview, imageState: "pending" };
|
|
}
|
|
if (metadata === null) {
|
|
return { ...preview, imageState: "none" };
|
|
}
|
|
|
|
const hasImage = Boolean(metadata.imageDataUrl && metadata.imageDomain);
|
|
const imageState: LinkPreviewImageState = hasImage
|
|
? "image"
|
|
: metadata.imageFetchState === "image" ||
|
|
metadata.imageFetchState === "transient_failure" ||
|
|
metadata.imageFetchState === "rejected"
|
|
? "fallback"
|
|
: "none";
|
|
return {
|
|
...preview,
|
|
snapshotReady: !preview.href.startsWith("buzz://"),
|
|
title: shouldResolveTitle(preview) ? metadata.title : preview.title,
|
|
description: metadata.description,
|
|
faviconDataUrl: metadata.faviconDataUrl,
|
|
provider:
|
|
preview.kind === "generic-link" && metadata.siteName
|
|
? metadata.siteName
|
|
: preview.provider,
|
|
imageDataUrl: hasImage ? metadata.imageDataUrl : null,
|
|
imageDomain: hasImage ? metadata.imageDomain : null,
|
|
imageState,
|
|
};
|
|
}
|
|
|
|
export function useResolvedLinkPreviews(
|
|
previews: SupportedLinkPreview[],
|
|
): ResolvedLinkPreview[] {
|
|
const [resolvedMetadata, setResolvedMetadata] =
|
|
React.useState<ResolvedMetadataByHref>({});
|
|
const [retryGeneration, setRetryGeneration] = React.useState(0);
|
|
|
|
React.useEffect(() => {
|
|
let cancelled = false;
|
|
let retryAt = Number.POSITIVE_INFINITY;
|
|
let retryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
|
|
const scheduleRetry = (
|
|
{ expiresAt, key }: Pick<MetadataLoadResult, "expiresAt" | "key">,
|
|
loader: typeof metadataLoader,
|
|
) => {
|
|
if (expiresAt === null || expiresAt >= retryAt) return;
|
|
retryAt = expiresAt;
|
|
if (retryTimer !== null) clearTimeout(retryTimer);
|
|
retryTimer = setTimeout(
|
|
() => {
|
|
loader.deleteKey(key);
|
|
setResolvedMetadata((current) => {
|
|
if (!(key in current)) return current;
|
|
const next = { ...current };
|
|
delete next[key];
|
|
return next;
|
|
});
|
|
setRetryGeneration(retryGeneration + 1);
|
|
},
|
|
Math.max(0, expiresAt - Date.now()),
|
|
);
|
|
};
|
|
|
|
const cancelScheduledLoads: Array<() => void> = [];
|
|
for (const preview of previews) {
|
|
const loader = preview.href.startsWith("buzz://")
|
|
? entityTitleLoader
|
|
: metadataLoader;
|
|
const cached = loader.peek(preview.href);
|
|
if (cached !== undefined) {
|
|
setResolvedMetadata((current) =>
|
|
current[cached.key] === cached.metadata
|
|
? current
|
|
: { ...current, [cached.key]: cached.metadata },
|
|
);
|
|
scheduleRetry(cached, loader);
|
|
continue;
|
|
}
|
|
|
|
cancelScheduledLoads.push(
|
|
scheduleAfterPaint(() => {
|
|
void loader.load(preview.href).then((result) => {
|
|
if (cancelled) return;
|
|
setResolvedMetadata((current) =>
|
|
current[result.key] === result.metadata
|
|
? current
|
|
: { ...current, [result.key]: result.metadata },
|
|
);
|
|
scheduleRetry(result, loader);
|
|
});
|
|
}),
|
|
);
|
|
}
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
for (const cancel of cancelScheduledLoads) cancel();
|
|
if (retryTimer !== null) clearTimeout(retryTimer);
|
|
};
|
|
}, [previews, retryGeneration]);
|
|
|
|
return React.useMemo(
|
|
() =>
|
|
previews.flatMap((preview) => {
|
|
const metadata = resolvedMetadata[metadataCacheKey(preview.href)];
|
|
return metadata === null ? [] : [resolveLinkPreview(preview, metadata)];
|
|
}),
|
|
[previews, resolvedMetadata],
|
|
);
|
|
}
|
|
|
|
export const __linkPreviewMetadataTest = {
|
|
createMetadataLoader,
|
|
createTaskScheduler,
|
|
metadataCacheKey,
|
|
metadataExpiry,
|
|
};
|