From 0bc56694b0ca347ab1eb083f62997a22007b763d Mon Sep 17 00:00:00 2001 From: boojack Date: Wed, 29 Apr 2026 23:23:52 +0800 Subject: [PATCH] feat: render link metadata cards --- .../MemoContent/LinkMetadataCard.tsx | 70 +++++++++++++++++++ .../MemoContent/MarkdownRenderContext.tsx | 22 ++++++ .../MemoContent/MemoMarkdownRenderer.tsx | 40 ++++++----- web/src/components/MemoContent/Table.tsx | 5 +- .../MemoContent/markdown/Blockquote.tsx | 3 +- .../components/MemoContent/markdown/List.tsx | 5 +- .../MemoContent/markdown/Paragraph.tsx | 40 +++++++++-- web/src/hooks/useMemoQueries.ts | 25 +++++++ 8 files changed, 184 insertions(+), 26 deletions(-) create mode 100644 web/src/components/MemoContent/LinkMetadataCard.tsx create mode 100644 web/src/components/MemoContent/MarkdownRenderContext.tsx diff --git a/web/src/components/MemoContent/LinkMetadataCard.tsx b/web/src/components/MemoContent/LinkMetadataCard.tsx new file mode 100644 index 000000000..197b101fe --- /dev/null +++ b/web/src/components/MemoContent/LinkMetadataCard.tsx @@ -0,0 +1,70 @@ +import type React from "react"; +import { useEffect, useState } from "react"; +import { useLinkMetadata } from "@/hooks/useMemoQueries"; +import { cn } from "@/lib/utils"; + +interface LinkMetadataCardProps { + url: string; + fallback: React.ReactNode; +} + +function getHostname(url: string): string { + try { + return new URL(url).hostname.replace(/^www\./, ""); + } catch { + return ""; + } +} + +const LinkMetadataCard = ({ url, fallback }: LinkMetadataCardProps) => { + const [imageFailed, setImageFailed] = useState(false); + const { data: metadata, isSuccess } = useLinkMetadata(url); + + const title = metadata?.title.trim() ?? ""; + const description = metadata?.description.trim() ?? ""; + const image = metadata?.image.trim() ?? ""; + const hostname = getHostname(metadata?.url || url); + const hasUsefulMetadata = title !== "" || description !== ""; + + useEffect(() => { + setImageFailed(false); + }, [url, image]); + + if (!isSuccess || !hasUsefulMetadata) { + return fallback; + } + + return ( + + + {hostname && {hostname}} + {title && {title}} + {description && {description}} + + {image && !imageFailed && ( + + + setImageFailed(true)} + /> + + + )} + + ); +}; + +export default LinkMetadataCard; diff --git a/web/src/components/MemoContent/MarkdownRenderContext.tsx b/web/src/components/MemoContent/MarkdownRenderContext.tsx new file mode 100644 index 000000000..8f906aa31 --- /dev/null +++ b/web/src/components/MemoContent/MarkdownRenderContext.tsx @@ -0,0 +1,22 @@ +import { createContext, useContext, useMemo } from "react"; + +interface MarkdownRenderContextValue { + blockDepth: number; +} + +export const rootMarkdownRenderContext: MarkdownRenderContextValue = { + blockDepth: 0, +}; + +export const MarkdownRenderContext = createContext(rootMarkdownRenderContext); + +export const useMarkdownRenderContext = () => { + return useContext(MarkdownRenderContext); +}; + +export const NestedMarkdownRenderContext = ({ children }: { children: React.ReactNode }) => { + const { blockDepth } = useMarkdownRenderContext(); + const value = useMemo(() => ({ blockDepth: blockDepth + 1 }), [blockDepth]); + + return {children}; +}; diff --git a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx index 159c6f4dd..d2750d2f7 100644 --- a/web/src/components/MemoContent/MemoMarkdownRenderer.tsx +++ b/web/src/components/MemoContent/MemoMarkdownRenderer.tsx @@ -16,6 +16,7 @@ import { remarkSplitMixedTaskLists } from "@/utils/remark-plugins/remark-split-m import { remarkTag } from "@/utils/remark-plugins/remark-tag"; import { CodeBlock } from "./CodeBlock"; import { SANITIZE_SCHEMA } from "./constants"; +import { MarkdownRenderContext, rootMarkdownRenderContext } from "./MarkdownRenderContext"; import { Mention } from "./Mention"; import { Blockquote, Heading, HorizontalRule, Image, InlineCode, Link, List, ListItem, Paragraph } from "./markdown"; import { Table, TableBody, TableCell, TableHead, TableHeaderCell, TableRow } from "./Table"; @@ -119,21 +120,28 @@ export const MemoMarkdownRenderer = ({ content, resolvedMentionUsernames }: Memo }; return ( - - {content} - + + + {content} + + ); }; diff --git a/web/src/components/MemoContent/Table.tsx b/web/src/components/MemoContent/Table.tsx index 69e967cdd..402ef3216 100644 --- a/web/src/components/MemoContent/Table.tsx +++ b/web/src/components/MemoContent/Table.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { NestedMarkdownRenderContext } from "./MarkdownRenderContext"; import type { ReactMarkdownProps } from "./markdown/types"; interface TableProps extends React.HTMLAttributes, ReactMarkdownProps { @@ -58,7 +59,7 @@ interface TableHeaderCellProps extends React.ThHTMLAttributes { return ( - {children} + {children} ); }; @@ -70,7 +71,7 @@ interface TableCellProps extends React.TdHTMLAttributes, R export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => { return ( - {children} + {children} ); }; diff --git a/web/src/components/MemoContent/markdown/Blockquote.tsx b/web/src/components/MemoContent/markdown/Blockquote.tsx index c8ed5fc31..531e61f74 100644 --- a/web/src/components/MemoContent/markdown/Blockquote.tsx +++ b/web/src/components/MemoContent/markdown/Blockquote.tsx @@ -1,4 +1,5 @@ import { cn } from "@/lib/utils"; +import { NestedMarkdownRenderContext } from "../MarkdownRenderContext"; import type { ReactMarkdownProps } from "./types"; interface BlockquoteProps extends React.BlockquoteHTMLAttributes, ReactMarkdownProps { @@ -11,7 +12,7 @@ interface BlockquoteProps extends React.BlockquoteHTMLAttributes { return (
- {children} + {children}
); }; diff --git a/web/src/components/MemoContent/markdown/List.tsx b/web/src/components/MemoContent/markdown/List.tsx index c34e6a075..3e3d2e958 100644 --- a/web/src/components/MemoContent/markdown/List.tsx +++ b/web/src/components/MemoContent/markdown/List.tsx @@ -1,5 +1,6 @@ import { cn } from "@/lib/utils"; import { TASK_LIST_CLASS, TASK_LIST_ITEM_CLASS } from "../constants"; +import { NestedMarkdownRenderContext } from "../MarkdownRenderContext"; import type { ReactMarkdownProps } from "./types"; interface ListProps extends React.HTMLAttributes, ReactMarkdownProps { @@ -55,14 +56,14 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List )} {...domProps} > - {children} + {children} ); } return (
  • - {children} + {children}
  • ); }; diff --git a/web/src/components/MemoContent/markdown/Paragraph.tsx b/web/src/components/MemoContent/markdown/Paragraph.tsx index ecf5e67e6..00331598b 100644 --- a/web/src/components/MemoContent/markdown/Paragraph.tsx +++ b/web/src/components/MemoContent/markdown/Paragraph.tsx @@ -1,17 +1,47 @@ +import type { Element } from "hast"; import { cn } from "@/lib/utils"; +import LinkMetadataCard from "../LinkMetadataCard"; +import { useMarkdownRenderContext } from "../MarkdownRenderContext"; import type { ReactMarkdownProps } from "./types"; interface ParagraphProps extends React.HTMLAttributes, ReactMarkdownProps { children: React.ReactNode; } -/** - * Paragraph component with compact spacing - */ -export const Paragraph = ({ children, className, node: _node, ...props }: ParagraphProps) => { - return ( +function getSingleLinkHref(node?: Element): string | undefined { + if (!node || node.tagName !== "p") { + return undefined; + } + + const meaningfulChildren = node.children.filter((child) => { + return !(child.type === "text" && child.value.trim() === ""); + }); + + if (meaningfulChildren.length !== 1) { + return undefined; + } + + const onlyChild = meaningfulChildren[0]; + if (onlyChild.type !== "element" || onlyChild.tagName !== "a") { + return undefined; + } + + const href = onlyChild.properties?.href; + return typeof href === "string" ? href : undefined; +} + +export const Paragraph = ({ children, className, node, ...props }: ParagraphProps) => { + const { blockDepth } = useMarkdownRenderContext(); + const href = blockDepth === 0 ? getSingleLinkHref(node) : undefined; + const paragraph = (

    {children}

    ); + + if (href) { + return ; + } + + return paragraph; }; diff --git a/web/src/hooks/useMemoQueries.ts b/web/src/hooks/useMemoQueries.ts index 9fda043f9..e92998671 100644 --- a/web/src/hooks/useMemoQueries.ts +++ b/web/src/hooks/useMemoQueries.ts @@ -15,6 +15,7 @@ export const memoKeys = { details: () => [...memoKeys.all, "detail"] as const, detail: (name: string) => [...memoKeys.details(), name] as const, comments: (name: string) => [...memoKeys.all, "comments", name] as const, + linkMetadata: (url: string) => [...memoKeys.all, "linkMetadata", url] as const, }; type MemoPatch = Partial & Pick; @@ -149,6 +150,30 @@ export function useMemo(name: string, options?: { enabled?: boolean }) { }); } +function isHTTPURL(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; + } catch { + return false; + } +} + +export function useLinkMetadata(url: string, options?: { enabled?: boolean }) { + const trimmedUrl = url.trim(); + + return useQuery({ + queryKey: memoKeys.linkMetadata(trimmedUrl), + queryFn: async () => { + const metadata = await memoServiceClient.getLinkMetadata({ url: trimmedUrl }); + return metadata; + }, + enabled: (options?.enabled ?? true) && isHTTPURL(trimmedUrl), + staleTime: 1000 * 60 * 60 * 24, + gcTime: 1000 * 60 * 60 * 24, + }); +} + export function useCreateMemo() { const queryClient = useQueryClient();