mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-20 07:00:09 +08:00
feat: 解析链接的 og 数据并添加到 preview 内容中 (#9752)
* feat: 解析链接的 og 数据并添加到 preview 内容中 * update test cases * refactor(useMetaDataParser): 移除冗余的isLoaded状态并优化加载逻辑 * feat(hyperlink): 重构超链接组件,提取OG卡片为独立组件 将超链接预览功能中的OG卡片逻辑提取为独立的OGCard组件 简化Hyperlink组件逻辑,移除重复代码 * refactor(OGCard): 简化加载状态并改进骨架屏样式 移除多余的SkeletonContainer包装,直接在加载状态返回CardSkeleton 重构骨架屏组件,添加图片和文本骨架样式 调整容器布局为flex列布局并添加间距 * test(Hyperlink): 添加OGCard组件模拟并更新快照 --------- Co-authored-by: icarus <eurfelux@gmail.com>
This commit is contained in:
parent
5ce7261678
commit
77c2255da4
@ -75,6 +75,7 @@
|
|||||||
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
"@napi-rs/system-ocr": "patch:@napi-rs/system-ocr@npm%3A1.0.2#~/.yarn/patches/@napi-rs-system-ocr-npm-1.0.2-59e7a78e8b.patch",
|
||||||
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
"@strongtz/win32-arm64-msvc": "^0.4.7",
|
||||||
"graceful-fs": "^4.2.11",
|
"graceful-fs": "^4.2.11",
|
||||||
|
"htmlparser2": "^10.0.0",
|
||||||
"jsdom": "26.1.0",
|
"jsdom": "26.1.0",
|
||||||
"node-stream-zip": "^1.15.0",
|
"node-stream-zip": "^1.15.0",
|
||||||
"officeparser": "^4.2.0",
|
"officeparser": "^4.2.0",
|
||||||
|
|||||||
BIN
src/renderer/src/assets/images/banner.png
Normal file
BIN
src/renderer/src/assets/images/banner.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 299 KiB |
145
src/renderer/src/components/OGCard.tsx
Normal file
145
src/renderer/src/components/OGCard.tsx
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
import CherryLogo from '@renderer/assets/images/banner.png'
|
||||||
|
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
||||||
|
import { useMetaDataParser } from '@renderer/hooks/useMetaDataParser'
|
||||||
|
import { Skeleton, Typography } from 'antd'
|
||||||
|
import { useEffect, useMemo } from 'react'
|
||||||
|
import styled from 'styled-components'
|
||||||
|
const { Title, Paragraph } = Typography
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
link: string
|
||||||
|
show: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export const OGCard = ({ link, show }: Props) => {
|
||||||
|
const openGraph = ['og:title', 'og:description', 'og:image', 'og:imageAlt'] as const
|
||||||
|
const { metadata, isLoading, parseMetadata } = useMetaDataParser(link, openGraph)
|
||||||
|
|
||||||
|
const hasImage = !!metadata['og:image']
|
||||||
|
|
||||||
|
const hostname = useMemo(() => {
|
||||||
|
try {
|
||||||
|
return new URL(link).hostname
|
||||||
|
} catch {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}, [link])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
// use show to lazy loading
|
||||||
|
if (show && isLoading) {
|
||||||
|
parseMetadata()
|
||||||
|
}
|
||||||
|
}, [parseMetadata, isLoading, show])
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return <CardSkeleton />
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PreviewContainer hasImage={hasImage}>
|
||||||
|
{hasImage && (
|
||||||
|
<PreviewImageContainer>
|
||||||
|
<PreviewImage src={metadata['og:image']} alt={metadata['og:imageAlt'] || link} />
|
||||||
|
</PreviewImageContainer>
|
||||||
|
)}
|
||||||
|
{!hasImage && (
|
||||||
|
<PreviewImageContainer>
|
||||||
|
<PreviewImage src={CherryLogo} alt={'no image'} />
|
||||||
|
</PreviewImageContainer>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PreviewContent>
|
||||||
|
<StyledHyperLink>
|
||||||
|
{hostname && <Favicon hostname={hostname} alt={link} />}
|
||||||
|
<Title
|
||||||
|
style={{
|
||||||
|
margin: 0,
|
||||||
|
fontSize: '14px',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
color: 'var(--color-text)'
|
||||||
|
}}>
|
||||||
|
{metadata['og:title'] || hostname}
|
||||||
|
</Title>
|
||||||
|
</StyledHyperLink>
|
||||||
|
<Paragraph
|
||||||
|
title={metadata['og:description'] || link}
|
||||||
|
ellipsis={{ rows: 2 }}
|
||||||
|
style={{
|
||||||
|
fontSize: '12px',
|
||||||
|
lineHeight: '1.2',
|
||||||
|
color: 'var(--color-text-secondary)'
|
||||||
|
}}>
|
||||||
|
{metadata['og:description'] || link}
|
||||||
|
</Paragraph>
|
||||||
|
</PreviewContent>
|
||||||
|
</PreviewContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const CardSkeleton = () => {
|
||||||
|
return (
|
||||||
|
<SkeletonContainer>
|
||||||
|
<Skeleton.Image style={{ width: '100%', height: 140 }} active />
|
||||||
|
<Skeleton
|
||||||
|
paragraph={{
|
||||||
|
rows: 1,
|
||||||
|
style: {
|
||||||
|
margin: '8px 0'
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
active
|
||||||
|
/>
|
||||||
|
</SkeletonContainer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const StyledHyperLink = styled.div`
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const PreviewContainer = styled.div<{ hasImage?: boolean }>`
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 380px;
|
||||||
|
height: 220px;
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
|
||||||
|
const PreviewImageContainer = styled.div`
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
min-height: 140px;
|
||||||
|
overflow: hidden;
|
||||||
|
`
|
||||||
|
|
||||||
|
const PreviewContent = styled.div`
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
`
|
||||||
|
|
||||||
|
const PreviewImage = styled.img`
|
||||||
|
width: 100%;
|
||||||
|
height: 140px;
|
||||||
|
object-fit: cover;
|
||||||
|
`
|
||||||
|
|
||||||
|
const SkeletonContainer = styled.div`
|
||||||
|
width: 380px;
|
||||||
|
height: 220px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: var(--color-background);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
gap: 16px;
|
||||||
|
`
|
||||||
78
src/renderer/src/hooks/useMetaDataParser.ts
Normal file
78
src/renderer/src/hooks/useMetaDataParser.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import axios from 'axios'
|
||||||
|
import * as htmlparser2 from 'htmlparser2'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
export function useMetaDataParser<T extends string>(
|
||||||
|
link: string,
|
||||||
|
properties: readonly T[],
|
||||||
|
options?: {
|
||||||
|
timeout?: number
|
||||||
|
}
|
||||||
|
) {
|
||||||
|
const { timeout = 5000 } = options || {}
|
||||||
|
|
||||||
|
const [metadata, setMetadata] = useState<Record<T, string>>({} as Record<T, string>)
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [error, setError] = useState<Error | null>(null)
|
||||||
|
|
||||||
|
const abortControllerRef = useRef<AbortController | null>(null)
|
||||||
|
|
||||||
|
const parseMetadata = useCallback(async () => {
|
||||||
|
if (!link || !isLoading) return
|
||||||
|
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort()
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController()
|
||||||
|
abortControllerRef.current = controller
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(link, { timeout, signal: controller.signal })
|
||||||
|
|
||||||
|
const htmlContent = response.data
|
||||||
|
const parsedMetadata = {} as Record<T, string>
|
||||||
|
|
||||||
|
const parser = new htmlparser2.Parser({
|
||||||
|
onopentag(tagName, attributes) {
|
||||||
|
if (tagName === 'meta') {
|
||||||
|
const { name: metaName, property: metaProperty, content } = attributes
|
||||||
|
const metaKey = metaName || metaProperty
|
||||||
|
if (!metaKey || !properties.includes(metaKey as T)) return
|
||||||
|
parsedMetadata[metaKey as T] = content
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
parser.parseComplete(htmlContent)
|
||||||
|
|
||||||
|
setMetadata(parsedMetadata)
|
||||||
|
} catch (err) {
|
||||||
|
// Don't set error if request was aborted
|
||||||
|
if (axios.isCancel(err) || (err instanceof Error && err.name === 'AbortError')) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setError(err instanceof Error ? err : new Error('Failed to fetch HTML'))
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [isLoading, link, properties, timeout])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (abortControllerRef.current) {
|
||||||
|
abortControllerRef.current.abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
return {
|
||||||
|
metadata,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
parseMetadata
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,13 +1,15 @@
|
|||||||
import Favicon from '@renderer/components/Icons/FallbackFavicon'
|
import { OGCard } from '@renderer/components/OGCard'
|
||||||
import { Popover } from 'antd'
|
import { Popover } from 'antd'
|
||||||
import React, { memo, useMemo } from 'react'
|
import React, { memo, useMemo, useState } from 'react'
|
||||||
import styled from 'styled-components'
|
|
||||||
|
|
||||||
interface HyperLinkProps {
|
interface HyperLinkProps {
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
href: string
|
href: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
const link = useMemo(() => {
|
const link = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
return decodeURIComponent(href)
|
return decodeURIComponent(href)
|
||||||
@ -16,32 +18,20 @@ const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
|||||||
}
|
}
|
||||||
}, [href])
|
}, [href])
|
||||||
|
|
||||||
const hostname = useMemo(() => {
|
|
||||||
try {
|
|
||||||
return new URL(link).hostname
|
|
||||||
} catch {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
}, [link])
|
|
||||||
|
|
||||||
if (!href) return children
|
if (!href) return children
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
arrow={false}
|
arrow={false}
|
||||||
content={
|
open={open}
|
||||||
<StyledHyperLink>
|
onOpenChange={setOpen}
|
||||||
{hostname && <Favicon hostname={hostname} alt={link} />}
|
content={<OGCard link={link} show={open} />}
|
||||||
<span>{link}</span>
|
|
||||||
</StyledHyperLink>
|
|
||||||
}
|
|
||||||
placement="top"
|
placement="top"
|
||||||
color="var(--color-background)"
|
|
||||||
styles={{
|
styles={{
|
||||||
body: {
|
body: {
|
||||||
border: '1px solid var(--color-border)',
|
padding: 0,
|
||||||
padding: '12px',
|
borderRadius: '8px',
|
||||||
borderRadius: '8px'
|
overflow: 'hidden'
|
||||||
}
|
}
|
||||||
}}>
|
}}>
|
||||||
{children}
|
{children}
|
||||||
@ -49,17 +39,4 @@ const Hyperlink: React.FC<HyperLinkProps> = ({ children, href }) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const StyledHyperLink = styled.div`
|
|
||||||
color: var(--color-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
span {
|
|
||||||
max-width: min(400px, 70vw);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
`
|
|
||||||
|
|
||||||
export default memo(Hyperlink)
|
export default memo(Hyperlink)
|
||||||
|
|||||||
@ -18,17 +18,55 @@ const mocks = vi.hoisted(() => ({
|
|||||||
),
|
),
|
||||||
Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => (
|
Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => (
|
||||||
<img data-testid="favicon" data-hostname={hostname} alt={alt} />
|
<img data-testid="favicon" data-hostname={hostname} alt={alt} />
|
||||||
)
|
),
|
||||||
|
Typography: {
|
||||||
|
Title: ({ children }: { children: React.ReactNode }) => <div data-testid="title">{children}</div>,
|
||||||
|
Text: ({ children }: { children: React.ReactNode }) => <div data-testid="text">{children}</div>
|
||||||
|
},
|
||||||
|
Skeleton: () => <div data-testid="skeleton">Loading...</div>,
|
||||||
|
useMetaDataParser: vi.fn(() => ({
|
||||||
|
metadata: {},
|
||||||
|
isLoading: false,
|
||||||
|
isLoaded: true,
|
||||||
|
parseMetadata: vi.fn()
|
||||||
|
}))
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('antd', () => ({
|
vi.mock('antd', () => ({
|
||||||
Popover: mocks.Popover
|
Popover: mocks.Popover,
|
||||||
|
Typography: mocks.Typography,
|
||||||
|
Skeleton: mocks.Skeleton
|
||||||
}))
|
}))
|
||||||
|
|
||||||
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
|
vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({
|
||||||
|
__esModule: true,
|
||||||
default: mocks.Favicon
|
default: mocks.Favicon
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
vi.mock('@renderer/hooks/useMetaDataParser', () => ({
|
||||||
|
useMetaDataParser: mocks.useMetaDataParser
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Mock the OGCard component
|
||||||
|
vi.mock('@renderer/components/OGCard', () => ({
|
||||||
|
OGCard: ({ link }: { link: string; show: boolean }) => {
|
||||||
|
let hostname = ''
|
||||||
|
try {
|
||||||
|
hostname = new URL(link).hostname
|
||||||
|
} catch (e) {
|
||||||
|
// Ignore invalid URLs
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div data-testid="og-card">
|
||||||
|
{hostname && <mocks.Favicon hostname={hostname} alt={link} />}
|
||||||
|
<mocks.Typography.Title>{hostname}</mocks.Typography.Title>
|
||||||
|
<mocks.Typography.Text>{link}</mocks.Typography.Text>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}))
|
||||||
|
|
||||||
describe('Hyperlink', () => {
|
describe('Hyperlink', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks()
|
vi.clearAllMocks()
|
||||||
@ -69,7 +107,9 @@ describe('Hyperlink', () => {
|
|||||||
// Content includes decoded url text and favicon with hostname
|
// Content includes decoded url text and favicon with hostname
|
||||||
expect(screen.getByTestId('favicon')).toHaveAttribute('data-hostname', 'domain.com')
|
expect(screen.getByTestId('favicon')).toHaveAttribute('data-hostname', 'domain.com')
|
||||||
expect(screen.getByTestId('favicon')).toHaveAttribute('alt', 'https://domain.com/a b')
|
expect(screen.getByTestId('favicon')).toHaveAttribute('alt', 'https://domain.com/a b')
|
||||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('https://domain.com/a b')
|
// The title should show hostname and text should show the full URL
|
||||||
|
expect(screen.getByTestId('title')).toHaveTextContent('domain.com')
|
||||||
|
expect(screen.getByTestId('text')).toHaveTextContent('https://domain.com/a b')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render favicon when URL parsing fails (invalid url)', () => {
|
it('should not render favicon when URL parsing fails (invalid url)', () => {
|
||||||
@ -81,7 +121,9 @@ describe('Hyperlink', () => {
|
|||||||
|
|
||||||
// decodeURIComponent succeeds => "not/url" is displayed
|
// decodeURIComponent succeeds => "not/url" is displayed
|
||||||
expect(screen.queryByTestId('favicon')).toBeNull()
|
expect(screen.queryByTestId('favicon')).toBeNull()
|
||||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('not/url')
|
// Since there's no hostname and no og:title, title shows empty, but text shows the URL
|
||||||
|
expect(screen.getByTestId('title')).toBeEmptyDOMElement()
|
||||||
|
expect(screen.getByTestId('text')).toHaveTextContent('not/url')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not render favicon for non-http(s) scheme without hostname (mailto:)', () => {
|
it('should not render favicon for non-http(s) scheme without hostname (mailto:)', () => {
|
||||||
@ -93,6 +135,8 @@ describe('Hyperlink', () => {
|
|||||||
|
|
||||||
// Decoded to mailto:test@example.com, hostname is empty => no favicon
|
// Decoded to mailto:test@example.com, hostname is empty => no favicon
|
||||||
expect(screen.queryByTestId('favicon')).toBeNull()
|
expect(screen.queryByTestId('favicon')).toBeNull()
|
||||||
expect(screen.getByTestId('popover-content')).toHaveTextContent('mailto:test@example.com')
|
// Since there's no hostname and no og:title, title shows empty, but text shows the decoded URL
|
||||||
|
expect(screen.getByTestId('title')).toBeEmptyDOMElement()
|
||||||
|
expect(screen.getByTestId('text')).toHaveTextContent('mailto:test@example.com')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -1,42 +1,34 @@
|
|||||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||||
|
|
||||||
exports[`Hyperlink > should match snapshot for normal url 1`] = `
|
exports[`Hyperlink > should match snapshot for normal url 1`] = `
|
||||||
.c0 {
|
|
||||||
color: var(--color-text);
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.c0 span {
|
|
||||||
max-width: min(400px,70vw);
|
|
||||||
overflow: hidden;
|
|
||||||
text-overflow: ellipsis;
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<div
|
<div
|
||||||
data-arrow="false"
|
data-arrow="false"
|
||||||
data-color="var(--color-background)"
|
|
||||||
data-placement="top"
|
data-placement="top"
|
||||||
data-styles="{"body":{"border":"1px solid var(--color-border)","padding":"12px","borderRadius":"8px"}}"
|
data-styles="{"body":{"padding":0,"borderRadius":"8px","overflow":"hidden"}}"
|
||||||
data-testid="popover"
|
data-testid="popover"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
data-testid="popover-content"
|
data-testid="popover-content"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
class="c0"
|
data-testid="og-card"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
alt="https://example.com/path with space"
|
alt="https://example.com/path with space"
|
||||||
data-hostname="example.com"
|
data-hostname="example.com"
|
||||||
data-testid="favicon"
|
data-testid="favicon"
|
||||||
/>
|
/>
|
||||||
<span>
|
<div
|
||||||
|
data-testid="title"
|
||||||
|
>
|
||||||
|
example.com
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
data-testid="text"
|
||||||
|
>
|
||||||
https://example.com/path with space
|
https://example.com/path with space
|
||||||
</span>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user