Skip to content

Commit f42258c

Browse files
authored
Fix resilient link preview providers (#361)
* Fix resilient link preview providers * Fix inline database duplicate stability * Match desktop link previews in table cells
1 parent 53a2b9d commit f42258c

11 files changed

Lines changed: 1111 additions & 82 deletions

File tree

playwright/e2e/page/simple-table.spec.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -281,6 +281,18 @@ test.describe('SimpleTable', () => {
281281
expect(metrics.cardWidth).toBeLessThanOrEqual(metrics.cellContentWidth + 1);
282282
expect(metrics.cellWidth).toBeLessThanOrEqual(initialCellWidth + 1);
283283

284+
const layout = await card.evaluate((cardEl) => {
285+
const title = cardEl.querySelector('.link-preview-title');
286+
287+
return {
288+
flexDirection: getComputedStyle(cardEl).flexDirection,
289+
titleWhiteSpace: title ? getComputedStyle(title).whiteSpace : '',
290+
};
291+
});
292+
293+
expect(layout.flexDirection).toBe('column');
294+
expect(layout.titleWhiteSpace).toBe('normal');
295+
284296
const urlCell = getCell(page, 0, 1);
285297
const plainUrl = 'https://appflowy.io/simple-table-url-layout';
286298

src/application/types.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1402,7 +1402,7 @@ export interface ViewComponentProps {
14021402
updatePage?: (viewId: string, data: UpdatePagePayload) => Promise<void>;
14031403
addPage?: (parentId: string, payload: CreatePagePayload) => Promise<CreatePageResponse>;
14041404
deletePage?: (viewId: string) => Promise<void>;
1405-
duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise<void>;
1405+
duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise<void>;
14061406
openPageModal?: (viewId: string) => void;
14071407
variant?: UIVariant;
14081408
isTemplateThumb?: boolean;
@@ -1452,6 +1452,14 @@ export interface DuplicatePageOptions {
14521452
source?: number;
14531453
}
14541454

1455+
export interface DuplicatePageOperationOptions extends DuplicatePageOptions {
1456+
/**
1457+
* Client-only lifecycle hook. Runs after the pre-duplicate collab sync and
1458+
* before the duplicate API request; it is not sent to the server.
1459+
*/
1460+
afterPreSync?: () => Promise<void>;
1461+
}
1462+
14551463
export interface CreateDatabaseViewPayload {
14561464
parent_view_id: string;
14571465
/** Insert the new database view after this sibling. When omitted the backend prepends. */

src/components/app/contexts/AppOperationsContext.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { SyncContext } from '@/application/services/js-services/sync-protocol';
55
import {
66
CreateDatabaseViewPayload,
77
CreateDatabaseViewResponse,
8-
DuplicatePageOptions,
8+
DuplicatePageOperationOptions,
99
CreatePagePayload,
1010
CreatePageResponse,
1111
CreateRow,
@@ -69,7 +69,7 @@ export interface AppOperationsContextType {
6969
/** Soft-delete a page (move to trash). */
7070
deletePage?: (viewId: string) => Promise<void>;
7171
/** Duplicate a page, optionally refreshing its parent children in the outline. */
72-
duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise<void>;
72+
duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise<void>;
7373
/** Update page properties (name, cover, etc.). */
7474
updatePage?: (viewId: string, payload: UpdatePagePayload) => Promise<void>;
7575
/** Update just the page icon. */

src/components/app/hooks/usePageOperations.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
} from '@/application/services/js-services/http/publish-api';
1212
import {
1313
CreateDatabaseViewPayload,
14-
DuplicatePageOptions,
14+
DuplicatePageOperationOptions,
1515
CreatePagePayload,
1616
CreateSpacePayload,
1717
Role,
@@ -154,11 +154,13 @@ export function usePageOperations({
154154
);
155155

156156
const duplicatePage = useCallback(
157-
async (viewId: string, options: DuplicatePageOptions = {}) => {
157+
async (viewId: string, options: DuplicatePageOperationOptions = {}) => {
158158
if (!currentWorkspaceId) {
159159
throw new Error('No workspace or service found');
160160
}
161161

162+
const { afterPreSync, ...duplicateOptions } = options;
163+
162164
try {
163165
// Sync all collab documents to the server via HTTP API before duplicating.
164166
// This ensures the server has the latest data (including unregistered row
@@ -174,12 +176,14 @@ export function usePageOperations({
174176
await flushAllSync?.();
175177
}
176178

177-
await PageService.duplicate(currentWorkspaceId, viewId, options);
179+
await afterPreSync?.();
180+
181+
await PageService.duplicate(currentWorkspaceId, viewId, duplicateOptions);
178182
await loadOutline?.(currentWorkspaceId, false);
179183

180-
if (options.parentViewId) {
181-
ViewService.invalidateCache(currentWorkspaceId, options.parentViewId);
182-
await loadViewChildren?.(options.parentViewId);
184+
if (duplicateOptions.parentViewId) {
185+
ViewService.invalidateCache(currentWorkspaceId, duplicateOptions.parentViewId);
186+
await loadViewChildren?.(duplicateOptions.parentViewId);
183187
}
184188
} catch (e) {
185189
return Promise.reject(e);

src/components/editor/EditorContext.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import {
1717
CreatePageResponse,
1818
CreateDatabaseViewPayload,
1919
CreateDatabaseViewResponse,
20-
DuplicatePageOptions,
20+
DuplicatePageOperationOptions,
2121
TextCount,
2222
LoadDatabasePrompts,
2323
TestDatabasePromptConfig,
@@ -85,7 +85,7 @@ export interface EditorContextState {
8585
onRendered?: () => void;
8686
addPage?: (parentId: string, payload: CreatePagePayload) => Promise<CreatePageResponse>;
8787
deletePage?: (viewId: string) => Promise<void>;
88-
duplicatePage?: (viewId: string, options?: DuplicatePageOptions) => Promise<void>;
88+
duplicatePage?: (viewId: string, options?: DuplicatePageOperationOptions) => Promise<void>;
8989
openPageModal?: (viewId: string) => void;
9090
loadViews?: (variant?: UIVariant) => Promise<View[] | undefined>;
9191
createDatabaseView?: (viewId: string, payload: CreateDatabaseViewPayload) => Promise<CreateDatabaseViewResponse>;

src/components/editor/components/blocks/link-preview/LinkPreview.tsx

Lines changed: 36 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,27 @@
1-
import axios from 'axios';
2-
import { forwardRef, memo, useCallback, useEffect, useRef, useState } from 'react';
1+
import { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
32
import { Element } from 'slate';
43
import { useReadOnly, useSlateStatic } from 'slate-react';
54

65
import { YjsEditor } from '@/application/slate-yjs';
76
import { BlockType, LinkPreviewType } from '@/application/types';
87
import { ReactComponent as LinkIcon } from '@/assets/icons/link.svg';
9-
import emptyImageSrc from '@/assets/images/empty.png';
108
import { usePopoverContext } from '@/components/editor/components/block-popover/BlockPopoverContext';
119
import { EditorElementProps, LinkPreviewNode } from '@/components/editor/editor.type';
10+
import { buildFallbackLinkPreviewData, fetchLinkPreviewData, LinkPreviewData } from '@/utils/link-preview';
1211
import { openUrl } from '@/utils/url';
1312

13+
interface RemoteLinkPreviewData {
14+
data: LinkPreviewData;
15+
url: string;
16+
}
17+
1418
export const LinkPreview = memo(
1519
forwardRef<HTMLDivElement, EditorElementProps<LinkPreviewNode>>(({ node, children, ...attributes }, ref) => {
16-
const [data, setData] = useState<{
17-
image?: { url: string };
18-
title: string;
19-
description: string;
20-
} | null>(null);
21-
const [notFound, setNotFound] = useState<boolean>(false);
20+
const [remotePreview, setRemotePreview] = useState<RemoteLinkPreviewData | null>(null);
2221
const url = node.data.url;
22+
const fallbackData = useMemo(() => (url ? buildFallbackLinkPreviewData(url) : null), [url]);
23+
const remoteData = remotePreview && remotePreview.url === url ? remotePreview.data : null;
24+
const data = remoteData ?? fallbackData;
2325
const previewType = node.data.preview_type ?? LinkPreviewType.Bookmark;
2426
const isEmbed = previewType === LinkPreviewType.Embed;
2527
const editor = useSlateStatic() as YjsEditor;
@@ -28,28 +30,31 @@ export const LinkPreview = memo(
2830
const { openPopover } = usePopoverContext();
2931

3032
useEffect(() => {
31-
if (!url) return;
33+
if (!url) {
34+
setRemotePreview(null);
35+
return;
36+
}
37+
38+
const controller = new AbortController();
3239

33-
setData(null);
40+
setRemotePreview(null);
3441
void (async () => {
3542
try {
36-
setNotFound(false);
37-
const response = await axios.get(`https://api.microlink.io/?url=${url}`);
43+
const data = await fetchLinkPreviewData(url, controller.signal);
3844

39-
if (response.data.statusCode !== 200) {
40-
setNotFound(true);
41-
return;
45+
if (!controller.signal.aborted) {
46+
setRemotePreview({ url, data });
4247
}
43-
44-
const data = response.data.data;
45-
46-
setData(data);
4748
} catch (_) {
48-
setNotFound(true);
49+
if (!controller.signal.aborted) {
50+
setRemotePreview(null);
51+
}
4952
}
5053
})();
54+
55+
return () => controller.abort();
5156
}, [url]);
52-
const imageUrl = data?.image?.url;
57+
const imageUrl = data?.image?.url || data?.logo?.url;
5358
const handleClick = useCallback(() => {
5459
if (!url) {
5560
if (!readOnly && emptyRef.current) {
@@ -81,28 +86,6 @@ export const LinkPreview = memo(
8186
<LinkIcon className={'h-6 w-6 flex-none'} />
8287
<div className={'truncate'}>{isEmbed ? 'Paste a link to embed' : 'Paste a link to create a bookmark'}</div>
8388
</div>
84-
) : notFound ? (
85-
<div className={`link-preview-not-found flex w-full min-w-0 ${isEmbed ? 'flex-col' : 'items-center'}`}>
86-
{!isEmbed && (
87-
<div
88-
className={
89-
'link-preview-empty-thumb mr-2 flex h-[80px] w-[120px] min-w-[80px] items-center justify-center rounded border text-text-primary'
90-
}
91-
>
92-
<img
93-
src={emptyImageSrc}
94-
alt={'Empty state'}
95-
className={'link-preview-empty-image h-full object-cover object-center'}
96-
/>
97-
</div>
98-
)}
99-
<div className={`link-preview-content flex min-w-0 flex-1 flex-col ${isEmbed ? 'p-4' : ''}`}>
100-
<div className={'link-preview-title text-function-error'}>
101-
The link cannot be previewed. Click to open in a new tab.
102-
</div>
103-
<div className={'link-preview-url text-sm text-text-secondary'}>{url}</div>
104-
</div>
105-
</div>
10689
) : (
10790
<>
10891
{imageUrl && (
@@ -128,11 +111,15 @@ export const LinkPreview = memo(
128111
>
129112
{data?.title}
130113
</div>
131-
<div
132-
className={'link-preview-description max-h-[64px] overflow-hidden truncate text-sm text-text-primary'}
133-
>
134-
{data?.description}
135-
</div>
114+
{data?.description && (
115+
<div
116+
className={
117+
'link-preview-description max-h-[64px] overflow-hidden truncate text-sm text-text-primary'
118+
}
119+
>
120+
{data.description}
121+
</div>
122+
)}
136123
<div className={'link-preview-url truncate whitespace-nowrap text-xs text-text-secondary'}>{url}</div>
137124
</div>
138125
</>
Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,61 @@
1-
import axios from 'axios';
21
import React, { useEffect } from 'react';
32

3+
import { buildFallbackLinkPreviewData, fetchLinkPreviewData, LinkPreviewData } from '@/utils/link-preview';
4+
5+
interface RemoteLinkPreviewData {
6+
data: LinkPreviewData;
7+
url: string;
8+
}
9+
410
function MentionExternalLink ({
511
url,
612
}: {
713
url: string;
814
}) {
9-
const [data, setData] = React.useState<{ title?: string; logo?: string } | undefined>(undefined);
15+
const fallbackData = React.useMemo(() => buildFallbackLinkPreviewData(url), [url]);
16+
const [remotePreview, setRemotePreview] = React.useState<RemoteLinkPreviewData | null>(null);
17+
const data = remotePreview && remotePreview.url === url ? remotePreview.data : fallbackData;
1018

1119
useEffect(() => {
12-
void axios.get(`https://api.microlink.io/?url=${url}`).then((data) => {
13-
setData({
14-
title: data.data.data.title,
15-
logo: data.data.data.logo.url,
16-
});
17-
},
18-
);
20+
const controller = new AbortController();
21+
22+
setRemotePreview(null);
23+
void fetchLinkPreviewData(url, controller.signal)
24+
.then((data) => {
25+
if (!controller.signal.aborted) {
26+
setRemotePreview({ url, data });
27+
}
28+
})
29+
.catch(() => {
30+
if (!controller.signal.aborted) {
31+
setRemotePreview(null);
32+
}
33+
});
34+
35+
return () => controller.abort();
1936
}, [url]);
37+
38+
const imageUrl = data.logo?.url || data.image?.url;
39+
2040
return (
2141
<span
2242
onClick={() => {
2343
window.open(url, '_blank');
2444
}}
2545
className={'cursor-pointer inline-flex gap-1.5 text-text-primary hover:underline'}
2646
>
27-
{data?.logo && (
47+
{imageUrl && (
2848
<span className={'mt-0.5'}>
2949
<img
3050
className={'object-cover w-5 h-5'}
31-
src={data.logo}
32-
alt={data.logo}
51+
src={imageUrl}
52+
alt={data.title}
3353
/>
3454
</span>
3555
)}
36-
<span className={'leading-[24px]'}>{data?.title || url}</span>
56+
<span className={'leading-[24px]'}>{data.title || url}</span>
3757
</span>
3858
);
3959
}
4060

41-
export default MentionExternalLink;
61+
export default MentionExternalLink;

0 commit comments

Comments
 (0)