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