feat: website preview
This commit is contained in:
@@ -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(
|
||||
<WebPreview
|
||||
className={size === 'small' ? 'mt-1' : 'mt-2'}
|
||||
key={`web-preview-${event.id}`}
|
||||
url={lastNonMediaUrl}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// 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) {
|
||||
|
||||
27
src/renderer/src/components/WebPreview/index.tsx
Normal file
27
src/renderer/src/components/WebPreview/index.tsx
Normal file
@@ -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 (
|
||||
<div
|
||||
className={cn('p-0 hover:bg-muted/50 cursor-pointer flex w-full', className)}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
window.open(url, '_blank')
|
||||
}}
|
||||
>
|
||||
{image && <Image src={image} className="rounded-l-lg object-cover w-2/5" removeWrapper />}
|
||||
<div className={`flex-1 w-0 p-2 border ${image ? 'rounded-r-lg' : 'rounded-lg'}`}>
|
||||
<div className="font-semibold truncate">{title}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-2">{description}</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
13
src/renderer/src/hooks/useFetchWebMetadata.tsx
Normal file
13
src/renderer/src/hooks/useFetchWebMetadata.tsx
Normal file
@@ -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<TWebMetadata>({})
|
||||
|
||||
useEffect(() => {
|
||||
webService.fetchWebMetadata(url).then((metadata) => setMetadata(metadata))
|
||||
}, [url])
|
||||
|
||||
return metadata
|
||||
}
|
||||
48
src/renderer/src/services/web.service.ts
Normal file
48
src/renderer/src/services/web.service.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { TWebMetadata } from '@renderer/types'
|
||||
import DataLoader from 'dataloader'
|
||||
|
||||
class WebService {
|
||||
static instance: WebService
|
||||
|
||||
private webMetadataDataLoader = new DataLoader<string, TWebMetadata>(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
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user