mirror of
https://github.com/aljazceru/rabbit.git
synced 2025-12-17 22:14:26 +01:00
feat: Link preview (#73)
This commit is contained in:
@@ -88,6 +88,7 @@ module.exports = {
|
|||||||
'notification-icon',
|
'notification-icon',
|
||||||
'notification-user',
|
'notification-user',
|
||||||
'notification-event',
|
'notification-event',
|
||||||
|
'twitter-tweet',
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -28,5 +28,6 @@
|
|||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|
||||||
<script src="/src/index.tsx" type="module"></script>
|
<script src="/src/index.tsx" type="module"></script>
|
||||||
|
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import MentionedUserDisplay from '@/components/event/textNote/MentionedUserDispl
|
|||||||
import VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
import VideoDisplay from '@/components/event/textNote/VideoDisplay';
|
||||||
import EventLink from '@/components/EventLink';
|
import EventLink from '@/components/EventLink';
|
||||||
import SafeLink from '@/components/utils/SafeLink';
|
import SafeLink from '@/components/utils/SafeLink';
|
||||||
|
import PreviewedLink from '@/components/utils/PreviewedLink';
|
||||||
import { createSearchColumn } from '@/core/column';
|
import { createSearchColumn } from '@/core/column';
|
||||||
import useConfig from '@/core/useConfig';
|
import useConfig from '@/core/useConfig';
|
||||||
import { useRequestCommand } from '@/hooks/useCommandBus';
|
import { useRequestCommand } from '@/hooks/useCommandBus';
|
||||||
@@ -51,7 +52,7 @@ const TextNoteContentDisplay = (props: TextNoteContentDisplayProps) => {
|
|||||||
if (isVideoUrl(item.content)) {
|
if (isVideoUrl(item.content)) {
|
||||||
return <VideoDisplay url={item.content} initialHidden={initialHidden()} />;
|
return <VideoDisplay url={item.content} initialHidden={initialHidden()} />;
|
||||||
}
|
}
|
||||||
return <SafeLink class="text-blue-500 underline" href={item.content} />;
|
return <PreviewedLink class="text-blue-500 underline" href={item.content} />;
|
||||||
}
|
}
|
||||||
if (item.type === 'TagReference') {
|
if (item.type === 'TagReference') {
|
||||||
const resolved = event().resolveTagReference(item);
|
const resolved = event().resolveTagReference(item);
|
||||||
|
|||||||
150
src/components/utils/PreviewedLink.tsx
Normal file
150
src/components/utils/PreviewedLink.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import { Component, Show, JSX, createSignal, Switch, Match, createEffect, onMount } from 'solid-js';
|
||||||
|
|
||||||
|
import SafeLink from '@/components/utils/SafeLink';
|
||||||
|
import { isTwitterUrl, isYoutubeVideoUrl } from '@/utils/url';
|
||||||
|
|
||||||
|
type SafeLinkProps = {
|
||||||
|
class?: string;
|
||||||
|
href: string;
|
||||||
|
children?: JSX.Element;
|
||||||
|
};
|
||||||
|
|
||||||
|
type OgpContents = {
|
||||||
|
url: URL | null;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
image: URL | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const twitterUrl = (urlString: string): string => {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
url.host = 'twitter.com';
|
||||||
|
return url.href;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const youtubeUrl = (urlString: string): string => {
|
||||||
|
try {
|
||||||
|
const originalUrl = new URL(urlString);
|
||||||
|
const iframeUrl = new URL('https://www.youtube.com/embed');
|
||||||
|
if (originalUrl.host === 'youtu.be') {
|
||||||
|
iframeUrl.pathname += originalUrl.pathname;
|
||||||
|
} else {
|
||||||
|
iframeUrl.pathname += `/${originalUrl.searchParams.get('v')}`;
|
||||||
|
}
|
||||||
|
iframeUrl.searchParams.set('origin', window.location.origin);
|
||||||
|
return iframeUrl.href;
|
||||||
|
} catch {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchOgpContents = async (url: URL): Promise<OgpContents | null> => {
|
||||||
|
const whiteList = ['www3.nhk.or.jp'];
|
||||||
|
if (whiteList.includes(url.host)) {
|
||||||
|
const res = await fetch(url, { headers: { Accept: 'text/html' } });
|
||||||
|
const text = await res.text();
|
||||||
|
const el = new DOMParser().parseFromString(text, 'text/html');
|
||||||
|
const ogs: { [index: string]: string } = {};
|
||||||
|
Array.from(el.head.querySelectorAll('meta'))
|
||||||
|
.filter((v) => v.getAttribute('property') != null && v.getAttribute('content') != null)
|
||||||
|
.forEach((v) => {
|
||||||
|
const property = v.getAttribute('property');
|
||||||
|
const content = v.getAttribute('content');
|
||||||
|
if (property != null && content != null) {
|
||||||
|
ogs[property] = content;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (ogs['og:image'] && ogs['og:title'] && ogs['og:description']) {
|
||||||
|
const contents: OgpContents = {
|
||||||
|
title: ogs['og:title'],
|
||||||
|
description: ogs['og:description'],
|
||||||
|
image: new URL(ogs['og:image']),
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
return contents;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PreviewedLink: Component<SafeLinkProps> = (props) => {
|
||||||
|
let twitterRef: HTMLQuoteElement | undefined;
|
||||||
|
|
||||||
|
const initialOgp: OgpContents = { title: '', description: '', image: null, url: null };
|
||||||
|
const [getOgpContents, setOgpContents] = createSignal(initialOgp);
|
||||||
|
|
||||||
|
const isSafe = () => {
|
||||||
|
try {
|
||||||
|
const url = new URL(props.href);
|
||||||
|
return url.protocol === 'https:' || url.protocol === 'http:';
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const updateOgpContents = async () => {
|
||||||
|
const ogp = await fetchOgpContents(new URL(props.href));
|
||||||
|
if (ogp != null) {
|
||||||
|
setOgpContents(ogp);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
createEffect(() => {
|
||||||
|
if (isTwitterUrl(props.href)) {
|
||||||
|
window.twttr?.widgets?.load(twitterRef);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
updateOgpContents()
|
||||||
|
.then(() => {})
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Show when={isSafe()} fallback={props.href}>
|
||||||
|
<Switch fallback={<SafeLink class={props.class} href={props.href} />}>
|
||||||
|
<Match when={isTwitterUrl(props.href)}>
|
||||||
|
<blockquote class="twitter-tweet" ref={twitterRef}>
|
||||||
|
<a
|
||||||
|
class={props.class}
|
||||||
|
href={twitterUrl(props.href)}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer noopener"
|
||||||
|
>
|
||||||
|
{twitterUrl(props.href)}
|
||||||
|
</a>
|
||||||
|
</blockquote>
|
||||||
|
</Match>
|
||||||
|
<Match when={isYoutubeVideoUrl(props.href)}>
|
||||||
|
<div class="my-2 aspect-video w-full">
|
||||||
|
<iframe title="YouTube" class="h-full w-full" src={youtubeUrl(props.href)} />
|
||||||
|
</div>
|
||||||
|
</Match>
|
||||||
|
<Match when={getOgpContents().url} keyed>
|
||||||
|
<a href={props.href} target="_blank" rel="noreferrer noopener">
|
||||||
|
<div class="rounded-lg border transition-colors hover:bg-slate-100">
|
||||||
|
<img
|
||||||
|
alt=""
|
||||||
|
class="max-w-full rounded-t-lg object-contain shadow"
|
||||||
|
src={getOgpContents().image?.href}
|
||||||
|
/>
|
||||||
|
<div class="mb-1 p-1">
|
||||||
|
<div class="text-xs text-slate-500">{getOgpContents().url?.host}</div>
|
||||||
|
<div class="text-sm">{getOgpContents().title}</div>
|
||||||
|
<div class="text-xs text-slate-500">{getOgpContents().description}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
</Match>
|
||||||
|
</Switch>
|
||||||
|
</Show>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PreviewedLink;
|
||||||
13
src/types/twitterWidget.d.ts
vendored
Normal file
13
src/types/twitterWidget.d.ts
vendored
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
export {};
|
||||||
|
|
||||||
|
type TwitterWidgetAPI = {
|
||||||
|
widgets: {
|
||||||
|
load(elem?: HTMLElement): void;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
twttr?: TwitterWidgetAPI;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -48,3 +48,27 @@ export const thumbnailUrl = (urlString: string): string => {
|
|||||||
return urlString;
|
return urlString;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const isTwitterUrl = (urlString: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
return url.protocol === 'https:' && (url.host === 'twitter.com' || url.host === 'x.com');
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const isYoutubeVideoUrl = (urlString: string): boolean => {
|
||||||
|
try {
|
||||||
|
const url = new URL(urlString);
|
||||||
|
return (
|
||||||
|
(url.protocol === 'https:' &&
|
||||||
|
url.host === 'www.youtube.com' &&
|
||||||
|
url.pathname === '/watch' &&
|
||||||
|
url.searchParams.get('v') != null) ||
|
||||||
|
(url.protocol === 'https:' && url.host === 'youtu.be' && url.pathname.lastIndexOf('/') === 0)
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user