feat(channel): add X embed card and link parser
This commit is contained in:
parent
980cd54b66
commit
e771db0c70
115
src/page/workspace/channel/x-embed-card.tsx
Normal file
115
src/page/workspace/channel/x-embed-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
30
src/page/workspace/channel/x-link-parser.ts
Normal file
30
src/page/workspace/channel/x-link-parser.ts
Normal 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;
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user