feat(channel): add X embed card and link parser

This commit is contained in:
zhenyi 2026-05-31 13:12:41 +08:00
parent 980cd54b66
commit e771db0c70
2 changed files with 145 additions and 0 deletions

View File

@ -0,0 +1,115 @@
import { useEffect, useState } from "react";
import { ExternalLink, Loader2 } from "lucide-react";
import { api } from "@/client";
import type { XLinkMatch } from "./x-link-parser";
type OEmbedData = {
author_name: string;
author_url: string;
provider_name: string;
html: string;
width: number | null;
height: number | null;
};
export default function XEmbedCard({ link }: { link: XLinkMatch }) {
const [data, setData] = useState<OEmbedData | null>(null);
const [error, setError] = useState(false);
const [loading, setLoading] = useState(true);
/* eslint-disable react-hooks/set-state-in-effect */
useEffect(() => {
let cancelled = false;
setLoading(true);
api
.get<OEmbedData>("/api/v1/ws/embed/twitter", {
params: { url: link.url },
})
.then((res) => {
if (!cancelled) setData(res.data);
})
.catch(() => {
if (!cancelled) setError(true);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [link.url]);
/* eslint-enable react-hooks/set-state-in-effect */
return (
<a
className="mt-2 block max-w-[420px] rounded-xl border border-border/30 bg-muted/[0.03] p-4 transition-[background-color,border-color,color,opacity,box-shadow,transform] duration-200 hover:border-primary/20 hover:bg-muted/[0.08] hover:shadow-sm no-underline"
href={link.url}
rel="noopener noreferrer"
target="_blank"
>
{loading ? (
<div className="flex items-center gap-2 py-2 text-[13px] text-muted-foreground/50">
<Loader2 className="size-4 animate-spin" />
Loading tweet
</div>
) : error || !data ? (
<div className="flex items-center gap-2 py-2">
<XIcon />
<div className="min-w-0 flex-1">
<p className="text-[13px] font-semibold text-foreground/70">
@{link.username}
</p>
<p className="text-[11px] text-muted-foreground/40">
Click to open tweet
</p>
</div>
<ExternalLink className="size-3.5 shrink-0 text-muted-foreground/25" />
</div>
) : (
<>
<div className="flex items-start gap-3">
<XIcon />
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[13px] font-semibold text-foreground">
{data.author_name}
</span>
<span className="truncate text-[12px] text-muted-foreground/50">
@{link.username}
</span>
</div>
<div
className="mt-1 text-[12px] leading-relaxed text-muted-foreground/70 [&_a]:text-primary/70 [&_a]:no-underline [&_p]:my-1"
dangerouslySetInnerHTML={{ __html: extractText(data.html) }}
/>
</div>
<ExternalLink className="mt-0.5 size-3.5 shrink-0 text-muted-foreground/20" />
</div>
</>
)}
</a>
);
}
/** Extract plain text from the oEmbed HTML blockquote */
function extractText(html: string): string {
// The oEmbed HTML is a <blockquote> with <p> tags inside.
// We strip the wrapper and keep the inner paragraphs.
const match = html.match(/<blockquote[^>]*>([\s\S]*?)<\/blockquote>/);
if (!match) return "";
return match[1].trim();
}
function XIcon() {
return (
<svg
className="size-5 shrink-0 text-foreground/60"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24H16.17l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
</svg>
);
}

View File

@ -0,0 +1,30 @@
/**
* Parse X (Twitter) tweet links from message content.
* Matches: https://x.com/user/status/123 and https://twitter.com/user/status/123
*/
const X_LINK_RE =
/(?:^|\s)(https?:\/\/(?:x\.com|twitter\.com)\/([A-Za-z0-9_]+)\/status\/(\d+))(?:\s|$|[?&#])/g;
export interface XLinkMatch {
/** Full matched URL string */
url: string;
/** Username (without @) */
username: string;
/** Tweet ID */
tweetId: string;
}
export function parseXLinks(text: string): XLinkMatch[] {
const results: XLinkMatch[] = [];
for (const match of text.matchAll(X_LINK_RE)) {
results.push({
url: match[1],
username: match[2],
tweetId: match[3],
});
}
return results;
}