From cb1b56f5a0eddbf77446f415f2beda57c8305f85 Mon Sep 17 00:00:00 2001 From: polwex Date: Sun, 23 Nov 2025 01:12:53 +0700 Subject: wut --- packages/tweetdeck/src/components/TweetCard.tsx | 337 ++++++++++++++++++++++++ 1 file changed, 337 insertions(+) create mode 100644 packages/tweetdeck/src/components/TweetCard.tsx (limited to 'packages/tweetdeck/src/components/TweetCard.tsx') diff --git a/packages/tweetdeck/src/components/TweetCard.tsx b/packages/tweetdeck/src/components/TweetCard.tsx new file mode 100644 index 0000000..7cd2936 --- /dev/null +++ b/packages/tweetdeck/src/components/TweetCard.tsx @@ -0,0 +1,337 @@ +import { useCallback, useState } from "react"; +import { Bookmark, Heart, Link2, MessageCircle, Repeat2 } from "lucide-react"; +import type { Tweet } from "../lib/fetching/types"; +import { timeAgo } from "../lib/utils/time"; +import { LangText } from "prosody-ui"; + +interface TweetCardProps { + tweet: Tweet; + accent: string; + allowBookmarkRemoval?: boolean; + onRemoveBookmark?: (tweetId: string) => void; + onReply?: (tweetId: string, text: string) => Promise; + onToggleRetweet?: (tweetId: string, next: boolean) => Promise; + onToggleLike?: (tweetId: string, next: boolean) => Promise; + onToggleBookmark?: (tweetId: string, next: boolean) => Promise; + onOpenAuthor?: (author: Tweet["author"]) => void; + onOpenThread?: (tweet: Tweet) => void; + isQuote?: boolean; +} + +type ActionKind = "reply" | "retweet" | "like" | "bookmark" | null; + +export function TweetCard(props: TweetCardProps) { + const { + tweet, + accent, + allowBookmarkRemoval, + onRemoveBookmark, + onOpenAuthor, + onOpenThread, + isQuote, + } = props; + const timestamp = timeAgo(tweet.time); + + return ( +
+ {tweet.retweeted_by && ( +

onOpenAuthor?.(tweet.retweeted_by!.author)} + className="muted tiny retweet-banner" + > + Retweeted by {tweet.retweeted_by.author.name} + {timeAgo(tweet.retweeted_by.time)} +

+ )} +
+
+ {tweet.author.name} + +
+
+ + {allowBookmarkRemoval && onRemoveBookmark && ( + + )} +
+
+ {isQuote && tweet.replyingTo.length > 0 && ( +
+ replying to + {tweet.replyingTo.map((rt) => ( + @{rt.username} + ))} +
+ )} +
+
+ + {/*renderText(tweet.text)}*/} +
+ {!!tweet.media.pics.length && ( +
+ {tweet.media.pics.map((pic) => ( + Tweet media + ))} +
+ )} + {tweet.media.video.url && ( +
+ +
+ )} + {!!tweet.urls.length && ( +
+ {tweet.urls.map((link) => ( + + {link.displayUrl} + + ))} +
+ // end body + )} + {tweet.quoting && } +
+ {!isQuote && } +
+ ); +} + +type Token = { type: "text" | "mention" | "hashtag" | "url"; value: string }; + +function tokenize(text: string): Token[] { + const tokens: Token[] = []; + const regex = /(https?:\/\/\S+|@[A-Za-z0-9_]+|#[A-Za-z0-9_]+)/g; + let lastIndex = 0; + for (const match of text.matchAll(regex)) { + const value = match[0]; + const index = match.index ?? 0; + if (index > lastIndex) { + tokens.push({ type: "text", value: text.slice(lastIndex, index) }); + } + if (value.startsWith("http")) { + tokens.push({ type: "url", value }); + } else if (value.startsWith("@")) { + tokens.push({ type: "mention", value }); + } else if (value.startsWith("#")) { + tokens.push({ type: "hashtag", value }); + } + lastIndex = index + value.length; + } + if (lastIndex < text.length) { + tokens.push({ type: "text", value: text.slice(lastIndex) }); + } + return tokens; +} + +function renderText(text: string) { + return tokenize(text).map((token, index) => { + if (token.type === "text") { + return token.value.split("\n").map((segment, segmentIndex, arr) => ( + + {segment} + {segmentIndex < arr.length - 1 ?
: null} +
+ )); + } + if (token.type === "url") { + return ( + + {token.value} + + ); + } + if (token.type === "mention") { + return ( + + {token.value} + + ); + } + return ( + + {token.value} + + ); + }); +} + +function Actions(props: TweetCardProps) { + const { tweet, onReply, onToggleRetweet, onToggleLike, onToggleBookmark } = + props; + + const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`; + const [copied, setCopied] = useState(false); + const [pendingAction, setPendingAction] = useState(null); + + const copyLink = useCallback(async () => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(tweetUrl); + } else if (typeof document !== "undefined") { + const textarea = document.createElement("textarea"); + textarea.value = tweetUrl; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } catch (error) { + console.warn("Failed to copy tweet link", error); + } + }, [tweetUrl]); + + const executeAction = useCallback( + async (kind: ActionKind, fn?: () => Promise) => { + if (!fn) return; + setPendingAction(kind); + try { + await fn(); + } catch (error) { + console.error(`Failed to perform ${kind ?? "action"}`, error); + } finally { + setPendingAction(null); + } + }, + [], + ); + + const handleReply = useCallback(() => { + if (!onReply || typeof window === "undefined") return; + const prefill = `@${tweet.author.username} `; + const text = window.prompt("Reply", prefill); + if (!text || !text.trim()) return; + executeAction("reply", () => onReply(tweet.id, text.trim())); + }, [executeAction, onReply, tweet.author.username, tweet.id]); + + const handleRetweet = useCallback(() => { + if (!onToggleRetweet) return; + const next = !tweet.rted; + executeAction("retweet", () => onToggleRetweet(tweet.id, next)); + }, [executeAction, onToggleRetweet, tweet.id, tweet.rted]); + + const handleLike = useCallback(() => { + if (!onToggleLike) return; + const next = !tweet.liked; + executeAction("like", () => onToggleLike(tweet.id, next)); + }, [executeAction, onToggleLike, tweet.id, tweet.liked]); + + const handleBookmark = useCallback(() => { + if (!onToggleBookmark) return; + const next = !tweet.bookmarked; + executeAction("bookmark", () => onToggleBookmark(tweet.id, next)); + }, [executeAction, onToggleBookmark, tweet.bookmarked, tweet.id]); + + return ( +
+ + + + + +
+ ); +} + +function Quote({ tweet }: { tweet: Tweet }) { + return ( +
+ +
+ ); +} -- cgit v1.2.3