diff options
Diffstat (limited to 'packages/tweetdeck/src/components/TweetCard.tsx')
| -rw-r--r-- | packages/tweetdeck/src/components/TweetCard.tsx | 337 |
1 files changed, 337 insertions, 0 deletions
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<unknown>; + onToggleRetweet?: (tweetId: string, next: boolean) => Promise<unknown>; + onToggleLike?: (tweetId: string, next: boolean) => Promise<unknown>; + onToggleBookmark?: (tweetId: string, next: boolean) => Promise<unknown>; + 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 ( + <article + className="tweet-card" + lang={tweet.language} + style={{ borderColor: accent }} + > + {tweet.retweeted_by && ( + <p + onClick={() => onOpenAuthor?.(tweet.retweeted_by!.author)} + className="muted tiny retweet-banner" + > + Retweeted by {tweet.retweeted_by.author.name} + <span>{timeAgo(tweet.retweeted_by.time)}</span> + </p> + )} + <header> + <div className="author"> + <img + src={tweet.author.avatar} + alt={tweet.author.name} + loading="lazy" + /> + <button + type="button" + className="link-button author-meta" + onClick={() => onOpenAuthor?.(tweet.author)} + title={`View @${tweet.author.username}`} + disabled={!onOpenAuthor} + > + <strong>{tweet.author.name}</strong> + <span className="muted">@{tweet.author.username}</span> + </button> + </div> + <div className="meta"> + <button + type="button" + className="link-button muted" + onClick={() => onOpenThread?.(tweet)} + title="Open thread" + disabled={!onOpenThread} + > + {timestamp} + </button> + {allowBookmarkRemoval && onRemoveBookmark && ( + <button + className="ghost" + onClick={() => onRemoveBookmark(tweet.id)} + > + Remove + </button> + )} + </div> + </header> + {isQuote && tweet.replyingTo.length > 0 && ( + <div className="tweet-replying-to"> + <span>replying to</span> + {tweet.replyingTo.map((rt) => ( + <span key={rt.username}>@{rt.username}</span> + ))} + </div> + )} + <div className="tweet-body"> + <div className="tweet-text"> + <LangText lang={tweet.language} text={tweet.text} /> + {/*renderText(tweet.text)}*/} + </div> + {!!tweet.media.pics.length && ( + <div + className={`media-grid pics-${Math.min(tweet.media.pics.length, 4)}`} + > + {tweet.media.pics.map((pic) => ( + <img key={pic} src={pic} alt="Tweet media" loading="lazy" /> + ))} + </div> + )} + {tweet.media.video.url && ( + <div className="video-wrapper"> + <video controls preload="none" poster={tweet.media.video.thumb}> + <source src={tweet.media.video.url} /> + </video> + </div> + )} + {!!tweet.urls.length && ( + <div className="link-chips"> + {tweet.urls.map((link) => ( + <a + key={link.expandedUrl} + href={link.expandedUrl} + target="_blank" + rel="noreferrer" + > + {link.displayUrl} + </a> + ))} + </div> + // end body + )} + {tweet.quoting && <Quote tweet={tweet.quoting} />} + </div> + {!isQuote && <Actions {...props} />} + </article> + ); +} + +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) => ( + <span key={`${index}-${segmentIndex}`}> + {segment} + {segmentIndex < arr.length - 1 ? <br /> : null} + </span> + )); + } + if (token.type === "url") { + return ( + <a key={index} href={token.value} target="_blank" rel="noreferrer"> + {token.value} + </a> + ); + } + if (token.type === "mention") { + return ( + <span key={index} className="mention"> + {token.value} + </span> + ); + } + return ( + <span key={index} className="hashtag"> + {token.value} + </span> + ); + }); +} + +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<ActionKind>(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<unknown>) => { + 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 ( + <footer className="tweet-actions"> + <button + type="button" + className={`action ${pendingAction === "reply" ? "in-flight" : ""}`} + aria-label="Reply" + title="Reply" + disabled={pendingAction === "reply"} + onClick={handleReply} + > + <MessageCircle /> + <span className="sr-only">Reply</span> + </button> + <button + type="button" + className={`action retweet ${tweet.rted ? "active" : ""} ${pendingAction === "retweet" ? "in-flight" : ""}`} + aria-label={tweet.rted ? "Undo Retweet" : "Retweet"} + aria-pressed={tweet.rted} + title={tweet.rted ? "Undo Retweet" : "Retweet"} + disabled={pendingAction === "retweet"} + onClick={handleRetweet} + > + <Repeat2 /> + <span className="sr-only">Retweet</span> + </button> + <button + type="button" + className={`action like ${tweet.liked ? "active" : ""} ${pendingAction === "like" ? "in-flight" : ""}`} + aria-label={tweet.liked ? "Undo like" : "Like"} + aria-pressed={tweet.liked} + title={tweet.liked ? "Undo like" : "Like"} + disabled={pendingAction === "like"} + onClick={handleLike} + > + <Heart /> + <span className="sr-only">Like</span> + </button> + <button + type="button" + className={`action bookmark ${tweet.bookmarked ? "active" : ""} ${pendingAction === "bookmark" ? "in-flight" : ""}`} + aria-label={tweet.bookmarked ? "Remove bookmark" : "Bookmark"} + aria-pressed={tweet.bookmarked} + title={tweet.bookmarked ? "Remove bookmark" : "Bookmark"} + disabled={pendingAction === "bookmark"} + onClick={handleBookmark} + > + <Bookmark /> + <span className="sr-only">Bookmark</span> + </button> + <button + type="button" + className={`action ${copied ? "copied" : ""}`} + aria-label="Copy link" + title="Copy link" + onClick={copyLink} + > + <Link2 /> + <span className="sr-only">Copy link</span> + </button> + </footer> + ); +} + +function Quote({ tweet }: { tweet: Tweet }) { + return ( + <div className="tweet-quote"> + <TweetCard tweet={tweet} accent="" isQuote={true} /> + </div> + ); +} |
