summaryrefslogtreecommitdiff
path: root/packages/tweetdeck/src/components
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-11-23 01:12:53 +0700
committerpolwex <polwex@sortug.com>2025-11-23 01:12:53 +0700
commitcb1b56f5a0eddbf77446f415f2beda57c8305f85 (patch)
treed333ca5c143063af8ee1b2f9e2d1d25f8ef2007c /packages/tweetdeck/src/components
wut
Diffstat (limited to 'packages/tweetdeck/src/components')
-rw-r--r--packages/tweetdeck/src/components/AddColumnModal.tsx234
-rw-r--r--packages/tweetdeck/src/components/ChatCard.tsx56
-rw-r--r--packages/tweetdeck/src/components/ChatColumn.tsx62
-rw-r--r--packages/tweetdeck/src/components/ColumnBoard.tsx93
-rw-r--r--packages/tweetdeck/src/components/FullscreenColumn.tsx134
-rw-r--r--packages/tweetdeck/src/components/Sidebar.tsx153
-rw-r--r--packages/tweetdeck/src/components/TimelineColumn.tsx500
-rw-r--r--packages/tweetdeck/src/components/TweetCard.tsx337
8 files changed, 1569 insertions, 0 deletions
diff --git a/packages/tweetdeck/src/components/AddColumnModal.tsx b/packages/tweetdeck/src/components/AddColumnModal.tsx
new file mode 100644
index 0000000..b7098ed
--- /dev/null
+++ b/packages/tweetdeck/src/components/AddColumnModal.tsx
@@ -0,0 +1,234 @@
+import { useEffect, useMemo, useState } from "react";
+import type { DeckAccount, DeckColumn, DeckListsCache } from "../types/app";
+import type { TwitterList } from "../lib/fetching/types";
+
+interface AddColumnModalProps {
+ accounts: DeckAccount[];
+ isOpen: boolean;
+ onClose: () => void;
+ onAdd: (column: Omit<DeckColumn, "id">) => void;
+ activeAccountId?: string;
+ fetchLists: (accountId: string) => Promise<TwitterList[]>;
+ listsCache: DeckListsCache;
+}
+
+const columnOptions = [
+ {
+ id: "foryou",
+ label: "For You",
+ description: "Twitter's AI-ranked firehose",
+ },
+ {
+ id: "following",
+ label: "Following",
+ description: "Chronological feed of people you follow",
+ },
+ { id: "bookmarks", label: "Bookmarks", description: "Your saved deep dives" },
+ { id: "list", label: "List", description: "Curate a topic-specific stream" },
+ { id: "chat", label: "Chat", description: "Mentions & notifications" },
+] as const;
+
+export function AddColumnModal({
+ accounts,
+ activeAccountId,
+ isOpen,
+ onClose,
+ onAdd,
+ fetchLists,
+ listsCache,
+}: AddColumnModalProps) {
+ const [kind, setKind] = useState<DeckColumn["kind"]>("foryou");
+ const [accountId, setAccountId] = useState<string>(
+ activeAccountId || accounts[0]?.id || "",
+ );
+ const [title, setTitle] = useState("For You");
+ const [listId, setListId] = useState<string>("");
+ const [listOptions, setListOptions] = useState<TwitterList[]>([]);
+ const [listsLoading, setListsLoading] = useState(false);
+ const [listsError, setListsError] = useState<string | undefined>();
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setAccountId(activeAccountId || accounts[0]?.id || "");
+ }, [isOpen, activeAccountId, accounts]);
+
+ useEffect(() => {
+ setTitle(defaultTitle(kind));
+ }, [kind]);
+
+ useEffect(() => {
+ if (!isOpen || kind !== "list" || !accountId) return;
+ let mounted = true;
+ async function loadLists() {
+ try {
+ setListsLoading(true);
+ setListsError(undefined);
+ const cached = listsCache[accountId];
+ if (cached) {
+ setListOptions(cached);
+ return;
+ }
+ const lists = await fetchLists(accountId);
+ if (!mounted) return;
+ setListOptions(lists);
+ } catch (error) {
+ setListsError(
+ error instanceof Error ? error.message : "Failed to load lists",
+ );
+ } finally {
+ if (mounted) setListsLoading(false);
+ }
+ }
+ loadLists();
+ return () => {
+ mounted = false;
+ };
+ }, [accountId, fetchLists, isOpen, kind, listsCache]);
+
+ useEffect(() => {
+ if (!isOpen) return;
+ setListId("");
+ setListOptions([]);
+ }, [accountId, isOpen, kind]);
+
+ const selectedAccount = useMemo(
+ () => accounts.find((account) => account.id === accountId),
+ [accountId, accounts],
+ );
+
+ const canSubmit = Boolean(selectedAccount && (kind !== "list" || listId));
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!canSubmit || !selectedAccount) return;
+ onAdd({
+ accountId: selectedAccount.id,
+ account: selectedAccount.username || "",
+ kind,
+ title: title.trim() || defaultTitle(kind),
+ listId: listId || undefined,
+ listName:
+ kind === "list"
+ ? listOptions.find((list) => list.id === listId)?.name
+ : undefined,
+ });
+ onClose();
+ };
+
+ useEffect(() => {
+ if (!isOpen) return;
+ const handleKeyDown = (event: KeyboardEvent) => {
+ if (event.key === "Escape") onClose();
+ };
+ window.addEventListener("keydown", handleKeyDown);
+ return () => window.removeEventListener("keydown", handleKeyDown);
+ }, [isOpen, onClose]);
+
+ if (!isOpen) return null;
+
+ return (
+ <div
+ className="modal-backdrop"
+ role="dialog"
+ aria-modal="true"
+ onMouseDown={(event) => {
+ if (event.target === event.currentTarget) {
+ onClose();
+ }
+ }}
+ >
+ <div className="modal">
+ <header>
+ <div>
+ <p className="eyebrow">New column</p>
+ <h2>Design your stream</h2>
+ </div>
+ <button
+ className="ghost"
+ onClick={onClose}
+ aria-label="Close add column modal"
+ >
+ ×
+ </button>
+ </header>
+ <form onSubmit={handleSubmit} className="modal-body">
+ <label>
+ Account
+ <select
+ value={accountId}
+ onChange={(e) => setAccountId(e.target.value)}
+ >
+ {accounts.map((account) => (
+ <option value={account.id} key={account.id}>
+ {account.label}
+ {account.handle ? ` (@${account.handle})` : ""}
+ </option>
+ ))}
+ </select>
+ </label>
+ <div className="option-grid">
+ {columnOptions.map((option) => (
+ <button
+ type="button"
+ key={option.id}
+ className={`option ${kind === option.id ? "selected" : ""}`}
+ onClick={() => setKind(option.id)}
+ >
+ <div>
+ <strong>{option.label}</strong>
+ <p>{option.description}</p>
+ </div>
+ </button>
+ ))}
+ </div>
+ {kind === "list" && (
+ <label>
+ Choose a list
+ {listsError && <span className="error">{listsError}</span>}
+ <select
+ value={listId}
+ disabled={listsLoading}
+ onChange={(e) => setListId(e.target.value)}
+ >
+ <option value="" disabled>
+ {listsLoading ? "Loading lists..." : "Select a list"}
+ </option>
+ {listOptions.map((list) => (
+ <option key={list.id} value={list.id}>
+ {list.name} ({list.member_count})
+ </option>
+ ))}
+ </select>
+ </label>
+ )}
+ <label>
+ Column title
+ <input
+ value={title}
+ onChange={(e) => setTitle(e.target.value)}
+ placeholder="Custom title"
+ />
+ </label>
+ <button className="primary" type="submit" disabled={!canSubmit}>
+ Add column
+ </button>
+ </form>
+ </div>
+ </div>
+ );
+}
+
+function defaultTitle(kind: DeckColumn["kind"]) {
+ switch (kind) {
+ case "following":
+ return "Following";
+ case "bookmarks":
+ return "Bookmarks";
+ case "list":
+ return "List";
+ case "chat":
+ return "Chat";
+ default:
+ return "For You";
+ }
+}
diff --git a/packages/tweetdeck/src/components/ChatCard.tsx b/packages/tweetdeck/src/components/ChatCard.tsx
new file mode 100644
index 0000000..d7d885c
--- /dev/null
+++ b/packages/tweetdeck/src/components/ChatCard.tsx
@@ -0,0 +1,56 @@
+import type { TwitterNotification } from "../lib/fetching/types";
+import { timeAgo } from "../lib/utils/time";
+
+interface ChatCardProps {
+ notification: TwitterNotification;
+ accent: string;
+}
+
+export function ChatCard({ notification, accent }: ChatCardProps) {
+ const firstUser = Object.values(notification.users)[0];
+ const timestamp = timeAgo(Number(notification.timestampMs));
+
+ return (
+ <article className="chat-card" style={{ borderColor: accent }}>
+ <div className="chat-avatar">
+ {firstUser?.profile_image_url_https ? (
+ <img src={firstUser.profile_image_url_https} alt={firstUser.name} loading="lazy" />
+ ) : (
+ <span>{firstUser?.name?.[0] ?? "?"}</span>
+ )}
+ </div>
+ <div className="chat-body">
+ <header>
+ <strong>{firstUser?.name ?? "Notification"}</strong>
+ {firstUser?.screen_name && <span className="muted">@{firstUser.screen_name}</span>}
+ <span className="muted dot" aria-hidden="true">
+ •
+ </span>
+ <span className="muted">{timestamp}</span>
+ </header>
+ <p>{highlight(notification.message.text)}</p>
+ </div>
+ </article>
+ );
+}
+
+function highlight(text: string) {
+ const parts = text.split(/([@#][A-Za-z0-9_]+)/g);
+ return parts.map((part, index) => {
+ if (part.startsWith("@")) {
+ return (
+ <span key={index} className="mention">
+ {part}
+ </span>
+ );
+ }
+ if (part.startsWith("#")) {
+ return (
+ <span key={index} className="hashtag">
+ {part}
+ </span>
+ );
+ }
+ return <span key={index}>{part}</span>;
+ });
+}
diff --git a/packages/tweetdeck/src/components/ChatColumn.tsx b/packages/tweetdeck/src/components/ChatColumn.tsx
new file mode 100644
index 0000000..0c336e5
--- /dev/null
+++ b/packages/tweetdeck/src/components/ChatColumn.tsx
@@ -0,0 +1,62 @@
+import { useCallback, useEffect, useState } from "react";
+import type { DeckAccount, DeckColumn, ChatState } from "../types/app";
+import { twitterClient } from "../lib/client/twitterClient";
+import { ChatCard } from "./ChatCard";
+
+interface ChatColumnProps {
+ column: DeckColumn & { kind: "chat" };
+ account: DeckAccount;
+ onRemove: () => void;
+}
+
+export function ChatColumn({ column, account, onRemove }: ChatColumnProps) {
+ const [state, setState] = useState<ChatState>({ entries: [], isLoading: false });
+ const [error, setError] = useState<string | undefined>();
+
+ const refresh = useCallback(async () => {
+ setState(prev => ({ ...prev, isLoading: true }));
+ setError(undefined);
+ try {
+ const notifications = await twitterClient.notifications({ cookie: account.cookie });
+ setState({ entries: notifications, isLoading: false });
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Failed to refresh chat");
+ setState(prev => ({ ...prev, isLoading: false }));
+ }
+ }, [account.cookie]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh, account.id]);
+
+ return (
+ <article className="column">
+ <header>
+ <div>
+ <p className="eyebrow">Signal</p>
+ <h3>{column.title || "Chat"}</h3>
+ <p className="muted tiny">Mentions, follows and notifications for {account.label}</p>
+ </div>
+ <div className="column-actions">
+ <button className="ghost" onClick={refresh} aria-label="Refresh chat">
+ ↻
+ </button>
+ <button className="ghost" onClick={onRemove} aria-label="Remove column">
+ ×
+ </button>
+ </div>
+ </header>
+ {error && <p className="error">{error}</p>}
+ {state.isLoading && !state.entries.length ? (
+ <div className="column-loading">Loading…</div>
+ ) : (
+ <div className="chat-stack">
+ {state.entries.map(entry => (
+ <ChatCard key={entry.id} notification={entry} accent={account.accent} />
+ ))}
+ {!state.entries.length && <p className="muted">No recent notifications.</p>}
+ </div>
+ )}
+ </article>
+ );
+}
diff --git a/packages/tweetdeck/src/components/ColumnBoard.tsx b/packages/tweetdeck/src/components/ColumnBoard.tsx
new file mode 100644
index 0000000..87da3e1
--- /dev/null
+++ b/packages/tweetdeck/src/components/ColumnBoard.tsx
@@ -0,0 +1,93 @@
+import { useMemo } from "react";
+import type {
+ ColumnSnapshot,
+ ColumnState,
+ DeckAccount,
+ DeckColumn,
+ FullscreenState,
+} from "../types/app";
+import { TimelineColumn, type TimelineConfig } from "./TimelineColumn";
+import { ChatColumn } from "./ChatColumn";
+
+interface ColumnBoardProps {
+ columns: DeckColumn[];
+ accounts: DeckAccount[];
+ onRemove: (id: string) => void;
+ onStateChange: (columnId: string, state: ColumnState) => void;
+ onSnapshot: (columnId: string, snapshot: ColumnSnapshot) => void;
+ onEnterFullscreen: (payload: FullscreenState) => void;
+}
+
+export function ColumnBoard({
+ columns,
+ accounts,
+ onRemove,
+ onStateChange,
+ onSnapshot,
+ onEnterFullscreen,
+}: ColumnBoardProps) {
+ const accountMap = useMemo(
+ () => Object.fromEntries(accounts.map(account => [account.id, account])),
+ [accounts],
+ );
+
+ if (!columns.length) {
+ return (
+ <section className="empty-board">
+ <p className="eyebrow">No columns yet</p>
+ <h2>Build your deck</h2>
+ <p>Add some columns from the left panel to start streaming timelines.</p>
+ </section>
+ );
+ }
+
+ return (
+ <section className="column-board">
+ {columns.map((column, columnIndex) => {
+ const account = accountMap[column.accountId];
+ if (!account) {
+ return (
+ <div className="column missing" key={column.id}>
+ <header>
+ <div>
+ <p className="eyebrow">Account missing</p>
+ <h3>{column.title}</h3>
+ </div>
+ <button className="ghost" onClick={() => onRemove(column.id)}>
+ Remove
+ </button>
+ </header>
+ <p className="muted">The account for this column was removed.</p>
+ </div>
+ );
+ }
+ if (isChatColumn(column)) {
+ return (
+ <ChatColumn
+ key={column.id}
+ column={column}
+ account={account}
+ onRemove={() => onRemove(column.id)}
+ />
+ );
+ }
+ return (
+ <TimelineColumn
+ key={column.id}
+ column={column as TimelineConfig}
+ account={account}
+ onRemove={() => onRemove(column.id)}
+ onStateChange={onStateChange}
+ onSnapshot={onSnapshot}
+ onEnterFullscreen={onEnterFullscreen}
+ columnIndex={columnIndex}
+ />
+ );
+ })}
+ </section>
+ );
+}
+
+function isChatColumn(column: DeckColumn): column is DeckColumn & { kind: "chat" } {
+ return column.kind === "chat";
+}
diff --git a/packages/tweetdeck/src/components/FullscreenColumn.tsx b/packages/tweetdeck/src/components/FullscreenColumn.tsx
new file mode 100644
index 0000000..66959b8
--- /dev/null
+++ b/packages/tweetdeck/src/components/FullscreenColumn.tsx
@@ -0,0 +1,134 @@
+import { useEffect } from "react";
+import type { FullscreenState } from "../types/app";
+import { TweetCard } from "./TweetCard";
+
+interface FullscreenColumnProps {
+ state: FullscreenState;
+ onExit: () => void;
+ onNavigate: (step: number) => void;
+ onSwitchColumn: (step: number) => void;
+ hasPrevColumn: boolean;
+ hasNextColumn: boolean;
+ onAddColumn: () => void;
+}
+
+export function FullscreenColumn({
+ state,
+ onExit,
+ onNavigate,
+ onSwitchColumn,
+ hasPrevColumn,
+ hasNextColumn,
+ onAddColumn,
+}: FullscreenColumnProps) {
+ console.log({ state });
+ const tweet = state.tweets[state.index];
+ const hasTweets = state.tweets.length > 0;
+
+ useEffect(() => {
+ const handler = (event: KeyboardEvent) => {
+ if (event.key === "Escape") {
+ onExit();
+ return;
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ onNavigate(1);
+ }
+ if (event.key === "ArrowUp") {
+ event.preventDefault();
+ onNavigate(-1);
+ }
+ if (event.key === "ArrowRight") {
+ event.preventDefault();
+ if (hasNextColumn) {
+ onSwitchColumn(1);
+ } else {
+ onAddColumn();
+ }
+ }
+ if (event.key === "ArrowLeft") {
+ event.preventDefault();
+ if (hasPrevColumn) {
+ onSwitchColumn(-1);
+ }
+ }
+ };
+ window.addEventListener("keydown", handler);
+ return () => window.removeEventListener("keydown", handler);
+ }, [
+ onExit,
+ onNavigate,
+ onSwitchColumn,
+ hasPrevColumn,
+ hasNextColumn,
+ onAddColumn,
+ ]);
+
+ return (
+ <div className="fullscreen-overlay">
+ <button
+ className="ghost fullscreen-close"
+ onClick={onExit}
+ aria-label="Close fullscreen view"
+ >
+ ×
+ </button>
+ <div className="fullscreen-content">
+ <header>
+ <p className="eyebrow">
+ {state.columnLabel}@{state.column.account}
+ </p>
+ <p className="muted tiny">
+ {hasTweets
+ ? `${state.index + 1} / ${state.tweets.length}`
+ : "0 / 0"}
+ </p>
+ </header>
+ <div className="fullscreen-card">
+ {hasTweets && tweet ? (
+ <TweetCard tweet={tweet} accent={state.accent} />
+ ) : (
+ <div className="fullscreen-empty">
+ <p>No tweets loaded for this column yet.</p>
+ <p className="muted">
+ Try refreshing the column or exit fullscreen.
+ </p>
+ </div>
+ )}
+ </div>
+ <div className="fullscreen-controls">
+ <button
+ className="ghost"
+ onClick={() => onNavigate(-1)}
+ disabled={!hasTweets || state.index === 0}
+ >
+ ↑ Previous tweet
+ </button>
+ <button
+ className="ghost"
+ onClick={() => onNavigate(1)}
+ disabled={!hasTweets || state.index >= state.tweets.length - 1}
+ >
+ Next tweet ↓
+ </button>
+ </div>
+ <div className="fullscreen-column-controls">
+ <button
+ className="ghost"
+ onClick={() => onSwitchColumn(-1)}
+ disabled={!hasPrevColumn}
+ >
+ ← Previous column
+ </button>
+ <button
+ className="ghost"
+ onClick={() => (hasNextColumn ? onSwitchColumn(1) : onAddColumn())}
+ >
+ {hasNextColumn ? "Next column →" : "+ Add column →"}
+ </button>
+ </div>
+ </div>
+ </div>
+ );
+}
diff --git a/packages/tweetdeck/src/components/Sidebar.tsx b/packages/tweetdeck/src/components/Sidebar.tsx
new file mode 100644
index 0000000..3d9b85d
--- /dev/null
+++ b/packages/tweetdeck/src/components/Sidebar.tsx
@@ -0,0 +1,153 @@
+import { useEffect, useState } from "react";
+import type { DeckAccount } from "../types/app";
+import { twitterClient } from "@/lib/client/twitterClient";
+
+export interface NewAccountInput {
+ cookie: string;
+}
+
+interface SidebarProps {
+ accounts: DeckAccount[];
+ activeAccountId?: string;
+ onActivate: (id: string) => void;
+ onAddAccount: (payload: NewAccountInput) => void;
+ onRemoveAccount: (id: string) => void;
+ onAddColumn: () => void;
+}
+
+export function Sidebar({
+ accounts,
+ activeAccountId,
+ onActivate,
+ onAddAccount,
+ onRemoveAccount,
+ onAddColumn,
+}: SidebarProps) {
+ const [isAdding, setIsAdding] = useState(!accounts.length);
+ const [cookie, setCookie] = useState("");
+ const [showCookie, setShowCookie] = useState(false);
+
+ const handleSubmit = (event: React.FormEvent) => {
+ event.preventDefault();
+ if (!cookie.trim()) return;
+ onAddAccount({ cookie: decodeURIComponent(cookie.trim()) });
+ setCookie("");
+ setIsAdding(false);
+ };
+
+ return (
+ <aside className="sidebar">
+ <div>
+ <div className="brand">
+ <div className="brand-glow" />
+ <div>
+ <p className="eyebrow">Project Starling</p>
+ <h1>Open TweetDeck</h1>
+ <p className="tagline">
+ Multi-account Twitter cockpit powered by Bun.
+ </p>
+ </div>
+ </div>
+
+ <section className="sidebar-section">
+ <header>
+ <p className="eyebrow">Accounts</p>
+ <button className="ghost" onClick={() => setIsAdding((v) => !v)}>
+ {isAdding ? "Close" : "Add"}
+ </button>
+ </header>
+ {!accounts.length && !isAdding && (
+ <p className="muted">
+ Add a Twitter session cookie to start streaming timelines. You can
+ rename the account later once data loads.
+ </p>
+ )}
+ {accounts.map((account) => (
+ <div
+ role="button"
+ tabIndex={0}
+ key={account.id}
+ className={`account-chip ${account.id === activeAccountId ? "active" : ""}`}
+ onClick={() => onActivate(account.id)}
+ onKeyDown={(event) => {
+ if (event.key === "Enter" || event.key === " ") {
+ event.preventDefault();
+ onActivate(account.id);
+ }
+ }}
+ >
+ <span
+ className="chip-accent"
+ style={{ background: account.accent }}
+ />
+ <span>
+ <strong>{account.label}</strong>
+ {account.handle ? <small>@{account.handle}</small> : null}
+ </span>
+ <span className="chip-actions">
+ <button
+ type="button"
+ className="ghost"
+ onClick={(event) => {
+ event.stopPropagation();
+ onRemoveAccount(account.id);
+ }}
+ aria-label={`Remove ${account.label}`}
+ >
+ ×
+ </button>
+ </span>
+ </div>
+ ))}
+ {isAdding && (
+ <form className="account-form" onSubmit={handleSubmit}>
+ <label>
+ Twitter session cookie
+ <textarea
+ className={!showCookie ? "masked" : undefined}
+ placeholder="Paste the entire Cookie header"
+ value={cookie}
+ onChange={(e) => setCookie(e.target.value)}
+ rows={4}
+ />
+ </label>
+ <label className="checkbox">
+ <input
+ type="checkbox"
+ checked={showCookie}
+ onChange={(e) => setShowCookie(e.target.checked)}
+ />
+ Reveal cookie contents
+ </label>
+ <small className="muted">
+ Cookie stays in your browser via localStorage. It is only sent
+ to your Bun server when fetching timelines.
+ </small>
+ <button
+ className="primary"
+ type="submit"
+ disabled={!cookie.trim()}
+ >
+ Save account
+ </button>
+ </form>
+ )}
+ </section>
+ </div>
+
+ <div className="sidebar-footer">
+ <button
+ className="primary wide"
+ onClick={onAddColumn}
+ disabled={!accounts.length}
+ >
+ + Add column
+ </button>
+ <p className="muted tiny">
+ Need a cookie? Open x.com, inspect network requests and copy the
+ request `Cookie` header. Keep it secret.
+ </p>
+ </div>
+ </aside>
+ );
+}
diff --git a/packages/tweetdeck/src/components/TimelineColumn.tsx b/packages/tweetdeck/src/components/TimelineColumn.tsx
new file mode 100644
index 0000000..534b2dd
--- /dev/null
+++ b/packages/tweetdeck/src/components/TimelineColumn.tsx
@@ -0,0 +1,500 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import type {
+ ColumnSnapshot,
+ ColumnState,
+ ColumnView,
+ DeckAccount,
+ DeckColumn,
+ FullscreenState,
+ TimelineState,
+} from "../types/app";
+import type { Tweet } from "../lib/fetching/types";
+import { twitterClient } from "../lib/client/twitterClient";
+import { TweetCard } from "./TweetCard";
+
+export type TimelineConfig = DeckColumn & {
+ kind: Exclude<DeckColumn["kind"], "chat">;
+};
+
+type TimelineView = Extract<ColumnView, { type: "timeline" }>;
+
+interface TimelineColumnProps {
+ column: TimelineConfig;
+ account: DeckAccount;
+ onRemove: () => void;
+ onStateChange: (columnId: string, state: ColumnState) => void;
+ onSnapshot: (columnId: string, snapshot: ColumnSnapshot) => void;
+ onEnterFullscreen: (payload: FullscreenState) => void;
+ columnIndex: number;
+}
+
+export function TimelineColumn({
+ column,
+ account,
+ onRemove,
+ onStateChange,
+ onSnapshot,
+ onEnterFullscreen,
+ columnIndex,
+}: TimelineColumnProps) {
+ const [state, setState] = useState<TimelineState>({
+ tweets: [],
+ cursorTop: "",
+ cursorBottom: "",
+ isLoading: false,
+ isAppending: false,
+ });
+ const [error, setError] = useState<string | undefined>();
+ const [transitionDirection, setTransitionDirection] = useState<
+ "forward" | "backward" | null
+ >(null);
+
+ const baseView = useMemo(
+ () => createBaseView(column),
+ [column.kind, column.title, column.listId, column.listName],
+ );
+ const initialStack = useMemo<ColumnView[]>(() => {
+ return column.state?.stack?.length ? column.state.stack : [baseView];
+ }, [column.state, baseView]);
+
+ const [viewStack, setViewStack] = useState<ColumnView[]>(initialStack);
+
+ useEffect(() => {
+ setViewStack(initialStack);
+ }, [initialStack]);
+
+ const activeView = viewStack[viewStack.length - 1] ?? baseView;
+ const canGoBack = viewStack.length > 1;
+
+ const descriptor = useMemo(
+ () => describeView(column, activeView),
+ [column, activeView],
+ );
+
+ const handleMaximize = useCallback(() => {
+ onEnterFullscreen({
+ column: column,
+ columnLabel: descriptor.label,
+ accent: account.accent,
+ tweets: state.tweets,
+ index: 0,
+ columnIndex,
+ });
+ }, [
+ state.tweets,
+ onEnterFullscreen,
+ column.id,
+ descriptor.label,
+ account.accent,
+ columnIndex,
+ ]);
+
+ const handleAnimationEnd = useCallback(() => {
+ setTransitionDirection(null);
+ }, []);
+
+ const pushView = useCallback((view: ColumnView) => {
+ setTransitionDirection("forward");
+ setViewStack((prev) => [...prev, view]);
+ }, []);
+
+ const popView = useCallback(() => {
+ setViewStack((prev) => {
+ if (prev.length <= 1) return prev;
+ setTransitionDirection("backward");
+ return prev.slice(0, -1);
+ });
+ }, []);
+
+ useEffect(() => {
+ onStateChange(column.id, { stack: viewStack });
+ }, [column.id, onStateChange, viewStack]);
+
+ useEffect(() => {
+ onSnapshot(column.id, { tweets: state.tweets, label: descriptor.label });
+ }, [column.id, state.tweets, descriptor.label, onSnapshot]);
+
+ const fetchPage = useCallback(
+ async (cursor?: string) => {
+ const payload: Record<string, unknown> = { cookie: account.cookie };
+ //
+ let mode: string;
+
+ if (activeView.type === "thread") {
+ mode = "thread";
+ payload.tweetId = activeView.tweetId;
+ } else if (activeView.type === "user") {
+ mode = "user";
+ payload.userId = activeView.userId;
+ } else {
+ mode = activeView.mode;
+ if (activeView.mode === "list" && activeView.listId) {
+ payload.listId = activeView.listId;
+ }
+ }
+
+ if (cursor) payload.cursor = cursor;
+ return twitterClient.timeline(mode, payload);
+ },
+ [account.cookie, activeView],
+ );
+
+ const refresh = useCallback(async () => {
+ if (
+ activeView.type === "timeline" &&
+ activeView.mode === "list" &&
+ !activeView.listId
+ ) {
+ setError("Select a list for this column");
+ return;
+ }
+ setState((prev) => ({ ...prev, isLoading: true }));
+ setError(undefined);
+ try {
+ const data = await fetchPage(state.cursorTop);
+ setState({
+ tweets: data.tweets,
+ cursorTop: data.cursorTop,
+ cursorBottom: data.cursorBottom,
+ isLoading: false,
+ isAppending: false,
+ });
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Failed to load timeline");
+ setState((prev) => ({ ...prev, isLoading: false, isAppending: false }));
+ }
+ }, [activeView, fetchPage]);
+
+ useEffect(() => {
+ setState({
+ tweets: [],
+ cursorTop: "",
+ cursorBottom: "",
+ isLoading: true,
+ isAppending: false,
+ });
+ }, [activeView]);
+
+ useEffect(() => {
+ refresh();
+ }, [refresh]);
+
+ const loadMore = useCallback(async () => {
+ if (!state.cursorBottom) return;
+ setState((prev) => ({ ...prev, isAppending: true }));
+ try {
+ const data = await fetchPage(state.cursorBottom);
+ setState((prev) => ({
+ tweets: [...prev.tweets, ...data.tweets],
+ cursorTop: prev.cursorTop || data.cursorTop,
+ cursorBottom: data.cursorBottom,
+ isLoading: false,
+ isAppending: false,
+ }));
+ } catch (err) {
+ console.error(err);
+ setError(err instanceof Error ? err.message : "Failed to load more");
+ setState((prev) => ({ ...prev, isAppending: false }));
+ }
+ }, [fetchPage, state.cursorBottom, state.cursorTop]);
+
+ const updateTweetById = useCallback(
+ (tweetId: string, updater: (tweet: Tweet) => Tweet) => {
+ setState((prev) => ({
+ ...prev,
+ tweets: prev.tweets.map((tweet) =>
+ tweet.id === tweetId ? updater(tweet) : tweet,
+ ),
+ }));
+ },
+ [],
+ );
+
+ const handleRemoveBookmark = useCallback(
+ async (tweetId: string) => {
+ try {
+ await twitterClient.removeBookmark({ cookie: account.cookie, tweetId });
+ setState((prev) => ({
+ ...prev,
+ tweets: prev.tweets.filter((tweet) => tweet.id !== tweetId),
+ }));
+ } catch (err) {
+ setError(
+ err instanceof Error
+ ? err.message
+ : "Unable to remove bookmark right now",
+ );
+ }
+ },
+ [account.cookie],
+ );
+
+ const likeTweet = useCallback(
+ async (tweetId: string, nextState: boolean) => {
+ try {
+ await twitterClient.like(tweetId, {
+ cookie: account.cookie,
+ undo: !nextState,
+ });
+ updateTweetById(tweetId, (tweet) => ({ ...tweet, liked: nextState }));
+ } catch (err) {
+ setError(err instanceof Error ? err.message : "Unable to update like");
+ }
+ },
+ [account.cookie, updateTweetById],
+ );
+
+ const retweet = useCallback(
+ async (tweetId: string, nextState: boolean) => {
+ try {
+ await twitterClient.retweet(tweetId, {
+ cookie: account.cookie,
+ undo: !nextState,
+ });
+ updateTweetById(tweetId, (tweet) => ({ ...tweet, rted: nextState }));
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Unable to update retweet",
+ );
+ }
+ },
+ [account.cookie, updateTweetById],
+ );
+
+ const bookmarkTweet = useCallback(
+ async (tweetId: string, nextState: boolean) => {
+ try {
+ await twitterClient.bookmark(tweetId, {
+ cookie: account.cookie,
+ undo: !nextState,
+ });
+ if (column.kind === "bookmarks" && !nextState) {
+ setState((prev) => ({
+ ...prev,
+ tweets: prev.tweets.filter((tweet) => tweet.id !== tweetId),
+ }));
+ } else {
+ updateTweetById(tweetId, (tweet) => ({
+ ...tweet,
+ bookmarked: nextState,
+ }));
+ }
+ } catch (err) {
+ setError(
+ err instanceof Error ? err.message : "Unable to update bookmark",
+ );
+ }
+ },
+ [account.cookie, column.kind, updateTweetById],
+ );
+
+ const replyToTweet = useCallback(
+ (tweetId: string, text: string) =>
+ twitterClient.reply(tweetId, {
+ cookie: account.cookie,
+ text,
+ }),
+ [account.cookie],
+ );
+
+ const handleOpenAuthor = useCallback(
+ (author: Tweet["author"]) => {
+ if (!author?.id) return;
+ pushView({
+ type: "user",
+ userId: author.id,
+ username: author.username,
+ title: author.name || `@${author.username}`,
+ });
+ },
+ [pushView],
+ );
+
+ const handleOpenThread = useCallback(
+ (tweet: Tweet) => {
+ pushView({
+ type: "thread",
+ tweetId: tweet.id,
+ title: `Thread · ${tweet.author.name}`,
+ });
+ },
+ [pushView],
+ );
+
+ const breadcrumbs = useMemo(
+ () => viewStack.map((view) => labelForView(view)).join(" / "),
+ [viewStack],
+ );
+
+ return (
+ <article className="column">
+ <header>
+ <div>
+ <p className="eyebrow">{descriptor.badge}</p>
+ <h3>{descriptor.label}</h3>
+ <p className="muted tiny">
+ {descriptor.description} · {account.label}
+ </p>
+ {viewStack.length > 1 && <p className="muted tiny">{breadcrumbs}</p>}
+ </div>
+ <div className="column-actions">
+ <button
+ className="ghost"
+ onClick={handleMaximize}
+ aria-label="Maximize column"
+ >
+ ⤢
+ </button>
+ {canGoBack && (
+ <button className="ghost" onClick={popView} aria-label="Go back">
+ ←
+ </button>
+ )}
+ <button
+ className="ghost"
+ onClick={refresh}
+ aria-label="Refresh column"
+ >
+ ↻
+ </button>
+ <button
+ className="ghost"
+ onClick={onRemove}
+ aria-label="Remove column"
+ >
+ ×
+ </button>
+ </div>
+ </header>
+ <div
+ className={`column-content ${transitionDirection ? `slide-${transitionDirection}` : ""}`}
+ onAnimationEnd={handleAnimationEnd}
+ >
+ {error && <p className="error">{error}</p>}
+ {state.isLoading && !state.tweets.length ? (
+ <div className="column-loading">Loading…</div>
+ ) : (
+ <div className="tweet-stack">
+ {state.tweets
+ .filter((t) => t.language === "th")
+ .slice(0, 10)
+ .map((tweet) => (
+ <TweetCard
+ key={tweet.id}
+ tweet={tweet}
+ accent={account.accent}
+ allowBookmarkRemoval={column.kind === "bookmarks"}
+ onRemoveBookmark={handleRemoveBookmark}
+ onToggleLike={likeTweet}
+ onToggleRetweet={retweet}
+ onToggleBookmark={bookmarkTweet}
+ onReply={replyToTweet}
+ onOpenAuthor={handleOpenAuthor}
+ onOpenThread={handleOpenThread}
+ />
+ ))}
+ {!state.tweets.length && !state.isLoading && (
+ <p className="muted">No tweets yet. Try refreshing.</p>
+ )}
+ {state.cursorBottom ? (
+ <div className="load-more-row">
+ <button
+ className="ghost"
+ disabled={state.isAppending}
+ onClick={loadMore}
+ >
+ {state.isAppending ? "Loading…" : "Load more"}
+ </button>
+ </div>
+ ) : (
+ state.tweets.length > 0 && (
+ <div className="load-more-row">
+ <p className="muted">End of feed</p>
+ </div>
+ )
+ )}
+ </div>
+ )}
+ </div>
+ </article>
+ );
+}
+
+function createBaseView(column: TimelineConfig): TimelineView {
+ return {
+ type: "timeline",
+ mode: column.kind,
+ title: column.title || describeTimeline(column.kind).label,
+ listId: column.listId,
+ listName: column.listName,
+ };
+}
+
+function labelForView(view: ColumnView): string {
+ if (view.type === "timeline") {
+ return view.title || describeTimeline(view.mode).label;
+ }
+ if (view.type === "user") {
+ return view.title || `@${view.username}`;
+ }
+ return view.title || "Thread";
+}
+
+function describeView(column: TimelineConfig, view: ColumnView) {
+ if (view.type === "timeline") {
+ const base = describeTimeline(view.mode);
+ if (view.mode === "list" && view.listName) {
+ return {
+ ...base,
+ label: view.title || view.listName,
+ description: `Tweets from ${view.listName}`,
+ };
+ }
+ return {
+ ...base,
+ label: view.title || base.label,
+ };
+ }
+ if (view.type === "user") {
+ return {
+ label: view.title || `@${view.username}`,
+ badge: "Profile",
+ description: `Posts from @${view.username}`,
+ };
+ }
+ return {
+ label: view.title || "Thread",
+ badge: "Thread",
+ description: "Deep dive into the conversation",
+ };
+}
+
+function describeTimeline(kind: TimelineConfig["kind"]) {
+ switch (kind) {
+ case "following":
+ return {
+ label: "Following",
+ badge: "Chrono",
+ description: "Latest posts from people you follow",
+ };
+ case "bookmarks":
+ return {
+ label: "Bookmarks",
+ badge: "Library",
+ description: "Saved gems queued for later",
+ };
+ case "list":
+ return {
+ label: "List",
+ badge: "Curated",
+ description: "Tweets from a Twitter List",
+ };
+ default:
+ return {
+ label: "For You",
+ badge: "Ranked",
+ description: "AI-ranked home timeline",
+ };
+ }
+}
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>
+ );
+}