summaryrefslogtreecommitdiff
path: root/packages/tweetdeck/src/components/TweetCard.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tweetdeck/src/components/TweetCard.tsx')
-rw-r--r--packages/tweetdeck/src/components/TweetCard.tsx337
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>
+ );
+}