diff --git a/package.json b/package.json index 0e6420ce03..4783fb77ef 100644 --- a/package.json +++ b/package.json @@ -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", "@strongtz/win32-arm64-msvc": "^0.4.7", "graceful-fs": "^4.2.11", + "htmlparser2": "^10.0.0", "jsdom": "26.1.0", "node-stream-zip": "^1.15.0", "officeparser": "^4.2.0", diff --git a/src/renderer/src/assets/images/banner.png b/src/renderer/src/assets/images/banner.png new file mode 100644 index 0000000000..e29198cf82 Binary files /dev/null and b/src/renderer/src/assets/images/banner.png differ diff --git a/src/renderer/src/components/OGCard.tsx b/src/renderer/src/components/OGCard.tsx new file mode 100644 index 0000000000..8a0036e8e2 --- /dev/null +++ b/src/renderer/src/components/OGCard.tsx @@ -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 + } + + return ( + + {hasImage && ( + + + + )} + {!hasImage && ( + + + + )} + + + + {hostname && } + + {metadata['og:title'] || hostname} + + + + {metadata['og:description'] || link} + + + + ) +} + +const CardSkeleton = () => { + return ( + + + + + ) +} + +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; +` diff --git a/src/renderer/src/hooks/useMetaDataParser.ts b/src/renderer/src/hooks/useMetaDataParser.ts new file mode 100644 index 0000000000..10585b1b13 --- /dev/null +++ b/src/renderer/src/hooks/useMetaDataParser.ts @@ -0,0 +1,78 @@ +import axios from 'axios' +import * as htmlparser2 from 'htmlparser2' +import { useCallback, useEffect, useRef, useState } from 'react' + +export function useMetaDataParser( + link: string, + properties: readonly T[], + options?: { + timeout?: number + } +) { + const { timeout = 5000 } = options || {} + + const [metadata, setMetadata] = useState>({} as Record) + const [isLoading, setIsLoading] = useState(true) + const [error, setError] = useState(null) + + const abortControllerRef = useRef(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 + + 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 + } +} diff --git a/src/renderer/src/pages/home/Markdown/Hyperlink.tsx b/src/renderer/src/pages/home/Markdown/Hyperlink.tsx index 6e461ea1a6..ec157270c8 100644 --- a/src/renderer/src/pages/home/Markdown/Hyperlink.tsx +++ b/src/renderer/src/pages/home/Markdown/Hyperlink.tsx @@ -1,13 +1,15 @@ -import Favicon from '@renderer/components/Icons/FallbackFavicon' +import { OGCard } from '@renderer/components/OGCard' import { Popover } from 'antd' -import React, { memo, useMemo } from 'react' -import styled from 'styled-components' +import React, { memo, useMemo, useState } from 'react' interface HyperLinkProps { children: React.ReactNode href: string } + const Hyperlink: React.FC = ({ children, href }) => { + const [open, setOpen] = useState(false) + const link = useMemo(() => { try { return decodeURIComponent(href) @@ -16,32 +18,20 @@ const Hyperlink: React.FC = ({ children, href }) => { } }, [href]) - const hostname = useMemo(() => { - try { - return new URL(link).hostname - } catch { - return null - } - }, [link]) - if (!href) return children return ( - {hostname && } - {link} - - } + open={open} + onOpenChange={setOpen} + content={} placement="top" - color="var(--color-background)" styles={{ body: { - border: '1px solid var(--color-border)', - padding: '12px', - borderRadius: '8px' + padding: 0, + borderRadius: '8px', + overflow: 'hidden' } }}> {children} @@ -49,17 +39,4 @@ const Hyperlink: React.FC = ({ 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) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx b/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx index bd232b3454..8be594af8b 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx +++ b/src/renderer/src/pages/home/Markdown/__tests__/Hyperlink.test.tsx @@ -18,17 +18,55 @@ const mocks = vi.hoisted(() => ({ ), Favicon: ({ hostname, alt }: { hostname: string; alt: string }) => ( {alt} - ) + ), + Typography: { + Title: ({ children }: { children: React.ReactNode }) =>
{children}
, + Text: ({ children }: { children: React.ReactNode }) =>
{children}
+ }, + Skeleton: () =>
Loading...
, + useMetaDataParser: vi.fn(() => ({ + metadata: {}, + isLoading: false, + isLoaded: true, + parseMetadata: vi.fn() + })) })) vi.mock('antd', () => ({ - Popover: mocks.Popover + Popover: mocks.Popover, + Typography: mocks.Typography, + Skeleton: mocks.Skeleton })) vi.mock('@renderer/components/Icons/FallbackFavicon', () => ({ + __esModule: true, 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 ( +
+ {hostname && } + {hostname} + {link} +
+ ) + } +})) + describe('Hyperlink', () => { beforeEach(() => { vi.clearAllMocks() @@ -69,7 +107,9 @@ describe('Hyperlink', () => { // Content includes decoded url text and favicon with hostname expect(screen.getByTestId('favicon')).toHaveAttribute('data-hostname', 'domain.com') 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)', () => { @@ -81,7 +121,9 @@ describe('Hyperlink', () => { // decodeURIComponent succeeds => "not/url" is displayed 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:)', () => { @@ -93,6 +135,8 @@ describe('Hyperlink', () => { // Decoded to mailto:test@example.com, hostname is empty => no favicon 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') }) }) diff --git a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap index 1dad29914b..84c01bd9fa 100644 --- a/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap +++ b/src/renderer/src/pages/home/Markdown/__tests__/__snapshots__/Hyperlink.test.tsx.snap @@ -1,42 +1,34 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html 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; -} -
https://example.com/path with space - +
+ example.com +
+
https://example.com/path with space - +