feat: render link metadata cards

pull/5917/head
boojack 4 weeks ago
parent 9c5c604944
commit 0bc56694b0

@ -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 (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className={cn(
"group my-0 mb-2 flex w-full max-w-full overflow-hidden rounded-md border border-border bg-muted/20 text-foreground no-underline transition-colors",
"hover:border-primary/35 hover:bg-accent/20",
)}
>
<span className="flex min-w-0 flex-1 flex-col gap-0.5 px-2.5 py-2 sm:gap-1 sm:px-3 sm:py-2.5">
{hostname && <span className="truncate text-[11px] leading-4 text-muted-foreground sm:text-xs">{hostname}</span>}
{title && <span className="line-clamp-2 text-sm font-medium leading-5 text-foreground">{title}</span>}
{description && <span className="line-clamp-1 text-xs leading-4 text-muted-foreground sm:line-clamp-2">{description}</span>}
</span>
{image && !imageFailed && (
<span className="flex w-24 shrink-0 items-center border-l border-border/70 bg-muted/40 sm:w-40">
<span className="aspect-[1.91/1] w-full overflow-hidden">
<img
src={image}
alt=""
className="h-full w-full object-cover transition-transform duration-200 group-hover:scale-[1.01]"
loading="lazy"
decoding="async"
onError={() => setImageFailed(true)}
/>
</span>
</span>
)}
</a>
);
};
export default LinkMetadataCard;

@ -0,0 +1,22 @@
import { createContext, useContext, useMemo } from "react";
interface MarkdownRenderContextValue {
blockDepth: number;
}
export const rootMarkdownRenderContext: MarkdownRenderContextValue = {
blockDepth: 0,
};
export const MarkdownRenderContext = createContext<MarkdownRenderContextValue>(rootMarkdownRenderContext);
export const useMarkdownRenderContext = () => {
return useContext(MarkdownRenderContext);
};
export const NestedMarkdownRenderContext = ({ children }: { children: React.ReactNode }) => {
const { blockDepth } = useMarkdownRenderContext();
const value = useMemo<MarkdownRenderContextValue>(() => ({ blockDepth: blockDepth + 1 }), [blockDepth]);
return <MarkdownRenderContext.Provider value={value}>{children}</MarkdownRenderContext.Provider>;
};

@ -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 (
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[rehypeRaw, [rehypeSanitize, SANITIZE_SCHEMA], rehypeHeadingId, [rehypeKatex, { throwOnError: false, strict: false }]]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
<MarkdownRenderContext.Provider value={rootMarkdownRenderContext}>
<ReactMarkdown
remarkPlugins={[
remarkDisableSetext,
remarkMath,
remarkGfm,
remarkSplitMixedTaskLists,
remarkBreaks,
remarkMention,
remarkTag,
remarkPreserveType,
]}
rehypePlugins={[
rehypeRaw,
[rehypeSanitize, SANITIZE_SCHEMA],
rehypeHeadingId,
[rehypeKatex, { throwOnError: false, strict: false }],
]}
components={markdownComponents}
>
{content}
</ReactMarkdown>
</MarkdownRenderContext.Provider>
);
};

@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { NestedMarkdownRenderContext } from "./MarkdownRenderContext";
import type { ReactMarkdownProps } from "./markdown/types";
interface TableProps extends React.HTMLAttributes<HTMLTableElement>, ReactMarkdownProps {
@ -58,7 +59,7 @@ interface TableHeaderCellProps extends React.ThHTMLAttributes<HTMLTableCellEleme
export const TableHeaderCell = ({ children, className, node: _node, ...props }: TableHeaderCellProps) => {
return (
<th className={cn("px-2 py-1 text-left align-middle text-sm font-medium text-muted-foreground", className)} {...props}>
{children}
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</th>
);
};
@ -70,7 +71,7 @@ interface TableCellProps extends React.TdHTMLAttributes<HTMLTableCellElement>, R
export const TableCell = ({ children, className, node: _node, ...props }: TableCellProps) => {
return (
<td className={cn("px-2 py-1 text-left align-middle text-sm", className)} {...props}>
{children}
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</td>
);
};

@ -1,4 +1,5 @@
import { cn } from "@/lib/utils";
import { NestedMarkdownRenderContext } from "../MarkdownRenderContext";
import type { ReactMarkdownProps } from "./types";
interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElement>, ReactMarkdownProps {
@ -11,7 +12,7 @@ interface BlockquoteProps extends React.BlockquoteHTMLAttributes<HTMLQuoteElemen
export const Blockquote = ({ children, className, node: _node, ...props }: BlockquoteProps) => {
return (
<blockquote className={cn("my-0 mb-2 border-l-4 border-primary/30 pl-3 text-muted-foreground italic", className)} {...props}>
{children}
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</blockquote>
);
};

@ -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<HTMLUListElement | HTMLOListElement>, ReactMarkdownProps {
@ -55,14 +56,14 @@ export const ListItem = ({ children, className, node: _node, ...domProps }: List
)}
{...domProps}
>
{children}
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</li>
);
}
return (
<li className={cn("mt-0.5 leading-6", className)} {...domProps}>
{children}
<NestedMarkdownRenderContext>{children}</NestedMarkdownRenderContext>
</li>
);
};

@ -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<HTMLParagraphElement>, 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 = (
<p className={cn("my-0 mb-2 leading-6", className)} {...props}>
{children}
</p>
);
if (href) {
return <LinkMetadataCard url={href} fallback={paragraph} />;
}
return paragraph;
};

@ -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<Memo> & Pick<Memo, "name">;
@ -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();

Loading…
Cancel
Save