diff --git a/src/page/workspace/channel/x-embed-card.tsx b/src/page/workspace/channel/x-embed-card.tsx new file mode 100644 index 0000000..8d3cd52 --- /dev/null +++ b/src/page/workspace/channel/x-embed-card.tsx @@ -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(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("/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 ( + + {loading ? ( +
+ + Loading tweet… +
+ ) : error || !data ? ( +
+ +
+

+ @{link.username} +

+

+ Click to open tweet +

+
+ +
+ ) : ( + <> +
+ +
+
+ + {data.author_name} + + + @{link.username} + +
+
+
+ +
+ + )} +
+ ); +} + +/** Extract plain text from the oEmbed HTML blockquote */ +function extractText(html: string): string { + // The oEmbed HTML is a
with

tags inside. + // We strip the wrapper and keep the inner paragraphs. + const match = html.match(/]*>([\s\S]*?)<\/blockquote>/); + if (!match) return ""; + return match[1].trim(); +} + +function XIcon() { + return ( + + + + ); +} diff --git a/src/page/workspace/channel/x-link-parser.ts b/src/page/workspace/channel/x-link-parser.ts new file mode 100644 index 0000000..77e917f --- /dev/null +++ b/src/page/workspace/channel/x-link-parser.ts @@ -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; +}