diff --git a/src/renderer/src/components/Content/index.tsx b/src/renderer/src/components/Content/index.tsx index e6d99af0..137a858d 100644 --- a/src/renderer/src/components/Content/index.tsx +++ b/src/renderer/src/components/Content/index.tsx @@ -13,6 +13,7 @@ import { } from '../Embedded' import ImageGallery from '../ImageGallery' import VideoPlayer from '../VideoPlayer' +import WebPreview from '../WebPreview' const Content = memo( ({ @@ -24,7 +25,7 @@ const Content = memo( className?: string size?: 'normal' | 'small' }) => { - const { content, images, videos, embeddedNotes } = preprocess(event.content) + const { content, images, videos, embeddedNotes, lastNonMediaUrl } = preprocess(event.content) const isNsfw = isNsfwEvent(event) const nodes = embedded(content, [ embeddedWebsocketUrlRenderer, @@ -62,6 +63,17 @@ const Content = memo( }) } + // Add website preview + if (lastNonMediaUrl) { + nodes.push( + + ) + } + // Add embedded notes if (embeddedNotes.length) { embeddedNotes.forEach((note, index) => { @@ -79,6 +91,7 @@ export default Content function preprocess(content: string) { const urlRegex = /(https?:\/\/[^\s"']+)/g const urls = content.match(urlRegex) || [] + let lastNonMediaUrl: string | undefined let c = content const images: string[] = [] @@ -91,6 +104,8 @@ function preprocess(content: string) { } else if (isVideo(url)) { c = c.replace(url, '').trim() videos.push(url) + } else { + lastNonMediaUrl = url } }) @@ -101,7 +116,7 @@ function preprocess(content: string) { embeddedNotes.push(note) }) - return { content: c, images, videos, embeddedNotes } + return { content: c, images, videos, embeddedNotes, lastNonMediaUrl } } function isImage(url: string) { diff --git a/src/renderer/src/components/WebPreview/index.tsx b/src/renderer/src/components/WebPreview/index.tsx new file mode 100644 index 00000000..090ba0cc --- /dev/null +++ b/src/renderer/src/components/WebPreview/index.tsx @@ -0,0 +1,27 @@ +import { Image } from '@nextui-org/image' +import { useFetchWebMetadata } from '@renderer/hooks/useFetchWebMetadata' +import { cn } from '@renderer/lib/utils' + +export default function WebPreview({ url, className }: { url: string; className?: string }) { + const { title, description, image } = useFetchWebMetadata(url) + + if (!title && !description && !image) { + return null + } + + return ( +
{ + e.stopPropagation() + window.open(url, '_blank') + }} + > + {image && } +
+
{title}
+
{description}
+
+
+ ) +} diff --git a/src/renderer/src/hooks/useFetchWebMetadata.tsx b/src/renderer/src/hooks/useFetchWebMetadata.tsx new file mode 100644 index 00000000..f30bbb02 --- /dev/null +++ b/src/renderer/src/hooks/useFetchWebMetadata.tsx @@ -0,0 +1,13 @@ +import { TWebMetadata } from '@renderer/types' +import { useEffect, useState } from 'react' +import webService from '@renderer/services/web.service' + +export function useFetchWebMetadata(url: string) { + const [metadata, setMetadata] = useState({}) + + useEffect(() => { + webService.fetchWebMetadata(url).then((metadata) => setMetadata(metadata)) + }, [url]) + + return metadata +} diff --git a/src/renderer/src/services/web.service.ts b/src/renderer/src/services/web.service.ts new file mode 100644 index 00000000..dfa20ece --- /dev/null +++ b/src/renderer/src/services/web.service.ts @@ -0,0 +1,48 @@ +import { TWebMetadata } from '@renderer/types' +import DataLoader from 'dataloader' + +class WebService { + static instance: WebService + + private webMetadataDataLoader = new DataLoader(async (urls) => { + return await Promise.all( + urls.map(async (url) => { + try { + const res = await fetch(url) + const html = await res.text() + const parser = new DOMParser() + const doc = parser.parseFromString(html, 'text/html') + + const title = + doc.querySelector('meta[property="og:title"]')?.getAttribute('content') || + doc.querySelector('title')?.textContent + const description = + doc.querySelector('meta[property="og:description"]')?.getAttribute('content') || + (doc.querySelector('meta[name="description"]') as HTMLMetaElement | null)?.content + const image = (doc.querySelector('meta[property="og:image"]') as HTMLMetaElement | null) + ?.content + + return { title, description, image } + } catch (e) { + console.error(e) + return {} + } + }) + ) + }) + + constructor() { + if (!WebService.instance) { + WebService.instance = this + } + return WebService.instance + } + + async fetchWebMetadata(url: string) { + return await this.webMetadataDataLoader.load(url) + } +} + +const instance = new WebService() + +export default instance diff --git a/src/renderer/src/types.ts b/src/renderer/src/types.ts index a1a4bbc0..8a8c0f47 100644 --- a/src/renderer/src/types.ts +++ b/src/renderer/src/types.ts @@ -16,3 +16,9 @@ export type TRelayList = { export type TRelayInfo = { supported_nips?: number[] } + +export type TWebMetadata = { + title?: string | null + description?: string | null + image?: string | null +}