Skip to content

Commit f46e010

Browse files
committed
Fix resilient link preview providers
1 parent 53a2b9d commit f46e010

4 files changed

Lines changed: 961 additions & 63 deletions

File tree

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)