feat: website preview
This commit is contained in:
@@ -13,6 +13,7 @@ import {
|
|||||||
} from '../Embedded'
|
} from '../Embedded'
|
||||||
import ImageGallery from '../ImageGallery'
|
import ImageGallery from '../ImageGallery'
|
||||||
import VideoPlayer from '../VideoPlayer'
|
import VideoPlayer from '../VideoPlayer'
|
||||||
|
import WebPreview from '../WebPreview'
|
||||||
|
|
||||||
const Content = memo(
|
const Content = memo(
|
||||||
({
|
({
|
||||||
@@ -24,7 +25,7 @@ const Content = memo(
|
|||||||
className?: string
|
className?: string
|
||||||
size?: 'normal' | 'small'
|
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 isNsfw = isNsfwEvent(event)
|
||||||
const nodes = embedded(content, [
|
const nodes = embedded(content, [
|
||||||
embeddedWebsocketUrlRenderer,
|
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
|
// Add embedded notes
|
||||||
if (embeddedNotes.length) {
|
if (embeddedNotes.length) {
|
||||||
embeddedNotes.forEach((note, index) => {
|
embeddedNotes.forEach((note, index) => {
|
||||||
@@ -79,6 +91,7 @@ export default Content
|
|||||||
function preprocess(content: string) {
|
function preprocess(content: string) {
|
||||||
const urlRegex = /(https?:\/\/[^\s"']+)/g
|
const urlRegex = /(https?:\/\/[^\s"']+)/g
|
||||||
const urls = content.match(urlRegex) || []
|
const urls = content.match(urlRegex) || []
|
||||||
|
let lastNonMediaUrl: string | undefined
|
||||||
|
|
||||||
let c = content
|
let c = content
|
||||||
const images: string[] = []
|
const images: string[] = []
|
||||||
@@ -91,6 +104,8 @@ function preprocess(content: string) {
|
|||||||
} else if (isVideo(url)) {
|
} else if (isVideo(url)) {
|
||||||
c = c.replace(url, '').trim()
|
c = c.replace(url, '').trim()
|
||||||
videos.push(url)
|
videos.push(url)
|
||||||
|
} else {
|
||||||
|
lastNonMediaUrl = url
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -101,7 +116,7 @@ function preprocess(content: string) {
|
|||||||
embeddedNotes.push(note)
|
embeddedNotes.push(note)
|
||||||
})
|
})
|
||||||
|
|
||||||
return { content: c, images, videos, embeddedNotes }
|
return { content: c, images, videos, embeddedNotes, lastNonMediaUrl }
|
||||||
}
|
}
|
||||||
|
|
||||||
function isImage(url: string) {
|
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 = {
|
export type TRelayInfo = {
|
||||||
supported_nips?: number[]
|
supported_nips?: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type TWebMetadata = {
|
||||||
|
title?: string | null
|
||||||
|
description?: string | null
|
||||||
|
image?: string | null
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user