diff --git a/playwright/e2e/page/simple-table.spec.ts b/playwright/e2e/page/simple-table.spec.ts index c47a6ace..b22744a6 100644 --- a/playwright/e2e/page/simple-table.spec.ts +++ b/playwright/e2e/page/simple-table.spec.ts @@ -281,6 +281,18 @@ test.describe('SimpleTable', () => { expect(metrics.cardWidth).toBeLessThanOrEqual(metrics.cellContentWidth + 1); expect(metrics.cellWidth).toBeLessThanOrEqual(initialCellWidth + 1); + const layout = await card.evaluate((cardEl) => { + const title = cardEl.querySelector('.link-preview-title'); + + return { + flexDirection: getComputedStyle(cardEl).flexDirection, + titleWhiteSpace: title ? getComputedStyle(title).whiteSpace : '', + }; + }); + + expect(layout.flexDirection).toBe('column'); + expect(layout.titleWhiteSpace).toBe('normal'); + const urlCell = getCell(page, 0, 1); const plainUrl = 'https://appflowy.io/simple-table-url-layout'; diff --git a/src/application/types.ts b/src/application/types.ts index 413313f8..92d607cf 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -1402,7 +1402,7 @@ export interface ViewComponentProps { updatePage?: (viewId: string, data: UpdatePagePayload) => Promise; addPage?: (parentId: string, payload: CreatePagePayload) => Promise; deletePage?: (viewId: string) => Promise; - duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise; + duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise; openPageModal?: (viewId: string) => void; variant?: UIVariant; isTemplateThumb?: boolean; @@ -1452,6 +1452,14 @@ export interface DuplicatePageOptions { source?: number; } +export interface DuplicatePageOperationOptions extends DuplicatePageOptions { + /** + * Client-only lifecycle hook. Runs after the pre-duplicate collab sync and + * before the duplicate API request; it is not sent to the server. + */ + afterPreSync?: () => Promise; +} + export interface CreateDatabaseViewPayload { parent_view_id: string; /** Insert the new database view after this sibling. When omitted the backend prepends. */ diff --git a/src/components/app/contexts/AppOperationsContext.ts b/src/components/app/contexts/AppOperationsContext.ts index aea3db1e..fe88355b 100644 --- a/src/components/app/contexts/AppOperationsContext.ts +++ b/src/components/app/contexts/AppOperationsContext.ts @@ -5,7 +5,7 @@ import { SyncContext } from '@/application/services/js-services/sync-protocol'; import { CreateDatabaseViewPayload, CreateDatabaseViewResponse, - DuplicatePageOptions, + DuplicatePageOperationOptions, CreatePagePayload, CreatePageResponse, CreateRow, @@ -69,7 +69,7 @@ export interface AppOperationsContextType { /** Soft-delete a page (move to trash). */ deletePage?: (viewId: string) => Promise; /** Duplicate a page, optionally refreshing its parent children in the outline. */ - duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise; + duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise; /** Update page properties (name, cover, etc.). */ updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise; /** Update just the page icon. */ diff --git a/src/components/app/hooks/usePageOperations.ts b/src/components/app/hooks/usePageOperations.ts index cae039c9..02926a92 100644 --- a/src/components/app/hooks/usePageOperations.ts +++ b/src/components/app/hooks/usePageOperations.ts @@ -11,7 +11,7 @@ import { } from '@/application/services/js-services/http/publish-api'; import { CreateDatabaseViewPayload, - DuplicatePageOptions, + DuplicatePageOperationOptions, CreatePagePayload, CreateSpacePayload, Role, @@ -154,11 +154,13 @@ export function usePageOperations({ ); const duplicatePage = useCallback( - async (viewId: string, options: DuplicatePageOptions = {}) => { + async (viewId: string, options: DuplicatePageOperationOptions = {}) => { if (!currentWorkspaceId) { throw new Error('No workspace or service found'); } + const { afterPreSync, ...duplicateOptions } = options; + try { // Sync all collab documents to the server via HTTP API before duplicating. // This ensures the server has the latest data (including unregistered row @@ -174,12 +176,14 @@ export function usePageOperations({ await flushAllSync?.(); } - await PageService.duplicate(currentWorkspaceId, viewId, options); + await afterPreSync?.(); + + await PageService.duplicate(currentWorkspaceId, viewId, duplicateOptions); await loadOutline?.(currentWorkspaceId, false); - if (options.parentViewId) { - ViewService.invalidateCache(currentWorkspaceId, options.parentViewId); - await loadViewChildren?.(options.parentViewId); + if (duplicateOptions.parentViewId) { + ViewService.invalidateCache(currentWorkspaceId, duplicateOptions.parentViewId); + await loadViewChildren?.(duplicateOptions.parentViewId); } } catch (e) { return Promise.reject(e); diff --git a/src/components/editor/EditorContext.tsx b/src/components/editor/EditorContext.tsx index c41f6649..ce401c81 100644 --- a/src/components/editor/EditorContext.tsx +++ b/src/components/editor/EditorContext.tsx @@ -17,7 +17,7 @@ import { CreatePageResponse, CreateDatabaseViewPayload, CreateDatabaseViewResponse, - DuplicatePageOptions, + DuplicatePageOperationOptions, TextCount, LoadDatabasePrompts, TestDatabasePromptConfig, @@ -85,7 +85,7 @@ export interface EditorContextState { onRendered?: () => void; addPage?: (parentId: string, payload: CreatePagePayload) => Promise; deletePage?: (viewId: string) => Promise; - duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise; + duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise; openPageModal?: (viewId: string) => void; loadViews?: (variant?: UIVariant) => Promise; createDatabaseView?: (viewId: string, payload: CreateDatabaseViewPayload) => Promise; diff --git a/src/components/editor/components/blocks/link-preview/LinkPreview.tsx b/src/components/editor/components/blocks/link-preview/LinkPreview.tsx index aa96e4b0..654384ed 100644 --- a/src/components/editor/components/blocks/link-preview/LinkPreview.tsx +++ b/src/components/editor/components/blocks/link-preview/LinkPreview.tsx @@ -1,25 +1,27 @@ -import axios from 'axios'; -import { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react'; +import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Element } from 'slate'; import { useReadOnly, useSlateStatic } from 'slate-react'; import { YjsEditor } from '@/application/slate-yjs'; import { BlockType, LinkPreviewType } from '@/application/types'; import { ReactComponent as LinkIcon } from '@/assets/icons/link.svg'; -import emptyImageSrc from '@/assets/images/empty.png'; import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext'; import { EditorElementProps, LinkPreviewNode } from '@/components/editor/editor.type'; +import { buildFallbackLinkPreviewData, fetchLinkPreviewData, LinkPreviewData } from '@/utils/link-preview'; import { openUrl } from '@/utils/url'; +interface RemoteLinkPreviewData { + data: LinkPreviewData; + url: string; +} + export const LinkPreview = memo( forwardRef>(({ node, children, ...attributes }, ref) => { - const [data, setData] = useState<{ - image?: { url: string }; - title: string; - description: string; - } | null>(null); - const [notFound, setNotFound] = useState(false); + const [remotePreview, setRemotePreview] = useState(null); const url = node.data.url; + const fallbackData = useMemo(() => (url ? buildFallbackLinkPreviewData(url) : null), [url]); + const remoteData = remotePreview && remotePreview.url === url ? remotePreview.data : null; + const data = remoteData ?? fallbackData; const previewType = node.data.preview_type ?? LinkPreviewType.Bookmark; const isEmbed = previewType === LinkPreviewType.Embed; const editor = useSlateStatic() as YjsEditor; @@ -28,28 +30,31 @@ export const LinkPreview = memo( const { openPopover } = usePopoverContext(); useEffect(() => { - if (!url) return; + if (!url) { + setRemotePreview(null); + return; + } + + const controller = new AbortController(); - setData(null); + setRemotePreview(null); void (async () => { try { - setNotFound(false); - const response = await axios.get(`https://api.microlink.io/?url=${url}`); + const data = await fetchLinkPreviewData(url, controller.signal); - if (response.data.statusCode !== 200) { - setNotFound(true); - return; + if (!controller.signal.aborted) { + setRemotePreview({ url, data }); } - - const data = response.data.data; - - setData(data); } catch (_) { - setNotFound(true); + if (!controller.signal.aborted) { + setRemotePreview(null); + } } })(); + + return () => controller.abort(); }, [url]); - const imageUrl = data?.image?.url; + const imageUrl = data?.image?.url || data?.logo?.url; const handleClick = useCallback(() => { if (!url) { if (!readOnly && emptyRef.current) { @@ -81,28 +86,6 @@ export const LinkPreview = memo(
{isEmbed ? 'Paste a link to embed' : 'Paste a link to create a bookmark'}
- ) : notFound ? ( -
- {!isEmbed && ( -
- {'Empty -
- )} -
-
- The link cannot be previewed. Click to open in a new tab. -
-
{url}
-
-
) : ( <> {imageUrl && ( @@ -128,11 +111,15 @@ export const LinkPreview = memo( > {data?.title} -
- {data?.description} -
+ {data?.description && ( +
+ {data.description} +
+ )}
{url}
diff --git a/src/components/editor/components/leaf/mention/MentionExternalLink.tsx b/src/components/editor/components/leaf/mention/MentionExternalLink.tsx index 840a3cf5..8fd81581 100644 --- a/src/components/editor/components/leaf/mention/MentionExternalLink.tsx +++ b/src/components/editor/components/leaf/mention/MentionExternalLink.tsx @@ -1,22 +1,42 @@ -import axios from 'axios'; import React, { useEffect } from 'react'; +import { buildFallbackLinkPreviewData, fetchLinkPreviewData, LinkPreviewData } from '@/utils/link-preview'; + +interface RemoteLinkPreviewData { + data: LinkPreviewData; + url: string; +} + function MentionExternalLink ({ url, }: { url: string; }) { - const [data, setData] = React.useState<{ title?: string; logo?: string } | undefined>(undefined); + const fallbackData = React.useMemo(() => buildFallbackLinkPreviewData(url), [url]); + const [remotePreview, setRemotePreview] = React.useState(null); + const data = remotePreview && remotePreview.url === url ? remotePreview.data : fallbackData; useEffect(() => { - void axios.get(`https://api.microlink.io/?url=${url}`).then((data) => { - setData({ - title: data.data.data.title, - logo: data.data.data.logo.url, - }); - }, - ); + const controller = new AbortController(); + + setRemotePreview(null); + void fetchLinkPreviewData(url, controller.signal) + .then((data) => { + if (!controller.signal.aborted) { + setRemotePreview({ url, data }); + } + }) + .catch(() => { + if (!controller.signal.aborted) { + setRemotePreview(null); + } + }); + + return () => controller.abort(); }, [url]); + + const imageUrl = data.logo?.url || data.image?.url; + return ( { @@ -24,18 +44,18 @@ function MentionExternalLink ({ }} className={'cursor-pointer inline-flex gap-1.5 text-text-primary hover:underline'} > - {data?.logo && ( + {imageUrl && ( {data.logo} )} - {data?.title || url} + {data.title || url} ); } -export default MentionExternalLink; \ No newline at end of file +export default MentionExternalLink; diff --git a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx index 8d51bee1..c4d09b4d 100644 --- a/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx +++ b/src/components/editor/components/toolbar/block-controls/ControlsMenu.tsx @@ -2,8 +2,10 @@ import { Button, Divider } from '@mui/material'; import { PopoverProps } from '@mui/material/Popover'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { Path, Transforms } from 'slate'; import { ReactEditor, useSlateStatic } from 'slate-react'; +import { prefetchDatabaseBlobDiff } from '@/application/database-blob'; import { ViewService } from '@/application/services/domains'; import { getAxios, executeAPIRequest, APIResponse } from '@/application/services/js-services/http/core'; import { getView } from '@/application/services/js-services/http/view-api'; @@ -59,6 +61,22 @@ const popoverProps: Partial = { disableEnforceFocus: true, }; +function selectPathStartSafely(editor: YjsEditor, path: Path) { + try { + const point = editor.start(path); + + if (!ReactEditor.hasRange(editor, { anchor: point, focus: point })) { + Transforms.deselect(editor); + return; + } + + Transforms.select(editor, point); + ReactEditor.focus(editor); + } catch { + Transforms.deselect(editor); + } +} + function ControlsMenu({ open, onClose, @@ -191,6 +209,11 @@ function ControlsMenu({ includeChildren: true, suffix: duplicateCopySuffix, source: 0, + afterPreSync: async () => { + await prefetchDatabaseBlobDiff(workspaceId, databaseId, { + forceFullSync: true, + }); + }, }); let duplicatedContainerView; @@ -326,16 +349,16 @@ function ControlsMenu({ notify.success(t('button.duplicateSuccessfully')); } - ReactEditor.focus(editor); - const entry = findSlateEntryByBlockId(editor, newBlockIds[0]); - - if (!entry) { + if (hasDatabaseBlock) { + Transforms.deselect(editor); return; } - const [, path] = entry; + const entry = findSlateEntryByBlockId(editor, newBlockIds[0]); - editor.select(editor.start(path)); + if (entry) { + selectPathStartSafely(editor, entry[1]); + } }, [duplicateDatabaseBlock, editor, selectedBlockIds, t]); const options = useMemo(() => { @@ -387,8 +410,7 @@ function ControlsMenu({ if (path) { window.getSelection()?.removeAllRanges(); - ReactEditor.focus(editor); - editor.select(editor.start(path)); + selectPathStartSafely(editor, path); } onClose(); diff --git a/src/components/editor/editor.scss b/src/components/editor/editor.scss index f329f0b5..ac5c49cf 100644 --- a/src/components/editor/editor.scss +++ b/src/components/editor/editor.scss @@ -160,6 +160,10 @@ td[data-block-type="simple_table_cell"] { box-sizing: border-box; } + .link-preview-block { + container-type: inline-size; + } + .link-preview-card { overflow: hidden; } @@ -182,6 +186,7 @@ td[data-block-type="simple_table_cell"] { overflow: hidden; overflow-wrap: anywhere; text-overflow: ellipsis; + white-space: normal !important; word-break: break-word; } @@ -192,6 +197,27 @@ td[data-block-type="simple_table_cell"] { .link-preview-card-embed .link-preview-embed-image { max-height: 220px; } + + @container (max-width: 260px) { + .link-preview-card-bookmark { + align-items: stretch; + flex-direction: column; + } + + .link-preview-card-bookmark .link-preview-image, + .link-preview-card-bookmark .link-preview-empty-thumb { + aspect-ratio: 1.91 / 1; + flex: none; + height: auto; + max-height: 160px; + width: 100%; + } + + .link-preview-card-bookmark .link-preview-content { + flex: none; + justify-content: flex-start; + } + } } .text-element { diff --git a/src/utils/__tests__/link-preview.test.ts b/src/utils/__tests__/link-preview.test.ts new file mode 100644 index 00000000..c61edcec --- /dev/null +++ b/src/utils/__tests__/link-preview.test.ts @@ -0,0 +1,404 @@ +import axios from 'axios'; + +import { + buildFallbackLinkPreviewData, + clearLinkPreviewDataCache, + fetchLinkPreviewData, + getLinkPreviewProviders, + parseGitHubPreviewTarget, + registerLinkPreviewProvider, +} from '../link-preview'; + +jest.mock('axios'); + +const mockedAxios = axios as jest.Mocked; + +describe('link preview providers', () => { + const cleanupCallbacks: Array<() => void> = []; + + afterEach(() => { + cleanupCallbacks.splice(0).forEach((cleanup) => cleanup()); + clearLinkPreviewDataCache(); + jest.restoreAllMocks(); + jest.clearAllMocks(); + mockedAxios.isCancel.mockReturnValue(false); + }); + + it('builds a readable fallback for any URL', () => { + expect(buildFallbackLinkPreviewData('https://example.com/docs/getting-started?tab=web')).toEqual({ + title: 'example.com/docs/getting-started', + description: '', + }); + }); + + it('uses generic metadata when the universal provider succeeds', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + statusCode: 200, + data: { + title: 'Example title', + description: 'Example description', + image: { url: 'https://example.com/image.png' }, + logo: { url: 'https://example.com/logo.png' }, + }, + }, + }); + + await expect(fetchLinkPreviewData('https://example.com/article')).resolves.toEqual({ + title: 'Example title', + description: 'Example description', + image: { url: 'https://example.com/image.png' }, + logo: { url: 'https://example.com/logo.png' }, + }); + + expect(mockedAxios.get).toHaveBeenCalledWith('https://api.microlink.io/', { + params: { url: 'https://example.com/article' }, + signal: undefined, + timeout: 10000, + }); + }); + + it('falls through to the deterministic URL fallback when providers fail', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('metadata provider unavailable')); + + await expect(fetchLinkPreviewData('https://example.com/articles/123')).resolves.toEqual({ + title: 'example.com/articles/123', + description: '', + }); + }); + + it('dedupes concurrent requests for the same normalized URL', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + statusCode: 200, + data: { + title: 'Cached title', + description: 'Cached description', + }, + }, + }); + + const [first, second] = await Promise.all([ + fetchLinkPreviewData('https://example.com/cached'), + fetchLinkPreviewData('https://example.com/cached'), + ]); + + expect(first).toEqual({ + title: 'Cached title', + description: 'Cached description', + }); + expect(second).toEqual(first); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it('returns cached metadata for repeated requests', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + statusCode: 200, + data: { + title: 'Stored title', + description: 'Stored description', + }, + }, + }); + + await expect(fetchLinkPreviewData('https://example.com/stored')).resolves.toEqual({ + title: 'Stored title', + description: 'Stored description', + }); + await expect(fetchLinkPreviewData('https://example.com/stored')).resolves.toEqual({ + title: 'Stored title', + description: 'Stored description', + }); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + }); + + it('expires cached metadata after the cache ttl', async () => { + const nowSpy = jest.spyOn(Date, 'now'); + + nowSpy.mockReturnValue(1_000); + mockedAxios.get.mockResolvedValueOnce({ + data: { + statusCode: 200, + data: { + title: 'Initial title', + description: 'Initial description', + }, + }, + }); + + await expect(fetchLinkPreviewData('https://example.com/ttl')).resolves.toEqual({ + title: 'Initial title', + description: 'Initial description', + }); + + nowSpy.mockReturnValue(11 * 60 * 1_000); + mockedAxios.get.mockResolvedValueOnce({ + data: { + statusCode: 200, + data: { + title: 'Refetched title', + description: 'Refetched description', + }, + }, + }); + + await expect(fetchLinkPreviewData('https://example.com/ttl')).resolves.toEqual({ + title: 'Refetched title', + description: 'Refetched description', + }); + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + + nowSpy.mockRestore(); + }); + + it('evicts the least recently used metadata when the cache reaches its size limit', async () => { + let fetchCount = 0; + const cleanup = registerLinkPreviewProvider({ + id: 'bounded-cache-provider', + canHandle: ({ parsedUrl }) => parsedUrl?.hostname === 'cache.example', + async fetch({ parsedUrl }) { + fetchCount += 1; + return { + title: parsedUrl?.pathname || 'cache', + description: 'cache entry', + }; + }, + }); + + cleanupCallbacks.push(cleanup); + + for (let index = 0; index < 201; index += 1) { + await fetchLinkPreviewData(`https://cache.example/${index}`); + } + + expect(fetchCount).toBe(201); + await expect(fetchLinkPreviewData('https://cache.example/0')).resolves.toEqual({ + title: '/0', + description: 'cache entry', + }); + expect(fetchCount).toBe(202); + }); + + it('invalidates cached metadata when providers are registered', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + statusCode: 200, + data: { + title: 'Generic title', + description: 'Generic description', + }, + }, + }); + + await expect(fetchLinkPreviewData('https://example.com/provider-cache')).resolves.toEqual({ + title: 'Generic title', + description: 'Generic description', + }); + mockedAxios.get.mockClear(); + + const cleanup = registerLinkPreviewProvider({ + id: 'example-provider', + canHandle: ({ parsedUrl }) => parsedUrl?.hostname === 'example.com', + async fetch() { + return { + title: 'Provider title', + description: 'Provider description', + }; + }, + }); + + cleanupCallbacks.push(cleanup); + + await expect(fetchLinkPreviewData('https://example.com/provider-cache')).resolves.toEqual({ + title: 'Provider title', + description: 'Provider description', + }); + expect(mockedAxios.get).not.toHaveBeenCalled(); + }); + + it('uses provider order deterministically within the same priority group', async () => { + let resolveSlowProvider: ((data: { description: string; title: string }) => void) | undefined; + const slowProviderPromise = new Promise<{ description: string; title: string }>((resolve) => { + resolveSlowProvider = resolve; + }); + + const cleanupFast = registerLinkPreviewProvider({ + id: 'fast-provider', + priority: 25, + canHandle: ({ parsedUrl }) => parsedUrl?.hostname === 'priority.example', + async fetch() { + return { + title: 'Fast title', + description: 'Fast description', + }; + }, + }); + const cleanupSlow = registerLinkPreviewProvider({ + id: 'slow-provider', + priority: 25, + canHandle: ({ parsedUrl }) => parsedUrl?.hostname === 'priority.example', + fetch: () => slowProviderPromise, + }); + + cleanupCallbacks.push(cleanupSlow, cleanupFast); + + const request = fetchLinkPreviewData('https://priority.example/file'); + let settled = false; + + void request.then(() => { + settled = true; + }); + await Promise.resolve(); + expect(settled).toBe(false); + + resolveSlowProvider?.({ + title: 'Slow title', + description: 'Slow description', + }); + + await expect(request).resolves.toEqual({ + title: 'Slow title', + description: 'Slow description', + }); + }); + + it('can be extended with a custom provider without changing the preview component', async () => { + const cleanup = registerLinkPreviewProvider({ + id: 'figma', + canHandle: ({ parsedUrl }) => parsedUrl?.hostname === 'www.figma.com', + async fetch() { + return { + title: 'Figma file', + description: 'A design preview from a custom provider', + image: { url: 'https://figma.example/preview.png' }, + }; + }, + }); + + cleanupCallbacks.push(cleanup); + + await expect(fetchLinkPreviewData('https://www.figma.com/file/abc/AppFlowy')).resolves.toEqual({ + title: 'Figma file', + description: 'A design preview from a custom provider', + image: { url: 'https://figma.example/preview.png' }, + }); + expect(mockedAxios.get).not.toHaveBeenCalled(); + }); + + it('prefers universal metadata for GitHub URLs so page preview images are preserved', async () => { + mockedAxios.get.mockResolvedValueOnce({ + data: { + statusCode: 200, + data: { + title: 'Issue title from metadata', + description: 'Issue description from metadata', + image: { url: 'https://opengraph.githubassets.com/hash/AppFlowy-IO/AppFlowy-Web/issues/53' }, + }, + }, + }); + + await expect(fetchLinkPreviewData('https://github.com/AppFlowy-IO/AppFlowy-Web/issues/53')).resolves.toEqual({ + title: 'Issue title from metadata', + description: 'Issue description from metadata', + image: { url: 'https://opengraph.githubassets.com/hash/AppFlowy-IO/AppFlowy-Web/issues/53' }, + }); + expect(mockedAxios.get).toHaveBeenCalledTimes(1); + expect(mockedAxios.get).toHaveBeenCalledWith('https://api.microlink.io/', { + params: { url: 'https://github.com/AppFlowy-IO/AppFlowy-Web/issues/53' }, + signal: undefined, + timeout: 10000, + }); + }); + + it('falls back to the GitHub API when universal metadata is unavailable', async () => { + mockedAxios.get.mockRejectedValueOnce(new Error('metadata provider unavailable')); + mockedAxios.get.mockResolvedValueOnce({ + data: { + body: 'Issue body', + html_url: 'https://github.com/AppFlowy-IO/AppFlowy-Web/issues/53', + number: 53, + title: 'Issue title', + user: { + avatar_url: 'https://avatars.githubusercontent.com/u/1?v=4', + }, + }, + }); + + await expect(fetchLinkPreviewData('https://github.com/AppFlowy-IO/AppFlowy-Web/issues/53')).resolves.toEqual({ + title: 'Issue title - AppFlowy-IO/AppFlowy-Web#53', + description: 'Issue body', + image: { url: 'https://avatars.githubusercontent.com/u/1?v=4' }, + }); + expect(mockedAxios.get).toHaveBeenCalledTimes(2); + expect(mockedAxios.get).toHaveBeenLastCalledWith( + 'https://api.github.com/repos/AppFlowy-IO/AppFlowy-Web/issues/53', + { + headers: { + Accept: 'application/vnd.github+json', + }, + signal: undefined, + timeout: 10000, + } + ); + }); + + it('keeps default providers available after custom providers', () => { + const cleanup = registerLinkPreviewProvider({ + id: 'custom-noop', + canHandle: () => false, + async fetch() { + return undefined; + }, + }); + + cleanupCallbacks.push(cleanup); + + expect(getLinkPreviewProviders().map((provider) => provider.id)).toEqual([ + 'custom-noop', + 'microlink', + 'github', + 'url-fallback', + ]); + }); + + it('supports priority placement for custom providers', () => { + const cleanup = registerLinkPreviewProvider({ + id: 'late-custom', + priority: 150, + canHandle: () => false, + async fetch() { + return undefined; + }, + }); + + cleanupCallbacks.push(cleanup); + + expect(getLinkPreviewProviders().map((provider) => provider.id)).toEqual([ + 'microlink', + 'github', + 'late-custom', + 'url-fallback', + ]); + }); + + it('parses GitHub repository, issue, and pull request URLs for provider-specific fallback', () => { + expect(parseGitHubPreviewTarget('https://github.com/AppFlowy-IO/AppFlowy-Web')).toEqual({ + owner: 'AppFlowy-IO', + repo: 'AppFlowy-Web', + kind: 'repo', + }); + expect(parseGitHubPreviewTarget('https://github.com/AppFlowy-IO/AppFlowy-Web/issues/53')).toEqual({ + owner: 'AppFlowy-IO', + repo: 'AppFlowy-Web', + kind: 'issue', + number: '53', + }); + expect(parseGitHubPreviewTarget('https://github.com/AppFlowy-IO/AppFlowy-Web/pull/100')).toEqual({ + owner: 'AppFlowy-IO', + repo: 'AppFlowy-Web', + kind: 'pull', + number: '100', + }); + }); +}); diff --git a/src/utils/link-preview.ts b/src/utils/link-preview.ts new file mode 100644 index 00000000..af88385d --- /dev/null +++ b/src/utils/link-preview.ts @@ -0,0 +1,546 @@ +import axios from 'axios'; + +import { processUrl } from '@/utils/url'; + +const LINK_PREVIEW_REQUEST_TIMEOUT = 10_000; +const DESCRIPTION_MAX_LENGTH = 240; +const LINK_PREVIEW_CACHE_TTL = 10 * 60 * 1000; +const LINK_PREVIEW_CACHE_MAX_ENTRIES = 200; +const DEFAULT_CUSTOM_PROVIDER_PRIORITY = 50; +const UNIVERSAL_PROVIDER_PRIORITY = 100; +const DOMAIN_PROVIDER_PRIORITY = 110; +const FALLBACK_PROVIDER_PRIORITY = 1000; + +export interface LinkPreviewImageData { + url: string; +} + +export interface LinkPreviewData { + image?: LinkPreviewImageData; + logo?: LinkPreviewImageData; + title: string; + description: string; +} + +export interface LinkPreviewProviderContext { + fallbackData: LinkPreviewData; + normalizedUrl: string; + parsedUrl?: URL; + signal?: AbortSignal; +} + +export interface LinkPreviewProvider { + id: string; + priority?: number; + canHandle: (context: LinkPreviewProviderContext) => boolean; + fetch: (context: LinkPreviewProviderContext) => Promise; +} + +interface MicrolinkResponse { + statusCode?: number; + data?: { + image?: LinkPreviewImageData | null; + logo?: LinkPreviewImageData | null; + title?: string | null; + description?: string | null; + }; +} + +interface GitHubIssueResponse { + body?: string | null; + html_url?: string; + number?: number; + pull_request?: unknown; + title?: string; + user?: { + avatar_url?: string; + } | null; +} + +interface GitHubRepositoryResponse { + description?: string | null; + full_name?: string; + html_url?: string; + owner?: { + avatar_url?: string; + } | null; +} + +interface GitHubPreviewTarget { + owner: string; + repo: string; + kind: 'issue' | 'pull' | 'repo'; + number?: string; +} + +interface LinkPreviewCacheEntry { + data: LinkPreviewData; + expiresAt: number; +} + +const customLinkPreviewProviders: LinkPreviewProvider[] = []; +const linkPreviewDataCache = new Map(); +const inFlightLinkPreviewRequests = new Map>(); +let providerRegistryVersion = 0; + +const microlinkProvider: LinkPreviewProvider = { + id: 'microlink', + priority: UNIVERSAL_PROVIDER_PRIORITY, + canHandle: () => true, + async fetch(context) { + const response = await axios.get('https://api.microlink.io/', { + params: { url: context.normalizedUrl }, + signal: context.signal, + timeout: LINK_PREVIEW_REQUEST_TIMEOUT, + }); + + const payload = response.data; + + if (!payload.data || (payload.statusCode !== undefined && payload.statusCode >= 400)) { + return undefined; + } + + return normalizePreviewData(payload.data, context.fallbackData); + }, +}; + +const githubProvider: LinkPreviewProvider = { + id: 'github', + priority: DOMAIN_PROVIDER_PRIORITY, + canHandle: (context) => Boolean(parseGitHubPreviewTarget(context)), + async fetch(context) { + const target = parseGitHubPreviewTarget(context); + + if (!target) return undefined; + + const requestConfig = { + headers: { + Accept: 'application/vnd.github+json', + }, + signal: context.signal, + timeout: LINK_PREVIEW_REQUEST_TIMEOUT, + }; + + if ((target.kind === 'issue' || target.kind === 'pull') && target.number) { + const response = await axios.get( + `https://api.github.com/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}/issues/${ + target.number + }`, + requestConfig + ); + const issue = response.data; + const type = issue.pull_request ? 'Pull request' : 'Issue'; + const title = issue.title + ? `${issue.title} - ${target.owner}/${target.repo}#${target.number}` + : `${type} #${target.number} - ${target.owner}/${target.repo}`; + const description = truncateDescription(cleanupGitHubText(issue.body) || issue.html_url || ''); + + return { + title, + description, + ...(issue.user?.avatar_url ? { image: { url: issue.user.avatar_url } } : {}), + }; + } + + const response = await axios.get( + `https://api.github.com/repos/${encodeURIComponent(target.owner)}/${encodeURIComponent(target.repo)}`, + requestConfig + ); + const repository = response.data; + + return { + title: repository.full_name || `${target.owner}/${target.repo}`, + description: truncateDescription(repository.description || repository.html_url || ''), + ...(repository.owner?.avatar_url ? { image: { url: repository.owner.avatar_url } } : {}), + }; + }, +}; + +const fallbackProvider: LinkPreviewProvider = { + id: 'url-fallback', + priority: FALLBACK_PROVIDER_PRIORITY, + canHandle: () => true, + async fetch(context) { + return context.fallbackData; + }, +}; + +const defaultLinkPreviewProviders: LinkPreviewProvider[] = [microlinkProvider, githubProvider, fallbackProvider]; + +export function registerLinkPreviewProvider(provider: LinkPreviewProvider): () => void { + const existingIndex = customLinkPreviewProviders.findIndex((item) => item.id === provider.id); + + if (existingIndex >= 0) { + customLinkPreviewProviders.splice(existingIndex, 1, provider); + } else { + customLinkPreviewProviders.unshift(provider); + } + + invalidateLinkPreviewCache(); + + return () => { + const currentIndex = customLinkPreviewProviders.findIndex((item) => item.id === provider.id); + + if (currentIndex >= 0 && customLinkPreviewProviders[currentIndex] === provider) { + customLinkPreviewProviders.splice(currentIndex, 1); + invalidateLinkPreviewCache(); + } + }; +} + +export function getLinkPreviewProviders(): LinkPreviewProvider[] { + return [ + ...customLinkPreviewProviders.map((provider, index) => ({ + provider, + priority: provider.priority ?? DEFAULT_CUSTOM_PROVIDER_PRIORITY, + index, + })), + ...defaultLinkPreviewProviders.map((provider, index) => ({ + provider, + priority: provider.priority ?? FALLBACK_PROVIDER_PRIORITY, + index: customLinkPreviewProviders.length + index, + })), + ] + .sort((a, b) => a.priority - b.priority || a.index - b.index) + .map(({ provider }) => provider); +} + +export function clearLinkPreviewDataCache() { + linkPreviewDataCache.clear(); + inFlightLinkPreviewRequests.clear(); +} + +function invalidateLinkPreviewCache() { + providerRegistryVersion += 1; + clearLinkPreviewDataCache(); +} + +export function buildFallbackLinkPreviewData(url: string): LinkPreviewData { + const normalizedUrl = processUrl(url) || url; + + try { + const parsed = new URL(normalizedUrl); + const host = parsed.hostname.replace(/^www\./, ''); + const path = parsed.pathname + .split('/') + .filter(Boolean) + .map(safeDecodeURIComponent) + .join('/'); + + return { + title: path ? `${host}/${path}` : host || normalizedUrl, + description: '', + }; + } catch { + return { + title: url, + description: '', + }; + } +} + +export async function fetchLinkPreviewData(url: string, signal?: AbortSignal): Promise { + const normalizedUrl = processUrl(url) || url; + const cacheKey = getLinkPreviewCacheKey(normalizedUrl); + const cachedData = getCachedLinkPreviewData(cacheKey); + + if (cachedData) return cachedData; + + let request = inFlightLinkPreviewRequests.get(cacheKey); + + if (!request) { + const registryVersion = providerRegistryVersion; + + request = fetchLinkPreviewDataFromProviders(normalizedUrl) + .then((data) => { + if (registryVersion === providerRegistryVersion) { + setCachedLinkPreviewData(cacheKey, data); + } + + return data; + }) + .finally(() => { + inFlightLinkPreviewRequests.delete(cacheKey); + }); + inFlightLinkPreviewRequests.set(cacheKey, request); + } + + return signal ? raceWithAbortSignal(request, signal) : request; +} + +async function fetchLinkPreviewDataFromProviders(normalizedUrl: string): Promise { + const context: LinkPreviewProviderContext = { + normalizedUrl, + fallbackData: buildFallbackLinkPreviewData(normalizedUrl), + parsedUrl: parseUrl(normalizedUrl), + }; + + for (const providers of getProviderGroups(context)) { + const data = await fetchFirstSuccessfulProviderData(providers, context); + + if (data) return data; + } + + return context.fallbackData; +} + +function getProviderGroups(context: LinkPreviewProviderContext): LinkPreviewProvider[][] { + const groups: LinkPreviewProvider[][] = []; + let currentPriority: number | undefined; + + for (const provider of getLinkPreviewProviders()) { + if (!provider.canHandle(context)) continue; + + const priority = provider.priority ?? FALLBACK_PROVIDER_PRIORITY; + + if (currentPriority !== priority) { + groups.push([]); + currentPriority = priority; + } + + groups[groups.length - 1].push(provider); + } + + return groups; +} + +function getLinkPreviewCacheKey(normalizedUrl: string): string { + return `${providerRegistryVersion}:${normalizedUrl}`; +} + +function getCachedLinkPreviewData(cacheKey: string): LinkPreviewData | undefined { + const entry = linkPreviewDataCache.get(cacheKey); + + if (!entry) return undefined; + + if (entry.expiresAt <= Date.now()) { + linkPreviewDataCache.delete(cacheKey); + return undefined; + } + + linkPreviewDataCache.delete(cacheKey); + linkPreviewDataCache.set(cacheKey, entry); + return entry.data; +} + +function setCachedLinkPreviewData(cacheKey: string, data: LinkPreviewData) { + if (linkPreviewDataCache.has(cacheKey)) { + linkPreviewDataCache.delete(cacheKey); + } + + linkPreviewDataCache.set(cacheKey, { + data, + expiresAt: Date.now() + LINK_PREVIEW_CACHE_TTL, + }); + + while (linkPreviewDataCache.size > LINK_PREVIEW_CACHE_MAX_ENTRIES) { + const oldestKey = linkPreviewDataCache.keys().next().value; + + if (!oldestKey) break; + + linkPreviewDataCache.delete(oldestKey); + } +} + +function fetchFirstSuccessfulProviderData( + providers: LinkPreviewProvider[], + context: LinkPreviewProviderContext +): Promise { + if (providers.length === 0) return Promise.resolve(undefined); + + return new Promise((resolve, reject) => { + let settled = false; + const results: Array< + | { + data?: LinkPreviewData; + status: 'fulfilled'; + } + | { + error: unknown; + status: 'rejected'; + } + | undefined + > = []; + + const resolveIfReady = () => { + if (settled) return; + + for (let index = 0; index < providers.length; index += 1) { + const result = results[index]; + + if (!result) return; + + if (result.status === 'fulfilled' && result.data) { + settled = true; + resolve(result.data); + return; + } + } + + const abortError = results.find((result) => result?.status === 'rejected' && isAbortError(result.error, context.signal)); + + settled = true; + if (abortError?.status === 'rejected') { + reject(abortError.error); + } else { + resolve(undefined); + } + }; + + providers.forEach((provider, index) => { + void provider + .fetch(context) + .then((data) => { + if (settled) return; + + results[index] = { + data, + status: 'fulfilled', + }; + resolveIfReady(); + }) + .catch((error) => { + if (settled) return; + + results[index] = { + error, + status: 'rejected', + }; + resolveIfReady(); + }); + }); + }); +} + +export function parseGitHubPreviewTarget( + contextOrUrl: LinkPreviewProviderContext | string +): GitHubPreviewTarget | undefined { + const parsed = + typeof contextOrUrl === 'string' ? parseUrl(processUrl(contextOrUrl) || contextOrUrl) : contextOrUrl.parsedUrl; + + if (!parsed) return undefined; + if (!['github.com', 'www.github.com'].includes(parsed.hostname.toLowerCase())) return undefined; + + const [owner, rawRepo, resource, number] = parsed.pathname.split('/').filter(Boolean); + + if (!owner || !rawRepo) return undefined; + + const repo = rawRepo.replace(/\.git$/, ''); + + if ((resource === 'issues' || resource === 'pull') && number && /^\d+$/.test(number)) { + return { + owner, + repo, + kind: resource === 'pull' ? 'pull' : 'issue', + number, + }; + } + + if (!resource) { + return { + owner, + repo, + kind: 'repo', + }; + } + + return undefined; +} + +function normalizePreviewData( + data: NonNullable, + fallbackData: LinkPreviewData +): LinkPreviewData { + return { + title: normalizeString(data.title) || fallbackData.title, + description: truncateDescription(normalizeString(data.description) || fallbackData.description), + ...(normalizeString(data.image?.url) ? { image: { url: normalizeString(data.image?.url) } } : {}), + ...(normalizeString(data.logo?.url) ? { logo: { url: normalizeString(data.logo?.url) } } : {}), + }; +} + +function cleanupGitHubText(value?: string | null): string { + return normalizeString(value) + .replace(/```[\s\S]*?```/g, '') + .replace(/`([^`]+)`/g, '$1') + .replace(/!\[[^\]]*]\([^)]*\)/g, '') + .replace(/\[([^\]]+)]\([^)]*\)/g, '$1') + .replace(/[#>*_~]/g, '') + .replace(/\s+/g, ' ') + .trim(); +} + +function truncateDescription(value: string): string { + if (value.length <= DESCRIPTION_MAX_LENGTH) return value; + + return `${value.slice(0, DESCRIPTION_MAX_LENGTH - 1).trimEnd()}...`; +} + +function normalizeString(value?: string | null): string { + return typeof value === 'string' ? value.trim() : ''; +} + +function safeDecodeURIComponent(value: string): string { + try { + return decodeURIComponent(value); + } catch { + return value; + } +} + +function parseUrl(url: string): URL | undefined { + try { + return new URL(url); + } catch { + return undefined; + } +} + +function raceWithAbortSignal(promise: Promise, signal: AbortSignal): Promise { + if (signal.aborted) return Promise.reject(createAbortError()); + + return new Promise((resolve, reject) => { + const handleAbort = () => { + reject(createAbortError()); + }; + + const cleanup = () => { + signal.removeEventListener('abort', handleAbort); + }; + + signal.addEventListener('abort', handleAbort, { once: true }); + promise.then( + (value) => { + cleanup(); + resolve(value); + }, + (error) => { + cleanup(); + reject(error); + } + ); + }); +} + +function createAbortError(): Error { + if (typeof DOMException !== 'undefined') { + return new DOMException('Aborted', 'AbortError'); + } + + const error = new Error('Aborted'); + + error.name = 'AbortError'; + return error; +} + +function isAbortError(error: unknown, signal?: AbortSignal): boolean { + if (signal?.aborted || axios.isCancel(error)) return true; + if (error instanceof DOMException && error.name === 'AbortError') return true; + + return ( + typeof error === 'object' && + error !== null && + 'code' in error && + (error as { code?: unknown }).code === 'ERR_CANCELED' + ); +}