mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-07 01:09:54 +08:00
refactor(editor): add cache extension for link preview service (#12196)
Closes: [BS-2578](https://linear.app/affine-design/issue/BS-2578/优化-footnote-预览的逻辑:支持缓存结果,避免重复-loading) <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Introduced a link preview caching mechanism, enabling faster and more efficient reuse of link preview data across the app. - Added a feature flag for enabling or disabling link preview cache, configurable through workspace experimental settings. - Enhanced localization with new entries describing the link preview cache feature. - **Improvements** - Updated link preview service architecture for better extensibility and maintainability. - Improved integration of feature flags throughout chat and rendering components. - **Bug Fixes** - Fixed tooltip formatting for footnote URLs. - **Chores** - Updated dependencies and localization completeness tracking. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -22,6 +22,7 @@ export interface BlockSuiteFlags {
|
||||
enable_embed_doc_with_alias: boolean;
|
||||
enable_turbo_renderer: boolean;
|
||||
enable_citation: boolean;
|
||||
enable_link_preview_cache: boolean;
|
||||
}
|
||||
|
||||
export class FeatureFlagService extends StoreExtension {
|
||||
@@ -48,6 +49,7 @@ export class FeatureFlagService extends StoreExtension {
|
||||
enable_embed_doc_with_alias: false,
|
||||
enable_turbo_renderer: false,
|
||||
enable_citation: false,
|
||||
enable_link_preview_cache: false,
|
||||
});
|
||||
|
||||
setFlag(key: keyof BlockSuiteFlags, value: boolean) {
|
||||
|
||||
@@ -11,7 +11,7 @@ export * from './feature-flag-service';
|
||||
export * from './file-size-limit-service';
|
||||
export * from './font-loader';
|
||||
export * from './generate-url-service';
|
||||
export * from './link-previewer-service';
|
||||
export * from './link-preview-service';
|
||||
export * from './native-clipboard-service';
|
||||
export * from './notification-service';
|
||||
export * from './open-doc-config';
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export * from './link-preview-cache';
|
||||
export * from './link-preview-service';
|
||||
export * from './link-preview-storage';
|
||||
@@ -0,0 +1,240 @@
|
||||
import type { LinkPreviewData } from '@blocksuite/affine-model';
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { Extension, type ExtensionType } from '@blocksuite/store';
|
||||
import debounce from 'lodash-es/debounce';
|
||||
import QuickLRU from 'quick-lru';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LinkPreviewStorage } from './link-preview-storage';
|
||||
|
||||
export const LinkPreviewCacheConfigSchema = z.object({
|
||||
/**
|
||||
* The maximum number of items in the cache
|
||||
*/
|
||||
cacheSize: z.number(),
|
||||
/**
|
||||
* The time to live for the memory cache
|
||||
*/
|
||||
memoryTTL: z.number(),
|
||||
/**
|
||||
* The time to live for the local storage cache
|
||||
*/
|
||||
localStorageTTL: z.number(),
|
||||
});
|
||||
|
||||
export type LinkPreviewCacheConfig = z.infer<
|
||||
typeof LinkPreviewCacheConfigSchema
|
||||
>;
|
||||
|
||||
const DEFAULT_LINK_PREVIEW_CACHE_CONFIG: LinkPreviewCacheConfig = {
|
||||
cacheSize: 50,
|
||||
memoryTTL: 1000 * 60 * 60, // 60 minutes
|
||||
localStorageTTL: 1000 * 60 * 60 * 6, // 6 hours
|
||||
};
|
||||
|
||||
/**
|
||||
* It's used to debounce the save to local storage to avoid frequent writes
|
||||
*/
|
||||
const DEBOUNCE_TIME = 1000;
|
||||
|
||||
/**
|
||||
* The interface for the link preview cache provider
|
||||
*/
|
||||
export interface LinkPreviewCacheProvider {
|
||||
/**
|
||||
* Get the link preview data for a given URL
|
||||
* @param url The URL to get the link preview data for
|
||||
* @returns The link preview data for the given URL
|
||||
*/
|
||||
get(url: string): Partial<LinkPreviewData> | undefined;
|
||||
/**
|
||||
* Set the link preview data for a given URL
|
||||
* @param url The URL to set the link preview data for
|
||||
* @param data The link preview data to set
|
||||
*/
|
||||
set(url: string, data: Partial<LinkPreviewData>): void;
|
||||
/**
|
||||
* Get the pending request for a given URL
|
||||
* @param url The URL to get the pending request for
|
||||
* @returns The pending request for the given URL
|
||||
*/
|
||||
getPendingRequest(url: string): Promise<Partial<LinkPreviewData>> | undefined;
|
||||
/**
|
||||
* Set the pending request for a given URL
|
||||
* @param url The URL to set the pending request for
|
||||
* @param promise The promise to set for the given URL
|
||||
*/
|
||||
setPendingRequest(
|
||||
url: string,
|
||||
promise: Promise<Partial<LinkPreviewData>>
|
||||
): void;
|
||||
/**
|
||||
* Delete the pending request for a given URL
|
||||
* @param url The URL to delete the pending request for
|
||||
*/
|
||||
deletePendingRequest(url: string): void;
|
||||
/**
|
||||
* Clear the cache
|
||||
*/
|
||||
clear(): void;
|
||||
}
|
||||
|
||||
export const LinkPreviewCacheIdentifier =
|
||||
createIdentifier<LinkPreviewCacheProvider>('AffineLinkPreviewCache');
|
||||
|
||||
/**
|
||||
* The link preview cache, it will cache the link preview data in the memory and local storage
|
||||
*/
|
||||
export class LinkPreviewCache
|
||||
extends Extension
|
||||
implements LinkPreviewCacheProvider
|
||||
{
|
||||
/**
|
||||
* The singleton instance of the link preview cache
|
||||
*/
|
||||
private static instance: LinkPreviewCache | null = null;
|
||||
|
||||
/**
|
||||
* The memory cache for the link preview
|
||||
*/
|
||||
private readonly memoryCache: QuickLRU<string, Partial<LinkPreviewData>>;
|
||||
/**
|
||||
* The pending requests for the link preview
|
||||
* The promise will be resolved when the data is fetched
|
||||
*/
|
||||
private readonly pendingRequests: Map<
|
||||
string,
|
||||
Promise<Partial<LinkPreviewData>>
|
||||
>;
|
||||
/**
|
||||
* The local storage manager for the link preview
|
||||
*/
|
||||
private readonly storage: LinkPreviewStorage;
|
||||
|
||||
constructor(
|
||||
private readonly config: LinkPreviewCacheConfig = DEFAULT_LINK_PREVIEW_CACHE_CONFIG
|
||||
) {
|
||||
super();
|
||||
this.storage = new LinkPreviewStorage();
|
||||
this.memoryCache = new QuickLRU({
|
||||
maxSize: this.config.cacheSize,
|
||||
maxAge: this.config.memoryTTL,
|
||||
onEviction: key => {
|
||||
this._clearItemFromStorage(key);
|
||||
},
|
||||
});
|
||||
this.pendingRequests = new Map();
|
||||
this._loadFromStorage();
|
||||
}
|
||||
|
||||
static getInstance(config?: LinkPreviewCacheConfig): LinkPreviewCache {
|
||||
if (!LinkPreviewCache.instance) {
|
||||
LinkPreviewCache.instance = new LinkPreviewCache(config);
|
||||
}
|
||||
return LinkPreviewCache.instance;
|
||||
}
|
||||
|
||||
get(url: string): Partial<LinkPreviewData> | undefined {
|
||||
return this.memoryCache.get(url);
|
||||
}
|
||||
|
||||
set(url: string, data: Partial<LinkPreviewData>): void {
|
||||
this.memoryCache.set(url, data);
|
||||
this._saveToStorage();
|
||||
}
|
||||
|
||||
getPendingRequest(
|
||||
url: string
|
||||
): Promise<Partial<LinkPreviewData>> | undefined {
|
||||
return this.pendingRequests.get(url);
|
||||
}
|
||||
|
||||
setPendingRequest(
|
||||
url: string,
|
||||
promise: Promise<Partial<LinkPreviewData>>
|
||||
): void {
|
||||
this.pendingRequests.set(url, promise);
|
||||
}
|
||||
|
||||
deletePendingRequest(url: string): void {
|
||||
this.pendingRequests.delete(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the cache from local storage
|
||||
*/
|
||||
private readonly _loadFromStorage = (): void => {
|
||||
const data = this.storage.load();
|
||||
|
||||
// Check if the data is expired
|
||||
const localDataExpires = data.expires;
|
||||
// If the data is expired, clear the data
|
||||
if (localDataExpires && localDataExpires < Date.now()) {
|
||||
this.storage.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
// load the data to the memory cache
|
||||
Object.entries(data.data).forEach(([url, item]) => {
|
||||
this.memoryCache.set(url, item);
|
||||
});
|
||||
};
|
||||
|
||||
/**
|
||||
* Save the cache to local storage
|
||||
* Debounce the save to local storage to avoid frequent writes
|
||||
*/
|
||||
private readonly _saveToStorage = debounce(() => {
|
||||
const entries = Array.from(this.memoryCache.entriesDescending());
|
||||
const linkPreviewData = Object.fromEntries(
|
||||
entries.slice(0, this.memoryCache.size).map(([url, data]) => [url, data])
|
||||
);
|
||||
const data = {
|
||||
data: linkPreviewData,
|
||||
expires: Date.now() + this.config.localStorageTTL,
|
||||
};
|
||||
this.storage.save(data);
|
||||
}, DEBOUNCE_TIME);
|
||||
|
||||
/**
|
||||
* Clear a link preview record from local storage with specific URL
|
||||
* Called when the item is evicted from the memory cache
|
||||
* @param {string} url The URL key to remove from storage
|
||||
* @returns {boolean} Whether the item was successfully removed
|
||||
*/
|
||||
private readonly _clearItemFromStorage = (url: string): void => {
|
||||
this.storage.clearItem(url);
|
||||
};
|
||||
|
||||
clear(): void {
|
||||
this.memoryCache.clear();
|
||||
this.pendingRequests.clear();
|
||||
}
|
||||
|
||||
clearLocalStorage(): void {
|
||||
this.storage.clear();
|
||||
}
|
||||
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(LinkPreviewCacheIdentifier, () =>
|
||||
LinkPreviewCache.getInstance()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The extension for the link preview cache, it will override the link preview cache instance
|
||||
* @param config - The configuration for the link preview cache
|
||||
* @returns The extension for the link preview cache
|
||||
*/
|
||||
export const LinkPreviewCacheExtension = (
|
||||
config?: LinkPreviewCacheConfig
|
||||
): ExtensionType => {
|
||||
return {
|
||||
setup: (di: Container) => {
|
||||
di.override(LinkPreviewCacheIdentifier, () =>
|
||||
LinkPreviewCache.getInstance(config)
|
||||
);
|
||||
},
|
||||
};
|
||||
};
|
||||
@@ -0,0 +1,225 @@
|
||||
import { type LinkPreviewData } from '@blocksuite/affine-model';
|
||||
import { type Container, createIdentifier } from '@blocksuite/global/di';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { type BlockStdScope, StdIdentifier } from '@blocksuite/std';
|
||||
import { Extension } from '@blocksuite/store';
|
||||
|
||||
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../../consts';
|
||||
import { isAbortError } from '../../utils/is-abort-error';
|
||||
import { FeatureFlagService } from '../feature-flag-service';
|
||||
import {
|
||||
LinkPreviewCacheIdentifier,
|
||||
type LinkPreviewCacheProvider,
|
||||
} from './link-preview-cache';
|
||||
|
||||
export type LinkPreviewResponseData = {
|
||||
url: string;
|
||||
title?: string;
|
||||
siteName?: string;
|
||||
description?: string;
|
||||
images?: string[];
|
||||
mediaType?: string;
|
||||
contentType?: string;
|
||||
charset?: string;
|
||||
videos?: string[];
|
||||
favicons?: string[];
|
||||
};
|
||||
|
||||
export interface LinkPreviewProvider {
|
||||
/**
|
||||
* Query link preview data for a given URL
|
||||
*/
|
||||
query: (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
) => Promise<Partial<LinkPreviewData>>;
|
||||
/**
|
||||
* Set the endpoint for link preview
|
||||
*/
|
||||
setEndpoint: (endpoint: string) => void;
|
||||
|
||||
/**
|
||||
* Get the endpoint for link preview
|
||||
*/
|
||||
endpoint: string;
|
||||
}
|
||||
|
||||
export const LinkPreviewServiceIdentifier =
|
||||
createIdentifier<LinkPreviewProvider>('AffineLinkPreviewService');
|
||||
|
||||
export class LinkPreviewService
|
||||
extends Extension
|
||||
implements LinkPreviewProvider
|
||||
{
|
||||
static override setup(di: Container) {
|
||||
di.addImpl(LinkPreviewServiceIdentifier, LinkPreviewService, [
|
||||
StdIdentifier,
|
||||
LinkPreviewCacheIdentifier,
|
||||
]);
|
||||
}
|
||||
|
||||
private _endpoint: string = DEFAULT_LINK_PREVIEW_ENDPOINT;
|
||||
|
||||
constructor(
|
||||
private readonly _std: BlockStdScope,
|
||||
private readonly _cache: LinkPreviewCacheProvider
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
get endpoint() {
|
||||
return this._endpoint;
|
||||
}
|
||||
|
||||
setEndpoint = (endpoint: string) => {
|
||||
this._endpoint = endpoint;
|
||||
};
|
||||
|
||||
private readonly _fetchTwitterPreview = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
try {
|
||||
const match = /\/status\/(\d+)/.exec(url);
|
||||
if (!match) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Invalid tweet URL: ${url}`
|
||||
);
|
||||
}
|
||||
const apiUrl = `https://api.fxtwitter.com/status/${match[1]}`;
|
||||
|
||||
const response = await fetch(apiUrl, { signal }).then(res => res.json());
|
||||
const tweet = response?.tweet;
|
||||
if (!tweet) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Invalid tweet response: ${url}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
title: tweet.author?.name ?? null,
|
||||
icon: tweet.author?.avatar_url ?? null,
|
||||
description: tweet.text ?? null,
|
||||
image:
|
||||
tweet.media?.photos?.[0]?.url || tweet.author?.banner_url || null,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch tweet: ${url}`);
|
||||
console.error(e);
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
private readonly _fetchStandardPreview = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
const response = await fetch(this.endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({ url }),
|
||||
signal,
|
||||
})
|
||||
.then(r => {
|
||||
if (!r || !r.ok) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Failed to fetch link preview: ${url}`
|
||||
);
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch(err => {
|
||||
if (isAbortError(err)) return null;
|
||||
console.error(`Failed to fetch link preview: ${url}`);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response) return {};
|
||||
|
||||
const data: LinkPreviewResponseData = await response.json();
|
||||
return {
|
||||
title: data.title ?? null,
|
||||
description: data.description ?? null,
|
||||
icon: data.favicons?.[0],
|
||||
image: data.images?.[0],
|
||||
};
|
||||
};
|
||||
|
||||
private readonly _isTwitterUrl = (url: string): boolean => {
|
||||
const twitterDomains = [
|
||||
'https://x.com/',
|
||||
'https://www.x.com/',
|
||||
'https://www.twitter.com/',
|
||||
'https://twitter.com/',
|
||||
];
|
||||
return (
|
||||
twitterDomains.some(domain => url.startsWith(domain)) &&
|
||||
url.includes('/status/')
|
||||
);
|
||||
};
|
||||
|
||||
private readonly _fetchPreview = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
if (this._isTwitterUrl(url)) {
|
||||
return this._fetchTwitterPreview(url, signal);
|
||||
}
|
||||
return this._fetchStandardPreview(url, signal);
|
||||
};
|
||||
|
||||
/**
|
||||
* Fetch link preview data for a given URL
|
||||
*/
|
||||
query = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
const featureFlagService = this._std.store.get(FeatureFlagService);
|
||||
const cacheEnabled = featureFlagService.getFlag(
|
||||
'enable_link_preview_cache'
|
||||
);
|
||||
// If the cache is not enabled, fetch the preview directly
|
||||
if (!cacheEnabled) {
|
||||
return this._fetchPreview(url, signal);
|
||||
}
|
||||
|
||||
// Check memory cache, if hit, return the cached data
|
||||
const cached = this._cache.get(url);
|
||||
if (cached) {
|
||||
return cached;
|
||||
}
|
||||
|
||||
// Check pending requests, if there is a pending request, return the promise
|
||||
const pendingRequest = this._cache.getPendingRequest(url);
|
||||
if (pendingRequest) {
|
||||
return pendingRequest;
|
||||
}
|
||||
|
||||
// Fetch new data
|
||||
const promise = (async () => {
|
||||
try {
|
||||
// Fetch new data
|
||||
const data = await this._fetchPreview(url, signal);
|
||||
// If the data is not empty, set the data to the cache
|
||||
if (data && Object.keys(data).length > 0) {
|
||||
this._cache.set(url, data);
|
||||
}
|
||||
return data;
|
||||
} finally {
|
||||
// Delete the pending request regardless of success or failure
|
||||
this._cache.deletePendingRequest(url);
|
||||
}
|
||||
})();
|
||||
|
||||
// Set the promise to the cache
|
||||
this._cache.setPendingRequest(url, promise);
|
||||
return promise;
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import { LinkPreviewDataSchema } from '@blocksuite/affine-model';
|
||||
import { z } from 'zod';
|
||||
|
||||
const _StorageSchema = z.object({
|
||||
data: z.record(LinkPreviewDataSchema.partial()),
|
||||
expires: z.number().optional(),
|
||||
});
|
||||
|
||||
type StorageData = z.infer<typeof _StorageSchema>;
|
||||
|
||||
/**
|
||||
* The local storage manager for the link preview cache data
|
||||
*/
|
||||
export class LinkPreviewStorage {
|
||||
/**
|
||||
* The storage key for the link preview
|
||||
*/
|
||||
storageKey = 'blocksuite:link-preview-cache';
|
||||
|
||||
/**
|
||||
* Load the cache from local storage
|
||||
* @returns StorageData
|
||||
*/
|
||||
load(): StorageData {
|
||||
try {
|
||||
const stored = localStorage.getItem(this.storageKey);
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored);
|
||||
const safe = _StorageSchema.safeParse(parsed);
|
||||
if (safe.success) {
|
||||
return safe.data;
|
||||
}
|
||||
// if the data is invalid, clear the data
|
||||
this.clear();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load cache from storage:', e);
|
||||
}
|
||||
return { data: {} };
|
||||
}
|
||||
|
||||
/**
|
||||
* Save the cache to local storage
|
||||
* @param {StorageData} data
|
||||
*/
|
||||
save(data: StorageData): void {
|
||||
try {
|
||||
const serialized = JSON.stringify(data);
|
||||
localStorage.setItem(this.storageKey, serialized);
|
||||
} catch (e) {
|
||||
console.error('Failed to save cache to storage:', e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear a link preview record from local storage with specific URL
|
||||
* @param {string} url The URL key to remove from storage
|
||||
* @returns {boolean} Whether the item was successfully removed
|
||||
*/
|
||||
clearItem(url: string): boolean {
|
||||
try {
|
||||
const data = this.load();
|
||||
if (!(url in data.data)) {
|
||||
return false;
|
||||
}
|
||||
delete data.data[url];
|
||||
this.save(data);
|
||||
return true;
|
||||
} catch (e) {
|
||||
console.error('Failed to clear item from storage:', e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all records from local storage
|
||||
*/
|
||||
clear(): void {
|
||||
localStorage.removeItem(this.storageKey);
|
||||
}
|
||||
}
|
||||
@@ -1,99 +0,0 @@
|
||||
import type { LinkPreviewData } from '@blocksuite/affine-model';
|
||||
import { BlockSuiteError, ErrorCode } from '@blocksuite/global/exceptions';
|
||||
import { StoreExtension } from '@blocksuite/store';
|
||||
|
||||
import { DEFAULT_LINK_PREVIEW_ENDPOINT } from '../consts';
|
||||
import { isAbortError } from '../utils/is-abort-error';
|
||||
|
||||
export type LinkPreviewResponseData = {
|
||||
url: string;
|
||||
title?: string;
|
||||
siteName?: string;
|
||||
description?: string;
|
||||
images?: string[];
|
||||
mediaType?: string;
|
||||
contentType?: string;
|
||||
charset?: string;
|
||||
videos?: string[];
|
||||
favicons?: string[];
|
||||
};
|
||||
|
||||
export class LinkPreviewerService extends StoreExtension {
|
||||
static override key = 'link-previewer';
|
||||
|
||||
private _endpoint = DEFAULT_LINK_PREVIEW_ENDPOINT;
|
||||
|
||||
query = async (
|
||||
url: string,
|
||||
signal?: AbortSignal
|
||||
): Promise<Partial<LinkPreviewData>> => {
|
||||
if (
|
||||
(url.startsWith('https://x.com/') ||
|
||||
url.startsWith('https://www.x.com/') ||
|
||||
url.startsWith('https://www.twitter.com/') ||
|
||||
url.startsWith('https://twitter.com/')) &&
|
||||
url.includes('/status/')
|
||||
) {
|
||||
// use api.fxtwitter.com
|
||||
url =
|
||||
'https://api.fxtwitter.com/status/' + /\/status\/(.*)/.exec(url)?.[1];
|
||||
try {
|
||||
const { tweet } = await fetch(url, { signal }).then(res => res.json());
|
||||
return {
|
||||
title: tweet.author.name,
|
||||
icon: tweet.author.avatar_url,
|
||||
description: tweet.text,
|
||||
image: tweet.media?.photos?.[0].url || tweet.author.banner_url,
|
||||
};
|
||||
} catch (e) {
|
||||
console.error(`Failed to fetch tweet: ${url}`);
|
||||
console.error(e);
|
||||
return {};
|
||||
}
|
||||
} else {
|
||||
const response = await fetch(this._endpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
url,
|
||||
}),
|
||||
signal,
|
||||
})
|
||||
.then(r => {
|
||||
if (!r || !r.ok) {
|
||||
throw new BlockSuiteError(
|
||||
ErrorCode.DefaultRuntimeError,
|
||||
`Failed to fetch link preview: ${url}`
|
||||
);
|
||||
}
|
||||
return r;
|
||||
})
|
||||
.catch(err => {
|
||||
if (isAbortError(err)) return null;
|
||||
console.error(`Failed to fetch link preview: ${url}`);
|
||||
console.error(err);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (!response) return {};
|
||||
|
||||
const data: LinkPreviewResponseData = await response.json();
|
||||
return {
|
||||
title: data.title ?? null,
|
||||
description: data.description ?? null,
|
||||
icon: data.favicons?.[0],
|
||||
image: data.images?.[0],
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
get endpoint() {
|
||||
return this._endpoint;
|
||||
}
|
||||
|
||||
setEndpoint = (endpoint: string) => {
|
||||
this._endpoint = endpoint;
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user