summaryrefslogtreecommitdiff
path: root/packages/tweetdeck/src
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tweetdeck/src')
-rw-r--r--packages/tweetdeck/src/APITester.tsx39
-rw-r--r--packages/tweetdeck/src/App.tsx310
-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
-rw-r--r--packages/tweetdeck/src/frontend.tsx26
-rw-r--r--packages/tweetdeck/src/hooks/usePersistentState.ts39
-rw-r--r--packages/tweetdeck/src/index.html16
-rw-r--r--packages/tweetdeck/src/index.ts242
-rw-r--r--packages/tweetdeck/src/lib/client/twitterClient.ts75
-rw-r--r--packages/tweetdeck/src/lib/fetching/python.ts52
-rw-r--r--packages/tweetdeck/src/lib/fetching/twitter-api.ts1178
-rw-r--r--packages/tweetdeck/src/lib/fetching/types.ts596
-rw-r--r--packages/tweetdeck/src/lib/utils/id.ts4
-rw-r--r--packages/tweetdeck/src/lib/utils/time.ts18
-rw-r--r--packages/tweetdeck/src/logo.svg1
-rw-r--r--packages/tweetdeck/src/react.svg8
-rw-r--r--packages/tweetdeck/src/styles/index.css835
-rw-r--r--packages/tweetdeck/src/styles/normalize.css379
-rw-r--r--packages/tweetdeck/src/types/app.ts92
25 files changed, 5479 insertions, 0 deletions
diff --git a/packages/tweetdeck/src/APITester.tsx b/packages/tweetdeck/src/APITester.tsx
new file mode 100644
index 0000000..fd2af48
--- /dev/null
+++ b/packages/tweetdeck/src/APITester.tsx
@@ -0,0 +1,39 @@
+import { useRef, type FormEvent } from "react";
+
+export function APITester() {
+ const responseInputRef = useRef<HTMLTextAreaElement>(null);
+
+ const testEndpoint = async (e: FormEvent<HTMLFormElement>) => {
+ e.preventDefault();
+
+ try {
+ const form = e.currentTarget;
+ const formData = new FormData(form);
+ const endpoint = formData.get("endpoint") as string;
+ const url = new URL(endpoint, location.href);
+ const method = formData.get("method") as string;
+ const res = await fetch(url, { method });
+
+ const data = await res.json();
+ responseInputRef.current!.value = JSON.stringify(data, null, 2);
+ } catch (error) {
+ responseInputRef.current!.value = String(error);
+ }
+ };
+
+ return (
+ <div className="api-tester">
+ <form onSubmit={testEndpoint} className="endpoint-row">
+ <select name="method" className="method">
+ <option value="GET">GET</option>
+ <option value="PUT">PUT</option>
+ </select>
+ <input type="text" name="endpoint" defaultValue="/api/hello" className="url-input" placeholder="/api/hello" />
+ <button type="submit" className="send-button">
+ Send
+ </button>
+ </form>
+ <textarea ref={responseInputRef} readOnly placeholder="Response will appear here..." className="response-area" />
+ </div>
+ );
+}
diff --git a/packages/tweetdeck/src/App.tsx b/packages/tweetdeck/src/App.tsx
new file mode 100644
index 0000000..924ff9a
--- /dev/null
+++ b/packages/tweetdeck/src/App.tsx
@@ -0,0 +1,310 @@
+import { useCallback, useEffect, useMemo, useState } from "react";
+import "./styles/normalize.css";
+import "./styles/index.css";
+import { Sidebar, type NewAccountInput } from "./components/Sidebar";
+import { ColumnBoard } from "./components/ColumnBoard";
+import { AddColumnModal } from "./components/AddColumnModal";
+import { usePersistentState } from "./hooks/usePersistentState";
+import type {
+ ColumnSnapshot,
+ ColumnState,
+ DeckAccount,
+ DeckColumn,
+ DeckListsCache,
+ FullscreenState,
+} from "./types/app";
+import type { Tweet } from "./lib/fetching/types";
+import { generateId } from "./lib/utils/id";
+import { twitterClient } from "./lib/client/twitterClient";
+import { FullscreenColumn } from "./components/FullscreenColumn";
+
+const ACCOUNTS_KEY = "tweetdeck.accounts";
+const COLUMNS_KEY = "tweetdeck.columns";
+
+export function App() {
+ const [accounts, setAccounts] = usePersistentState<DeckAccount[]>(
+ ACCOUNTS_KEY,
+ [],
+ );
+ const [columns, setColumns] = usePersistentState<DeckColumn[]>(
+ COLUMNS_KEY,
+ [],
+ );
+ const [listsCache, setListsCache] = useState<DeckListsCache>({});
+ const [activeAccountId, setActiveAccountId] = useState<string | undefined>(
+ () => accounts[0]?.id,
+ );
+ const [isModalOpen, setModalOpen] = useState(false);
+ const [toast, setToast] = useState<string | null>(null);
+ const [fullscreen, setFullscreen] = useState<FullscreenState | null>(null);
+ const [columnSnapshots, setColumnSnapshots] = useState<
+ Record<string, ColumnSnapshot>
+ >({});
+
+ useEffect(() => {
+ if (!activeAccountId) {
+ const firstAccount = accounts[0];
+ if (firstAccount) {
+ setActiveAccountId(firstAccount.id);
+ }
+ }
+ }, [accounts, activeAccountId]);
+
+ useEffect(() => {
+ const acs = accounts.filter((a) => !a.avatar || !a.username);
+ console.log({ acs });
+ const nacs = acs.map(async (acc) => {
+ const our = await twitterClient.own({ cookie: acc.cookie });
+ const nacc = {
+ ...acc,
+ handle: our.username,
+ label: our.name,
+ avatar: our.avatar,
+ };
+ return nacc;
+ });
+ Promise.all(nacs)
+ .then((acs) => setAccounts(acs))
+ .catch((err) => console.error(err));
+ }, []);
+
+ const handleAddAccount = useCallback(
+ (payload: NewAccountInput) => {
+ const label = `Session ${accounts.length + 1}`;
+ const account: DeckAccount = {
+ id: generateId(),
+ label,
+ accent: randomAccent(),
+ cookie: payload.cookie.trim(),
+ createdAt: Date.now(),
+ };
+ setAccounts((prev) => [...prev, account]);
+ setActiveAccountId(account.id);
+ setToast(`${account.label} is ready`);
+ },
+ [accounts.length, setAccounts],
+ );
+
+ const handleRemoveAccount = useCallback(
+ (accountId: string) => {
+ setAccounts((prev) => prev.filter((account) => account.id !== accountId));
+ setColumns((prev) =>
+ prev.filter((column) => column.accountId !== accountId),
+ );
+ setListsCache((prev) => {
+ const next = { ...prev };
+ delete next[accountId];
+ return next;
+ });
+ if (activeAccountId === accountId) {
+ setActiveAccountId(undefined);
+ }
+ },
+ [activeAccountId, setAccounts, setColumns],
+ );
+
+ const handleAddColumn = useCallback(
+ (column: Omit<DeckColumn, "id">) => {
+ const nextColumn = { ...column, id: generateId() };
+ setColumns((prev) => [...prev, nextColumn]);
+ setToast(`${nextColumn.title} added to deck`);
+ },
+ [setColumns],
+ );
+
+ const handleRemoveColumn = useCallback(
+ (id: string) => {
+ setColumns((prev) => prev.filter((column) => column.id !== id));
+ },
+ [setColumns],
+ );
+
+ const fetchLists = useCallback(
+ async (accountId: string) => {
+ const account = accounts.find((acc) => acc.id === accountId);
+ if (!account) throw new Error("Account not found");
+ if (listsCache[accountId]) return listsCache[accountId];
+ console.log({ listsCache });
+ const lists = await twitterClient.lists({ cookie: account.cookie });
+ console.log({ lists });
+ setListsCache((prev) => ({ ...prev, [accountId]: lists }));
+ return lists;
+ },
+ [accounts, listsCache],
+ );
+
+ const handleColumnStateChange = useCallback(
+ (columnId: string, state: ColumnState) => {
+ setColumns((prev) =>
+ prev.map((column) =>
+ column.id === columnId ? { ...column, state } : column,
+ ),
+ );
+ },
+ [setColumns],
+ );
+
+ const handleColumnSnapshot = useCallback(
+ (columnId: string, snapshot: ColumnSnapshot) => {
+ setColumnSnapshots((prev) => {
+ const existing = prev[columnId];
+ if (
+ existing &&
+ existing.tweets === snapshot.tweets &&
+ existing.label === snapshot.label
+ ) {
+ return prev;
+ }
+ return {
+ ...prev,
+ [columnId]: { tweets: snapshot.tweets, label: snapshot.label },
+ };
+ });
+ },
+ [],
+ );
+
+ const openFullscreen = useCallback(
+ (payload: FullscreenState) => {
+ const snapshot = columnSnapshots[payload.column.id];
+ setFullscreen({
+ ...payload,
+ tweets: snapshot?.tweets ?? payload.tweets,
+ columnLabel: snapshot?.label ?? payload.columnLabel,
+ });
+ },
+ [columnSnapshots],
+ );
+
+ useEffect(() => {
+ if (!fullscreen) return;
+ const snapshot = columnSnapshots[fullscreen.column.id];
+ if (!snapshot) return;
+ if (
+ snapshot.tweets === fullscreen.tweets &&
+ snapshot.label === fullscreen.columnLabel
+ ) {
+ return;
+ }
+ setFullscreen((prev) => {
+ if (!prev) return prev;
+ if (prev.column.id !== fullscreen.column.id) return prev;
+ const tweets = snapshot.tweets;
+ const index = Math.min(prev.index, Math.max(tweets.length - 1, 0));
+ return {
+ ...prev,
+ tweets,
+ columnTitle: snapshot.label,
+ index,
+ };
+ });
+ }, [columnSnapshots, fullscreen]);
+
+ const content = useMemo(
+ () => (
+ <ColumnBoard
+ columns={columns}
+ accounts={accounts}
+ onRemove={handleRemoveColumn}
+ onStateChange={handleColumnStateChange}
+ onSnapshot={handleColumnSnapshot}
+ onEnterFullscreen={openFullscreen}
+ />
+ ),
+ [
+ accounts,
+ columns,
+ handleRemoveColumn,
+ handleColumnStateChange,
+ handleColumnSnapshot,
+ openFullscreen,
+ ],
+ );
+
+ return (
+ <div className="app-shell">
+ <Sidebar
+ accounts={accounts}
+ activeAccountId={activeAccountId}
+ onActivate={(id) => setActiveAccountId(id)}
+ onAddAccount={handleAddAccount}
+ onRemoveAccount={handleRemoveAccount}
+ onAddColumn={() => setModalOpen(true)}
+ />
+
+ <main>{content}</main>
+
+ <AddColumnModal
+ accounts={accounts}
+ activeAccountId={activeAccountId}
+ isOpen={isModalOpen}
+ onClose={() => setModalOpen(false)}
+ onAdd={handleAddColumn}
+ fetchLists={fetchLists}
+ listsCache={listsCache}
+ />
+
+ {toast && (
+ <div className="toast" onAnimationEnd={() => setToast(null)}>
+ {toast}
+ </div>
+ )}
+
+ {fullscreen && (
+ <FullscreenColumn
+ state={fullscreen}
+ onExit={() => setFullscreen(null)}
+ onNavigate={(step) => {
+ setFullscreen((prev) => {
+ if (!prev) return prev;
+ if (!prev.tweets.length) return prev;
+ const nextIndex = Math.min(
+ prev.tweets.length - 1,
+ Math.max(0, prev.index + step),
+ );
+ if (nextIndex === prev.index) return prev;
+ return { ...prev, index: nextIndex };
+ });
+ }}
+ hasPrevColumn={fullscreen.columnIndex > 0}
+ hasNextColumn={fullscreen.columnIndex < columns.length - 1}
+ onSwitchColumn={(direction) => {
+ setFullscreen((prev) => {
+ if (!prev) return prev;
+ const nextIndex = prev.columnIndex + direction;
+ if (nextIndex < 0) return prev;
+ if (nextIndex >= columns.length) {
+ setModalOpen(true);
+ return prev;
+ }
+ const nextColumn = columns[nextIndex];
+ if (!nextColumn) return prev;
+ const snapshot = columnSnapshots[nextColumn.id];
+ const account = accounts.find(
+ (acc) => acc.id === nextColumn.accountId,
+ );
+ const tweets = snapshot?.tweets ?? [];
+ return {
+ column: nextColumn,
+ columnIndex: nextIndex,
+ columnLabel: snapshot?.label ?? nextColumn.title,
+ accent: account?.accent ?? prev.accent,
+ tweets,
+ index: 0,
+ };
+ });
+ }}
+ onAddColumn={() => setModalOpen(true)}
+ />
+ )}
+ </div>
+ );
+}
+
+function randomAccent(): string {
+ const palette = ["#7f5af0", "#2cb67d", "#f25f4c", "#f0a500", "#19a7ce"];
+ const pick = palette[Math.floor(Math.random() * palette.length)];
+ return pick ?? "#7f5af0";
+}
+
+export default App;
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>
+ );
+}
diff --git a/packages/tweetdeck/src/frontend.tsx b/packages/tweetdeck/src/frontend.tsx
new file mode 100644
index 0000000..5691535
--- /dev/null
+++ b/packages/tweetdeck/src/frontend.tsx
@@ -0,0 +1,26 @@
+/**
+ * This file is the entry point for the React app, it sets up the root
+ * element and renders the App component to the DOM.
+ *
+ * It is included in `src/index.html`.
+ */
+
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "./App";
+
+const elem = document.getElementById("root")!;
+const app = (
+ // <StrictMode>
+ <App />
+ // </StrictMode>
+);
+
+if (import.meta.hot) {
+ // With hot module reloading, `import.meta.hot.data` is persisted.
+ const root = (import.meta.hot.data.root ??= createRoot(elem));
+ root.render(app);
+} else {
+ // The hot module reloading API is not available in production.
+ createRoot(elem).render(app);
+}
diff --git a/packages/tweetdeck/src/hooks/usePersistentState.ts b/packages/tweetdeck/src/hooks/usePersistentState.ts
new file mode 100644
index 0000000..7465f53
--- /dev/null
+++ b/packages/tweetdeck/src/hooks/usePersistentState.ts
@@ -0,0 +1,39 @@
+import { useEffect, useRef, useState } from "react";
+
+type Initializer<T> = T | (() => T);
+
+const isBrowser = typeof window !== "undefined";
+
+function readFromStorage<T>(key: string, fallback: Initializer<T>): T {
+ if (!isBrowser) {
+ return typeof fallback === "function" ? (fallback as () => T)() : fallback;
+ }
+ try {
+ const raw = window.localStorage.getItem(key);
+ if (raw) {
+ return JSON.parse(raw) as T;
+ }
+ } catch (error) {
+ console.warn("Failed to parse localStorage value", { key, error });
+ }
+ return typeof fallback === "function" ? (fallback as () => T)() : fallback;
+}
+
+export function usePersistentState<T>(key: string, initial: Initializer<T>) {
+ const initialRef = useRef<T | null>(null);
+ if (initialRef.current === null) {
+ initialRef.current = readFromStorage<T>(key, initial);
+ }
+ const [value, setValue] = useState<T>(() => initialRef.current as T);
+
+ useEffect(() => {
+ if (!isBrowser) return;
+ try {
+ window.localStorage.setItem(key, JSON.stringify(value));
+ } catch (error) {
+ console.warn("Failed to write localStorage value", { key, error });
+ }
+ }, [key, value]);
+
+ return [value, setValue] as const;
+}
diff --git a/packages/tweetdeck/src/index.html b/packages/tweetdeck/src/index.html
new file mode 100644
index 0000000..fa411d2
--- /dev/null
+++ b/packages/tweetdeck/src/index.html
@@ -0,0 +1,16 @@
+<!doctype html>
+<html lang="en">
+
+<head>
+ <meta charset="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <link rel="icon" type="image/svg+xml" href="./logo.svg" />
+ <title>Sordeck</title>
+</head>
+
+<body>
+ <div id="root"></div>
+ <script type="module" src="./frontend.tsx"></script>
+</body>
+
+</html> \ No newline at end of file
diff --git a/packages/tweetdeck/src/index.ts b/packages/tweetdeck/src/index.ts
new file mode 100644
index 0000000..ccc86e7
--- /dev/null
+++ b/packages/tweetdeck/src/index.ts
@@ -0,0 +1,242 @@
+import { serve } from "bun";
+import index from "./index.html";
+import { TwitterApiService } from "./lib/fetching/twitter-api";
+
+const jsonResponse = (data: unknown, init?: ResponseInit) =>
+ Response.json(data, init);
+
+async function withTwitterService(
+ req: Request,
+ handler: (
+ service: TwitterApiService,
+ payload: Record<string, any>,
+ ) => Promise<Response>,
+) {
+ try {
+ const payload = await req.json();
+ const cookie = payload?.cookie;
+
+ if (!cookie || typeof cookie !== "string") {
+ return jsonResponse(
+ { error: "Missing twitter auth cookie" },
+ { status: 400 },
+ );
+ }
+
+ const service = new TwitterApiService(cookie);
+ return await handler(service, payload);
+ } catch (error) {
+ console.error("Twitter API route error", error);
+ return jsonResponse(
+ { error: error instanceof Error ? error.message : "Unknown error" },
+ { status: 500 },
+ );
+ }
+}
+
+const server = serve({
+ port: 3010,
+ routes: {
+ // Serve index.html for all unmatched routes.
+ "/*": index,
+
+ "/api/hello": {
+ async GET(req) {
+ return Response.json({
+ message: "Hello, world!",
+ method: "GET",
+ });
+ },
+ async PUT(req) {
+ return Response.json({
+ message: "Hello, world!",
+ method: "PUT",
+ });
+ },
+ },
+
+ "/api/hello/:name": async (req) => {
+ const name = req.params.name;
+ return Response.json({
+ message: `Hello, ${name}!`,
+ });
+ },
+
+ "/api/twitter/our": {
+ async POST(req) {
+ return withTwitterService(req, async (service, _payload) => {
+ return jsonResponse(await service.findOwn());
+ });
+ },
+ },
+ "/api/twitter/timeline/:mode": {
+ async POST(req) {
+ const { mode } = req.params;
+ console.log("fetching tweets", mode);
+ return withTwitterService(req, async (service, payload) => {
+ const cursor =
+ typeof payload.cursor === "string" ? payload.cursor : undefined;
+ switch (mode) {
+ case "foryou":
+ return jsonResponse(await service.fetchForyou(cursor));
+ case "following":
+ return jsonResponse(await service.fetchFollowing(cursor));
+ case "bookmarks":
+ return jsonResponse(await service.fetchBookmarks(cursor));
+ case "list":
+ if (!payload.listId) {
+ return jsonResponse(
+ { error: "Missing listId" },
+ { status: 400 },
+ );
+ }
+ return jsonResponse(
+ await service.fetchList(String(payload.listId), cursor),
+ );
+ case "user":
+ if (!payload.userId) {
+ return jsonResponse(
+ { error: "Missing userId" },
+ { status: 400 },
+ );
+ }
+ return jsonResponse(
+ await service.fetchUserTweets(String(payload.userId), cursor),
+ );
+ case "thread":
+ if (!payload.tweetId) {
+ return jsonResponse(
+ { error: "Missing tweetId" },
+ { status: 400 },
+ );
+ }
+ return jsonResponse(
+ await service.fetchThread(String(payload.tweetId), cursor),
+ );
+ default:
+ return jsonResponse(
+ { error: `Unknown timeline mode: ${mode}` },
+ { status: 400 },
+ );
+ }
+ });
+ },
+ },
+
+ "/api/twitter/lists": {
+ async POST(req) {
+ return withTwitterService(req, async (service) => {
+ return jsonResponse(await service.fetchLists());
+ });
+ },
+ },
+
+ "/api/twitter/notifications": {
+ async POST(req) {
+ return withTwitterService(req, async (service, payload) => {
+ const cursor =
+ typeof payload.cursor === "string" ? payload.cursor : undefined;
+ return jsonResponse(await service.fetchNotifications(cursor));
+ });
+ },
+ },
+
+ "/api/twitter/bookmarks/remove": {
+ async POST(req) {
+ return withTwitterService(req, async (service, payload) => {
+ const tweetId = payload?.tweetId;
+ if (!tweetId) {
+ return jsonResponse({ error: "Missing tweetId" }, { status: 400 });
+ }
+ await service.removeBookmark(String(tweetId));
+ return jsonResponse({ status: "ok" });
+ });
+ },
+ },
+
+ "/api/twitter/tweets/:tweetId/like": {
+ async POST(req) {
+ const { tweetId } = req.params;
+ return withTwitterService(req, async (service, payload) => {
+ if (!tweetId) {
+ return jsonResponse({ error: "Missing tweetId" }, { status: 400 });
+ }
+ const undo = Boolean(payload?.undo);
+ if (undo) {
+ await service.removeLike(tweetId);
+ } else {
+ await service.addLike(tweetId);
+ }
+ return jsonResponse({ status: "ok" });
+ });
+ },
+ },
+
+ "/api/twitter/tweets/:tweetId/retweet": {
+ async POST(req) {
+ const { tweetId } = req.params;
+ return withTwitterService(req, async (service, payload) => {
+ if (!tweetId) {
+ return jsonResponse({ error: "Missing tweetId" }, { status: 400 });
+ }
+ const undo = Boolean(payload?.undo);
+ if (undo) {
+ await service.removeRT(tweetId);
+ } else {
+ await service.addRT(tweetId);
+ }
+ return jsonResponse({ status: "ok" });
+ });
+ },
+ },
+
+ "/api/twitter/tweets/:tweetId/bookmark": {
+ async POST(req) {
+ const { tweetId } = req.params;
+ return withTwitterService(req, async (service, payload) => {
+ if (!tweetId) {
+ return jsonResponse({ error: "Missing tweetId" }, { status: 400 });
+ }
+ const undo = Boolean(payload?.undo);
+ if (undo) {
+ await service.removeBookmark(tweetId);
+ } else {
+ await service.addBookmark(tweetId);
+ }
+ return jsonResponse({ status: "ok" });
+ });
+ },
+ },
+
+ "/api/twitter/tweets/:tweetId/reply": {
+ async POST(req) {
+ const { tweetId } = req.params;
+ return withTwitterService(req, async (service, payload) => {
+ if (!tweetId) {
+ return jsonResponse({ error: "Missing tweetId" }, { status: 400 });
+ }
+ const text =
+ typeof payload?.text === "string" ? payload.text.trim() : "";
+ if (!text) {
+ return jsonResponse(
+ { error: "Missing reply text" },
+ { status: 400 },
+ );
+ }
+ await service.createTweet(text, { reply: tweetId });
+ return jsonResponse({ status: "ok" });
+ });
+ },
+ },
+ },
+
+ development: process.env.NODE_ENV !== "production" && {
+ // Enable browser hot reloading in development
+ hmr: true,
+
+ // Echo console logs from the browser to the server
+ console: true,
+ },
+});
+
+console.log(`🚀 Server running at ${server.url}`);
diff --git a/packages/tweetdeck/src/lib/client/twitterClient.ts b/packages/tweetdeck/src/lib/client/twitterClient.ts
new file mode 100644
index 0000000..b8914b5
--- /dev/null
+++ b/packages/tweetdeck/src/lib/client/twitterClient.ts
@@ -0,0 +1,75 @@
+import {
+ type TwitterUser,
+ type TweetList,
+ type TwitterList,
+ type TwitterNotification,
+} from "../fetching/types";
+
+const headers = { "Content-Type": "application/json" };
+
+async function postJson<T>(
+ url: string,
+ body: Record<string, unknown>,
+): Promise<T> {
+ const res = await fetch(url, {
+ method: "POST",
+ headers,
+ body: JSON.stringify(body),
+ });
+
+ if (!res.ok) {
+ const text = await res.text();
+ throw new Error(text || `Request failed (${res.status})`);
+ }
+
+ return (await res.json()) as T;
+}
+
+export const twitterClient = {
+ own(payload: Record<string, unknown>) {
+ // return postJson<TwitterUser>(`/api/twitter/our`, payload);
+ },
+ timeline(mode: string, payload: Record<string, unknown>) {
+ console.log("fetching tweets", mode);
+ return postJson<TweetList>(`/api/twitter/timeline/${mode}`, payload);
+ },
+ lists(payload: Record<string, unknown>) {
+ return postJson<TwitterList[]>("/api/twitter/lists", payload);
+ },
+ notifications(payload: Record<string, unknown>) {
+ return postJson<TwitterNotification[]>(
+ "/api/twitter/notifications",
+ payload,
+ );
+ },
+ removeBookmark(payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ "/api/twitter/bookmarks/remove",
+ payload,
+ );
+ },
+ like(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/like`,
+ payload,
+ );
+ },
+ retweet(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/retweet`,
+ payload,
+ );
+ },
+ bookmark(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/bookmark`,
+ payload,
+ );
+ },
+ reply(tweetId: string, payload: Record<string, unknown>) {
+ return postJson<{ status: string }>(
+ `/api/twitter/tweets/${tweetId}/reply`,
+ payload,
+ );
+ },
+};
diff --git a/packages/tweetdeck/src/lib/fetching/python.ts b/packages/tweetdeck/src/lib/fetching/python.ts
new file mode 100644
index 0000000..760cb2c
--- /dev/null
+++ b/packages/tweetdeck/src/lib/fetching/python.ts
@@ -0,0 +1,52 @@
+import python from "bun_python";
+
+export class TransactionIdGenerator {
+ private initialHtmlContent!: string;
+ private client_transaction: any;
+ private cookie: string;
+ private headers: any;
+
+ private BeautifulSoup: any;
+ private ClientTransaction: any;
+
+ constructor(cookie: string) {
+ this.cookie = cookie;
+ }
+ public async init() {
+ const genheaders = await python.import("x_client_transaction.utils")
+ .generate_headers;
+ const hs = genheaders();
+ this.headers = { ...hs, Cookie: this.cookie };
+ const currentUrl = "https://x.com";
+ const response = await fetch(currentUrl, { headers: this.headers });
+ const html = await response.text();
+ this.initialHtmlContent = html;
+ }
+
+ public async getTransactionId(method: string, path: string): Promise<string> {
+ if (!this.BeautifulSoup || !this.ClientTransaction) {
+ this.BeautifulSoup = await python.import("bs4").BeautifulSoup;
+ this.ClientTransaction = await python.import("x_client_transaction")
+ .ClientTransaction;
+ }
+
+ if (!this.client_transaction) {
+ const soup = this.BeautifulSoup(this.initialHtmlContent, "lxml");
+ const onDemand = await python.import("x_client_transaction.utils")
+ .get_ondemand_file_url;
+ const file = onDemand(soup);
+ const ondemand_res = await fetch(file, {
+ method: "GET",
+ headers: this.headers,
+ });
+ const ondemand_text = await ondemand_res.text();
+ this.client_transaction = this.ClientTransaction(soup, ondemand_text);
+ }
+
+ const transaction_id = this.client_transaction.generate_transaction_id(
+ method,
+ path,
+ );
+ return transaction_id;
+ }
+}
diff --git a/packages/tweetdeck/src/lib/fetching/twitter-api.ts b/packages/tweetdeck/src/lib/fetching/twitter-api.ts
new file mode 100644
index 0000000..8ea4709
--- /dev/null
+++ b/packages/tweetdeck/src/lib/fetching/twitter-api.ts
@@ -0,0 +1,1178 @@
+import type {
+ Tweet,
+ TweetList,
+ TweetResult,
+ TwitterBookmarkResponse,
+ TwitterList,
+ TwitterListTimelineResponse,
+ TwitterListsManagementResponse,
+ TwitterNotification,
+ TwitterNotificationsTimelineResponse,
+ TimelineEntry,
+ TwitterTimelineResponse,
+ TwitterTweetDetailResponse,
+ TwitterUserTweetsResponse,
+ APITwitterList,
+ TweetWithVisibilityResult,
+ TwitterUser,
+ RTMetadata,
+ TwitterProfilesResponse,
+ UserResult,
+} from "./types";
+import { TransactionIdGenerator } from "./python";
+
+const TWITTER_INTERNAL_API_KEY =
+ "Bearer AAAAAAAAAAAAAAAAAAAAANRILgAAAAAAnNwIzUejRCOuH5E6I8xnZz4puTs%3D1Zv7ttfk8LF81IUq16cHjhLTvJu4FA33AGWWjCpTnA";
+
+export class TwitterApiService {
+ cookie: string;
+ constructor(cookie: string) {
+ this.cookie = cookie;
+ }
+ // Read endpoints
+ private static readonly BOOKMARKS_URL = new URL(
+ "https://x.com/i/api/graphql/C7CReOA1R0PwKorWAxnNUQ/Bookmarks",
+ );
+ private static readonly FOLLOWING_URL = new URL(
+ "https://x.com/i/api/graphql/fhqL7Cgmvax9jOhRMOhWpA/HomeLatestTimeline",
+ );
+ private static readonly FORYOU_URL = new URL(
+ "https://x.com/i/api/graphql/sMNeM4wvNe4JnRUZ2jd2zw/HomeTimeline",
+ );
+ private static readonly LISTS_URL = new URL(
+ "https://x.com/i/api/graphql/wLXb5F6pIEOrYtTjXFLQsA/ListsManagementPageTimeline",
+ );
+ private static readonly LIST_URL = new URL(
+ "https://x.com/i/api/graphql/p-5fXSlJaR-aZ4UUBdPMAg/ListLatestTweetsTimeline",
+ );
+ private static readonly NOTIFICATIONS_URL = new URL(
+ "https://api.x.com/1.1/notifications/timeline.json",
+ );
+ private static readonly USERDATA_URL = new URL(
+ "https://x.com/i/api/graphql/2AtIgw7Kz26sV6sEBrQjSQ/UsersByRestIds",
+ );
+ private static readonly USER_URL = new URL(
+ "https://x.com/i/api/graphql/Le1DChzkS7ioJH_yEPMi3w/UserTweets",
+ );
+ private static readonly THREAD_URL = new URL(
+ "https://x.com/i/api/graphql/aTYmkYpjWyvUyrinVWSiYA/TweetDetail",
+ );
+
+ private static readonly HEADERS = {
+ "User-Agent":
+ "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/138.0.0.0 Safari/537.36",
+ Accept: "*/*",
+ Referer: "https://x.com/i/bookmarks",
+ "Content-Type": "application/json",
+ "X-Twitter-Auth-Type": "OAuth2Session",
+ "X-Twitter-Active-User": "yes",
+ "X-Twitter-Client-Language": "en",
+ };
+
+ private get csrfToken() {
+ return this.cookie.match(/ct0=([^;]+)/)?.[1];
+ }
+
+ private buildHeaders(extra?: Record<string, string>) {
+ const headers: Record<string, string> = {
+ ...TwitterApiService.HEADERS,
+ Authorization: TWITTER_INTERNAL_API_KEY,
+ Cookie: this.cookie,
+ ...(extra ?? {}),
+ };
+ const csrf = this.csrfToken;
+ if (csrf) headers["X-Csrf-Token"] = csrf;
+ return headers;
+ }
+
+ private async request(url: URL, init: RequestInit) {
+ const headers = this.buildHeaders(init.headers as Record<string, string>);
+
+ const xcs = new TransactionIdGenerator("");
+ await xcs.init();
+ const xclientid = await xcs.getTransactionId(init.method!, url.pathname);
+ headers["X-Client-Transaction-Id"] = xclientid;
+ const response = await fetch(url, { ...init, headers });
+ if (!response.ok) {
+ console.log(headers);
+ console.log(response);
+ throw new Error(
+ `Twitter API request failed: ${response.status} ${response.statusText}`,
+ );
+ }
+
+ return await response.json();
+ }
+ async postCall(url: URL, payload: Record<string, unknown>) {
+ const body = JSON.stringify(payload);
+ return this.request(url, {
+ method: "POST",
+ body,
+ headers: { "Content-Type": "application/json" },
+ });
+ }
+
+ async getCall(url: URL) {
+ return this.request(url, { method: "GET" });
+ }
+ async findOwn(): Promise<TwitterUser> {
+ const cookie = decodeURIComponent(this.cookie);
+ const match = cookie.match(/twid=u=([^;]+)/);
+ const id = match![1]!;
+ const profs = await this.fetchProfiles([id]);
+ return profs[0]!;
+ }
+ async fetchProfiles(userIds: string[]): Promise<TwitterUser[]> {
+ const variables = {
+ userIds,
+ };
+ const features = {
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ responsive_web_profile_redirect_enabled: false,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ };
+ const url = new URL(TwitterApiService.USERDATA_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+
+ const data = (await this.getCall(url)) as TwitterProfilesResponse;
+ const users = data?.data?.users;
+ if (!users) throw new Error("error parsing ids");
+ const parsed = users.map((u) =>
+ TwitterApiService.extractUserData(u.result),
+ );
+ return parsed;
+ }
+ async fetchForyou(cursor?: string): Promise<TweetList> {
+ const payload = {
+ variables: {
+ count: 50,
+ cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA",
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ withCommunity: true,
+ seenTweetIds: [],
+ },
+ features: {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ },
+ queryId: "sMNeM4wvNe4JnRUZ2jd2zw",
+ };
+ const data = (await this.postCall(
+ TwitterApiService.FORYOU_URL,
+ payload,
+ )) as TwitterTimelineResponse;
+ try {
+ return TwitterApiService.parseTimelineResponse(data, "foryou");
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("wtf");
+ }
+ }
+
+ async fetchFollowing(cursor?: string) {
+ const payload = {
+ variables: {
+ count: 50,
+ cursor: cursor || "DAABCgABGv-ytnDAJxEKAAIa_qVgidrhcwgAAwAAAAEAAA",
+ includePromotedContent: true,
+ latestControlAvailable: true,
+ requestContext: "launch",
+ seenTweetIds: [],
+ },
+ features: {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ },
+ queryId: "fhqL7Cgmvax9jOhRMOhWpA",
+ };
+ const data = (await this.postCall(
+ TwitterApiService.FOLLOWING_URL,
+ payload,
+ )) as TwitterTimelineResponse;
+ try {
+ return TwitterApiService.parseTimelineResponse(data, "following");
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("wtf");
+ }
+ }
+
+ async fetchList(listId: string, cursor?: string): Promise<TweetList> {
+ const variables = { listId, count: 20, cursor };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.LIST_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterListTimelineResponse;
+ return TwitterApiService.parseListTimelineResponse(data);
+ }
+ async fetchLists(): Promise<TwitterList[]> {
+ const variables = { count: 100 };
+
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.LISTS_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterListsManagementResponse;
+ try {
+ return TwitterApiService.parseListsManagementResponse(data);
+ } catch (e) {
+ console.error(e);
+ // console.dir(data.data.viewer.list_management_timeline, { depth: null });
+ throw e;
+ }
+ }
+
+ async fetchNotifications(cursor?: string): Promise<TwitterNotification[]> {
+ const variables: Record<string, string | number> = {
+ include_profile_interstitial_type: 1,
+ include_blocking: 1,
+ include_blocked_by: 1,
+ include_followed_by: 1,
+ include_want_retweets: 1,
+ include_mute_edge: 1,
+ include_can_dm: 1,
+ include_can_media_tag: 1,
+ include_ext_has_nft_avatar: 1,
+ include_ext_is_blue_verified: 1,
+ include_ext_verified_type: 1,
+ include_ext_profile_image_shape: 1,
+ skip_status: 1,
+ cards_platform: "Web-12",
+ include_cards: 1,
+ include_composer_source: "true",
+ include_ext_alt_text: "true",
+ include_ext_limited_action_results: "false",
+ include_reply_count: 1,
+ tweet_mode: "extended",
+ include_entities: "true",
+ include_user_entities: "true",
+ include_ext_media_color: "true",
+ include_ext_media_availability: "true",
+ include_ext_sensitive_media_warning: "true",
+ include_ext_trusted_friends_metadata: "true",
+ send_error_codes: "true",
+ simple_quoted_tweet: "true",
+ count: 40,
+ ext: "mediaStats,highlightedLabel,hasNftAvatar,voiceInfo,enrichments,superFollowMetadata,unmentionInfo,editControl,vibe",
+ };
+ if (cursor) {
+ variables.cursor = cursor;
+ }
+ const url = new URL(TwitterApiService.NOTIFICATIONS_URL);
+ Object.keys(variables).forEach((key) =>
+ url.searchParams.set(key, variables[key]!.toString()),
+ );
+
+ const data = (await this.getCall(
+ url,
+ )) as TwitterNotificationsTimelineResponse;
+
+ return TwitterApiService.parseNotificationsResponse(data);
+ }
+ async fetchUserTweets(userId: string, cursor?: string): Promise<TweetList> {
+ const variables = {
+ userId,
+ count: 50,
+ includePromotedContent: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withVoice: true,
+ };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const fieldToggles = { withArticlePlainText: true };
+
+ const url = new URL(TwitterApiService.USER_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
+ const data = (await this.getCall(url)) as TwitterUserTweetsResponse;
+
+ try {
+ return TwitterApiService.parseUserTweetsResponse(data);
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("bad");
+ }
+ }
+ async fetchThread(tweetId: string, cursor?: string): Promise<TweetList> {
+ const variables = {
+ focalTweetId: tweetId,
+ referrer: "profile",
+ with_rux_injections: false,
+ rankingMode: "Relevance",
+ includePromotedContent: true,
+ withCommunity: true,
+ withQuickPromoteEligibilityTweetFields: true,
+ withBirdwatchNotes: true,
+ withVoice: true,
+ };
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const fieldToggles = {
+ withArticleRichContentState: true,
+ withArticlePlainText: true,
+ withGrokAnalyze: true,
+ withDisallowedReplyControls: false,
+ };
+
+ const url = new URL(TwitterApiService.THREAD_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ url.searchParams.set("fieldToggles", JSON.stringify(fieldToggles));
+ const data = (await this.getCall(url)) as TwitterTweetDetailResponse;
+
+ try {
+ return TwitterApiService.parseThreadResponse(data);
+ } catch (e) {
+ console.error(e);
+ console.dir(data, { depth: null });
+ throw new Error("Bad");
+ }
+ }
+ async fetchBookmarks(cursor?: string): Promise<TweetList> {
+ const variables = {
+ count: 40,
+ cursor: cursor || null,
+ includePromotedContent: true,
+ };
+
+ const features = {
+ rweb_video_screen_enabled: false,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ creator_subscriptions_tweet_preview_api_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ articles_preview_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ const url = new URL(TwitterApiService.BOOKMARKS_URL);
+ url.searchParams.set("variables", JSON.stringify(variables));
+ url.searchParams.set("features", JSON.stringify(features));
+ const data = (await this.getCall(url)) as TwitterBookmarkResponse;
+
+ return TwitterApiService.parseBookmarkResponse(data);
+ }
+
+ private static parseUserTweetsResponse(
+ response: TwitterUserTweetsResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+ const instructions =
+ response.data?.user?.result?.timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing user feed");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing user feed");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ if (tweet) {
+ tweets.push(tweet);
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseThreadResponse(
+ response: TwitterTweetDetailResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.threaded_conversation_with_injections_v2?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing thread ");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing thread feed");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ if (tweet) {
+ tweets.push(tweet);
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseBookmarkResponse(
+ response: TwitterBookmarkResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.bookmark_timeline_v2?.timeline?.instructions || [];
+
+ for (const instruction of instructions) {
+ if (
+ instruction.type === "TimelineAddEntries" ||
+ instruction.type === "TimelineReplaceEntry" ||
+ instruction.type === "TimelineShowMoreEntries"
+ ) {
+ const entries = [
+ ...(instruction.entries || []),
+ ...(((instruction as { entry?: TimelineEntry }).entry
+ ? [(instruction as { entry?: TimelineEntry }).entry!]
+ : []) as TimelineEntry[]),
+ ];
+
+ for (const entry of entries) {
+ const content = entry.content;
+
+ if (content?.entryType === "TimelineTimelineItem") {
+ const tweetData = content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const bookmark = this.extractTweetData(tweetData);
+ if (bookmark) {
+ tweets.push(bookmark);
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+ }
+
+ return {
+ tweets,
+ cursorTop,
+ cursorBottom,
+ };
+ }
+
+ private static parseTimelineResponse(
+ response: TwitterTimelineResponse,
+ type: "foryou" | "following",
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions = response.data?.home?.home_timeline_urt?.instructions;
+ response.data?.home_timeline_urt?.instructions;
+
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing thread ");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing thread feed");
+
+ for (const entry of instruction.entries) {
+ // if (entry.content.entryType.includes("ursor")) console.log(entry);
+ // if (entry.content.entryType.includes("odule"))
+ // console.log("module", entry);
+
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content?.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ try {
+ const tweet = this.extractTweetData(tweetData);
+ tweets.push(tweet);
+ } catch (e) {
+ console.error(e);
+ // console.dir(entry, { depth: null });
+ }
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Gap" // TODO wtf???
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorTop, cursorBottom };
+ }
+
+ private static parseListTimelineResponse(
+ response: TwitterListTimelineResponse,
+ ): TweetList {
+ const tweets: Tweet[] = [];
+ let cursorBottom = "";
+ let cursorTop = "";
+
+ const instructions =
+ response.data?.list?.tweets_timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing tw timeline res");
+
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing tw timeline res");
+ for (const entry of instruction.entries) {
+ if (entry.content?.entryType === "TimelineTimelineItem") {
+ const tweetData = entry.content.itemContent?.tweet_results?.result;
+ if (tweetData) {
+ const tweet = this.extractTweetData(tweetData);
+ tweets.push(tweet);
+ }
+ } else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Bottom"
+ )
+ cursorBottom = entry.content?.value || "";
+ else if (
+ entry.content?.entryType === "TimelineTimelineCursor" &&
+ entry.content?.cursorType === "Top"
+ )
+ cursorTop = entry.content?.value || "";
+ }
+ }
+ }
+
+ return { tweets, cursorBottom, cursorTop };
+ }
+
+ private static parseListsManagementResponse(
+ response: TwitterListsManagementResponse,
+ ): TwitterList[] {
+ const lists: TwitterList[] = [];
+ const instructions =
+ response.data?.viewer?.list_management_timeline?.timeline?.instructions;
+ if (!instructions || !Array.isArray(instructions))
+ throw new Error("error parsing tw lists res");
+ for (const instruction of instructions) {
+ if (instruction.type === "TimelineAddEntries") {
+ if (!instruction?.entries || !Array.isArray(instruction.entries))
+ throw new Error("error parsing tw lists res 2");
+ for (const entry of instruction.entries) {
+ console.log("entry", entry.content.__typename);
+ // if (entry.content.__typename === "TimelineTimelineModule")
+ if (entry.content.__typename === "TimelineTimelineCursor") {
+ console.dir(entry, { depth: null });
+ continue;
+ }
+ // entry.content.entryType can be TimelineTimelineModule, TimelineTimelineCursor,
+ // entry.entryId can be list-to-follow-<bignumber> which si the recommended lists that show on top
+ // or owned-subscribed-list-module-<smolnum> which is what we want
+ const listList = entry?.content?.items;
+ if (!listList || !Array.isArray(listList))
+ throw new Error("error parsing tw lists res 3");
+ for (const list of listList) {
+ lists.push(this.parseListResponse(list.item.itemContent.list));
+ }
+ }
+ }
+ }
+ return lists;
+ }
+
+ private static parseListResponse(res: APITwitterList): TwitterList {
+ const { name, id_str, member_count, subscriber_count } = res;
+ const creator = res.user_results.result.core.name;
+ return { name, id: id_str, member_count, subscriber_count, creator };
+ }
+ private static parseNotificationsResponse(
+ response: TwitterNotificationsTimelineResponse,
+ ): TwitterNotification[] {
+ const notifications: TwitterNotification[] = [];
+ const timelineNotifs =
+ response.timeline.instructions[0]?.addEntries?.entries;
+ if (!timelineNotifs || !Array.isArray(timelineNotifs))
+ throw new Error("error parsing notifs");
+ for (const entry of timelineNotifs) {
+ const notificationId = entry.content.notification.id;
+ const notification = response.globalObjects.notifications[notificationId];
+ if (notification) {
+ notifications.push(notification);
+ }
+ }
+ return notifications;
+ }
+ private static extractUserData(userResults: UserResult): TwitterUser {
+ return {
+ id: userResults.rest_id,
+ avatar:
+ userResults.avatar?.image_url ||
+ userResults.legacy?.profile_image_url_https!,
+ name: userResults.legacy?.name || userResults.core?.name!,
+ username:
+ userResults.legacy?.screen_name || userResults.core?.screen_name!,
+ };
+ }
+
+ private static extractTweetData(
+ tweetRes: TweetResult | TweetWithVisibilityResult,
+ rter: RTMetadata | null = null,
+ ): Tweet {
+ const tweetData =
+ tweetRes.__typename === "Tweet" ? tweetRes : tweetRes.tweet;
+
+ console.log({ tweetData });
+ let quoting: Tweet | null = null;
+ let retweeted_by = rter;
+ const legacy = tweetData?.legacy;
+ const userResults = tweetData?.core?.user_results?.result;
+ // if (!legacy || !userResults) throw new Error("no legacy??");
+ if (!legacy) throw new Error("no legacy??");
+ if (!userResults) throw new Error("no userResults??");
+
+ const author = this.extractUserData(userResults);
+ const time = new Date(legacy.created_at).getTime();
+
+ // is_rt
+ if (legacy.retweeted_status_result) {
+ const d = legacy.retweeted_status_result.result;
+ if (!d) console.log("bad rt", tweetData);
+ return this.extractTweetData(legacy.retweeted_status_result.result, {
+ author,
+ time,
+ });
+ }
+ //
+
+ // quotes
+ if (
+ tweetData.quoted_status_result &&
+ tweetData.quoted_status_result.result
+ ) {
+ // const d = tweetData.quoted_status_result.result;
+ // if (!d) console.log("bad quote", tweetData);
+ quoting = this.extractTweetData(tweetData.quoted_status_result.result);
+ }
+ //
+ const mediaEntities = legacy.entities.media;
+ // if (!mediaEntities) {
+ // console.log("no media");
+ // console.dir(legacy.entities, { depth: null });
+ // }
+ const media = (mediaEntities || []).reduce(
+ (
+ acc: { pics: string[]; video: { thumb: string; url: string } },
+ item,
+ ) => {
+ if (item.type === "photo" && item.media_url_https) {
+ return {
+ pics: [...acc.pics, item.media_url_https],
+ video: acc.video,
+ };
+ }
+ if (item.type === "video" && item.video_info?.variants) {
+ const video = item.video_info.variants.reduce(
+ (
+ acc: { bitrate?: number; url: string },
+ vid: { bitrate?: number; url: string },
+ ) => {
+ if (!vid.bitrate) return acc;
+ if (!acc.bitrate || vid.bitrate > acc.bitrate) return vid;
+ return acc;
+ },
+ { url: "" },
+ );
+ return {
+ pics: acc.pics,
+ video: {
+ url: video.url!,
+ thumb: item.media_url_https!,
+ },
+ };
+ }
+ return acc;
+ },
+ { pics: [] as string[], video: { thumb: "", url: "" } },
+ );
+ if (legacy.full_text.includes("your computers"))
+ console.dir(tweetRes, { depth: null });
+ const replyingTo = legacy.entities?.user_mentions
+ ? legacy.entities.user_mentions.map((m) => ({
+ name: m.name,
+ username: m.screen_name,
+ id: m.id_str,
+ }))
+ : [];
+
+ return {
+ id: tweetData.rest_id,
+ text: legacy.display_text_range
+ ? legacy.full_text.slice(
+ legacy.display_text_range[0],
+ legacy.display_text_range[1] + 1,
+ )
+ : legacy.full_text!,
+ language: legacy.lang || "en",
+ author,
+ time,
+ urls:
+ legacy.entities?.urls?.map(
+ (url: { expanded_url: string; display_url: string }) => ({
+ expandedUrl: url.expanded_url,
+ displayUrl: url.display_url,
+ }),
+ ) || [],
+ media,
+ hashtags:
+ legacy.entities?.hashtags?.map((tag: { text: string }) => tag.text) ||
+ [],
+ quoting,
+ retweeted_by,
+ liked: legacy.favorited,
+ bookmarked: legacy.bookmarked,
+ rted: legacy.retweeted,
+ replyingTo,
+ };
+ }
+
+ async fetchAllBookmarks(): Promise<Tweet[]> {
+ const allBookmarks: Tweet[] = [];
+ let cursor: string | undefined;
+ let hasMore = true;
+
+ while (hasMore) {
+ try {
+ const result = await this.fetchBookmarks(cursor);
+ allBookmarks.push(...result.tweets);
+ cursor = result.cursorBottom;
+
+ // Rate limiting - be nice to Twitter's API
+ await new Promise((resolve) => setTimeout(resolve, 1000));
+ } catch (error) {
+ console.error("Error fetching bookmarks batch:", error);
+ break;
+ }
+ }
+
+ return allBookmarks;
+ }
+ // WRITE ENDPOINTS
+ // TODO Grok stuff
+ //
+ // TODO add images, polls etc.
+ // quote is the URL https;//x.com/{user}/status/{id} of the quoted tweet;
+ async createTweet(text: string, topts: { quote?: string; reply?: string }) {
+ const queryId = `-fU2A9SG7hdlzUdOh04POw`;
+ const url = `https://x.com/i/api/graphql/${queryId}/createTweet`;
+ let variables: any = {
+ tweet_text: text,
+ dark_request: false,
+ media: {
+ media_entities: [],
+ possibly_sensitive: false,
+ },
+ semantic_annotation_ids: [],
+ disallowed_reply_options: null,
+ };
+ const features = {
+ premium_content_api_read_enabled: false,
+ communities_web_enable_tweet_community_results_fetch: true,
+ c9s_tweet_anatomy_moderator_badge_enabled: true,
+ responsive_web_grok_analyze_button_fetch_trends_enabled: false,
+ responsive_web_grok_analyze_post_followups_enabled: true,
+ responsive_web_jetfuel_frame: true,
+ responsive_web_grok_share_attachment_enabled: true,
+ responsive_web_edit_tweet_api_enabled: true,
+ graphql_is_translatable_rweb_tweet_is_translatable_enabled: true,
+ view_counts_everywhere_api_enabled: true,
+ longform_notetweets_consumption_enabled: true,
+ responsive_web_twitter_article_tweet_consumption_enabled: true,
+ tweet_awards_web_tipping_enabled: false,
+ responsive_web_grok_show_grok_translated_post: false,
+ responsive_web_grok_analysis_button_from_backend: true,
+ creator_subscriptions_quote_tweet_preview_enabled: false,
+ longform_notetweets_rich_text_read_enabled: true,
+ longform_notetweets_inline_media_enabled: true,
+ payments_enabled: false,
+ profile_label_improvements_pcf_label_in_post_enabled: true,
+ responsive_web_profile_redirect_enabled: false,
+ rweb_tipjar_consumption_enabled: true,
+ verified_phone_label_enabled: false,
+ articles_preview_enabled: true,
+ responsive_web_grok_community_note_auto_translation_is_enabled: false,
+ responsive_web_graphql_skip_user_profile_image_extensions_enabled: false,
+ freedom_of_speech_not_reach_fetch_enabled: true,
+ standardized_nudges_misinfo: true,
+ tweet_with_visibility_results_prefer_gql_limited_actions_policy_enabled: true,
+ responsive_web_grok_image_annotation_enabled: true,
+ responsive_web_grok_imagine_annotation_enabled: true,
+ responsive_web_graphql_timeline_navigation_enabled: true,
+ responsive_web_enhance_cards_enabled: false,
+ };
+ if (topts.reply)
+ variables = {
+ ...variables,
+ reply: {
+ in_reply_to_tweet_id: topts.reply,
+ exclude_reply_user_ids: [],
+ },
+ };
+ if (topts.quote) variables = { ...variables, attachment_url: topts.quote };
+
+ const params = { ...features, variables, queryId };
+ const body = JSON.stringify(params);
+ const nheaders = { "Content-type": "application/json" };
+ const headers = {
+ ...TwitterApiService.HEADERS,
+ Authorization: TWITTER_INTERNAL_API_KEY,
+ Cookie: this.cookie,
+ ...nheaders,
+ };
+ const opts = {
+ method: "POST",
+ headers,
+ body,
+ };
+ const res = await fetch(url, opts);
+ console.log("like added", res);
+ }
+ async addLike(tweet_id: string) {
+ const queryId = `lI07N6Otwv1PhnEgXILM7A`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/FavoriteTweet`);
+ const body = { variables: { tweet_id }, queryId };
+ return await this.postCall(url, body);
+ }
+ async removeLike(tweet_id: string) {
+ const queryId = `ZYKSe-w7KEslx3JhSIk5LA`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/UnfavoriteTweet`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ return await this.postCall(url, body);
+ }
+ async addRT(tweet_id: string) {
+ const queryId = `ZYKSe-w7KEslx3JhSIk5LA`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/CreateRetweet`);
+ // TODO wtf is dark_request bruh
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+ async removeRT(tweet_id: string) {
+ const queryId = `iQtK4dl5hBmXewYZuEOKVw`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteRetweet`);
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+ async removeTweet(tweet_id: string) {
+ const queryId = `VaenaVgh5q5ih7kvyVjgtg`;
+ const url = new URL(`https://x.com/i/api/graphql/${queryId}/DeleteTweet`);
+ const body = {
+ variables: { tweet_id, dark_request: false },
+ queryId,
+ };
+ return await this.postCall(url, body);
+ }
+
+ async addBookmark(tweet_id: string) {
+ const queryId = `aoDbu3RHznuiSkQ9aNM67Q`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/CreateBookmark`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ try {
+ const res = await this.postCall(url, body);
+ return res?.data?.tweet_bookmark_put === "Done";
+ } catch (e) {
+ console.log("wtf man", e);
+ // return this.removeBookmark(tweet_id);
+ }
+ }
+ async removeBookmark(tweet_id: string) {
+ const queryId = `Wlmlj2-xzyS1GN3a6cj-mQ`;
+ const url = new URL(
+ `https://x.com/i/api/graphql/${queryId}/DeleteBookmark`,
+ );
+ const body = { variables: { tweet_id }, queryId };
+ const res = await this.postCall(url, body);
+ return res?.data?.tweet_bookmark_delete === "Done";
+ }
+}
diff --git a/packages/tweetdeck/src/lib/fetching/types.ts b/packages/tweetdeck/src/lib/fetching/types.ts
new file mode 100644
index 0000000..deb5418
--- /dev/null
+++ b/packages/tweetdeck/src/lib/fetching/types.ts
@@ -0,0 +1,596 @@
+export type TweetList = {
+ tweets: Tweet[];
+ cursorTop: string;
+ cursorBottom: string;
+};
+export interface UserResult {
+ __typename: "User";
+ id: string; // hash
+ rest_id: string; // number
+ affiliates_highlighted_label: {};
+ avatar: {
+ image_url: string;
+ };
+ core: {
+ created_at: string; // date string
+ name: string;
+ screen_name: string;
+ };
+ dm_permissions: {
+ can_dm: boolean;
+ };
+ has_graduated_access: boolean;
+ is_blue_verified: boolean;
+ legacy: {
+ profile_image_url_https?: string;
+ name?: string;
+ screen_name?: string;
+ default_profile: boolean;
+ default_profile_image: boolean;
+ description: string;
+ entities: {
+ description: {
+ urls: APITwitterURLEntity[];
+ };
+ url: {
+ urls: APITwitterURLEntity[];
+ };
+ };
+ fast_followers_count: number;
+ favourites_count: number;
+ followers_count: number;
+ friends_count: number;
+ has_custom_timelines: boolean;
+ is_translator: boolean;
+ listed_count: number;
+ media_count: number;
+ needs_phone_verification: boolean;
+ normal_followers_count: number;
+ pinned_tweet_ids_str: string[];
+ possibly_sensitive: boolean;
+ profile_interstitial_type: string;
+ statuses_count: number;
+ translator_type: string; // "none"
+ url: string;
+ want_retweets: boolean;
+ withheld_in_countries: string[];
+ };
+ location: {
+ location: string;
+ };
+ media_permissions: {
+ can_media_tag: boolean;
+ };
+ parody_commentary_fan_label: string;
+ profile_image_shape: string;
+ privacy: {
+ protected: boolean;
+ };
+ relationship_perspectives: {
+ following: boolean;
+ };
+ tipjar_settings:
+ | {}
+ | {
+ is_enabled: true;
+ bitcoin_handle: string;
+ ethereum_handle: string;
+ patreon_handle: string;
+ }; // TODO
+ super_follow_eligible?: boolean;
+ verification: {
+ verified: boolean;
+ };
+ quick_promote_eligibility?: {
+ eligibility: "IneligibleNotProfessional"; // TODO
+ };
+}
+export interface TweetWithVisibilityResult {
+ __typename: "TweetWithVisibilityResults";
+ tweet: TweetResult;
+ limitedActionResults: {
+ limited_actions: Array<{
+ action: "Reply"; // and?
+ prompts: {
+ __typename: "CtaLimitedActionPrompt"; // ?
+ cta_type: "SeeConversation";
+ headline: { text: string; entities: [] };
+ subtext: { text: string; entities: [] };
+ };
+ }>;
+ };
+}
+export interface TweetResult {
+ __typename: "Tweet";
+ rest_id: string;
+ post_video_description?: string;
+ has_birdwatch_notes?: boolean;
+ unmention_data: {};
+ edit_control: {
+ edit_tweet_ids: string[];
+ editable_until_msecs: string;
+ is_edit_eligible: boolean;
+ edits_remaining: number;
+ };
+ is_translatable: boolean;
+ views: {
+ count: string;
+ state: "EnabledWithCount"; // TODO
+ };
+ source: string; // "<a href=\"http://twitter.com/download/iphone\" rel=\"nofollow\">Twitter for iPhone</a>",
+ grok_analysis_button: boolean;
+ quoted_status_result?: { result: TweetResult };
+ is_quote_status: boolean;
+ legacy: {
+ retweeted_status_result?: { result: TweetResult };
+ quoted_status_id_str?: string;
+ quoted_status_permalink?: {
+ uri: string;
+ expanded: string;
+ display: string;
+ };
+ id_str: string;
+ user_id_str: string;
+ bookmark_count: number;
+ bookmarked: boolean;
+ favorite_count: number;
+ favorited: boolean;
+ quote_count: number;
+ reply_count: number;
+ retweet_count: number;
+ retweeted: boolean;
+ conversation_control: {
+ policy: "ByInvitation"; // TODO
+ conversation_owner_results: {
+ result: {
+ __typename: "User";
+ core: {
+ screen_name: string;
+ };
+ };
+ };
+ };
+ conversation_id_str: string;
+ display_text_range?: [number, number];
+ full_text: string;
+ lang: string;
+ created_at: string;
+ possibly_sensitive: boolean;
+ possibly_sensitive_Editable: boolean;
+ entities: {
+ hashtags?: Array<{ text: string }>;
+ media?: APITwitterMediaEntity[];
+ symbols: string[];
+ timestamps: string[];
+ urls: APITwitterURLEntity[]; // TODO
+ user_mentions: Array<{
+ id_str: string;
+ name: string;
+ screen_name: string;
+ indices: [number, number];
+ }>;
+ };
+ extended_entities: {
+ media: APITwitterMediaExtendedEntity[];
+ };
+ limitedActionResults: {
+ limited_actions: Array<{
+ actions: "Reply"; // TODO;
+ prompts: {
+ cta_type: string;
+ headline: {
+ text: string;
+ entities: APITwitterMediaEntity[]; // ?
+ };
+ subtext: {
+ text: string;
+ entities: APITwitterMediaEntity[];
+ };
+ };
+ }>;
+ };
+ };
+ core: {
+ user_results?: {
+ result: UserResult;
+ };
+ };
+}
+interface APITwitterURLEntity {
+ display_url: string;
+ expanded_url: string;
+ url: string; // minified
+ indices: [number, number];
+}
+type APITwitterMediaEntity = APITwitterPhotoEntity | APITwitterVideoEntity;
+interface APITwitterMediaBase {
+ additional_media_info?: {
+ monetizable: boolean;
+ };
+ display_url: string;
+ expanded_url: string;
+ id_str: string;
+ indices: [number, number];
+ media_key: string;
+ media_url_https: string;
+ url: string; // minified
+ ext_media_availability: {
+ status: "Available" | "Unavailable"; // ?
+ };
+ features: {
+ large: {
+ faces: [];
+ };
+ medium: {
+ faces: [];
+ };
+ small: {
+ faces: [];
+ };
+ orig: {
+ faces: [];
+ };
+ };
+ sizes: {
+ large: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ medium: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ small: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ thumb: {
+ h: number;
+ w: number;
+ resize: "fit" | "crop";
+ };
+ };
+ original_info: {
+ height: number;
+ width: number;
+ focus_rects: [
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ {
+ x: number;
+ y: number;
+ w: number;
+ h: number;
+ },
+ ];
+ };
+ media_results: {
+ result: {
+ media_key: string;
+ };
+ };
+}
+interface APITwitterPhotoEntity extends APITwitterMediaBase {
+ type: "photo";
+}
+interface APITwitterVideoEntity extends APITwitterMediaBase {
+ type: "video";
+
+ video_info: {
+ aspect_ratio: [number, number];
+ duration_millis: number;
+ variants: Array<
+ | {
+ content_type: "application/x-mpegURL";
+ url: string;
+ }
+ | {
+ content_type: "video/mp4";
+ bitrate: number;
+ url: string;
+ }
+ >;
+ };
+}
+
+type APITwitterMediaExtendedEntity = APITwitterMediaEntity;
+
+export interface TimelineEntry {
+ entryId: string;
+ sortIndex: string;
+ content: {
+ entryType: string;
+ __typename: string;
+ itemContent?: {
+ itemType: string;
+ __typename: string;
+ tweet_results?: {
+ result?: TweetResult | TweetWithVisibilityResult;
+ };
+ user_results?: {
+ result?: {
+ __typename: string;
+ id: string;
+ rest_id: string;
+ legacy: {
+ name?: string;
+ screen_name?: string;
+ profile_image_url_https?: string;
+ };
+ };
+ };
+ };
+ cursorType?: string;
+ value?: string;
+ stopOnEmptyResponse?: boolean;
+ };
+}
+
+export interface TimelineInstruction {
+ type: string;
+ entries?: TimelineEntry[];
+}
+
+export interface TwitterProfilesResponse {
+ data: { users: Array<{ result: UserResult }> };
+}
+export interface TwitterUserTweetsResponse {
+ data: {
+ user: {
+ result: {
+ __typename: "User";
+ timeline: {
+ timeline: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ };
+ };
+ };
+}
+export interface TwitterTweetDetailResponse {
+ data: {
+ threaded_conversation_with_injections_v2: {
+ instructions: TimelineInstruction[];
+ };
+ };
+}
+export interface TwitterBookmarkResponse {
+ data: {
+ bookmark_timeline_v2: {
+ timeline: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ };
+}
+
+export interface TwitterTimelineResponse {
+ data: {
+ home?: {
+ home_timeline_urt: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ home_timeline_urt?: {
+ instructions: TimelineInstruction[];
+ };
+ };
+}
+
+export interface TwitterListTimelineResponse {
+ data: {
+ list: {
+ tweets_timeline: {
+ timeline: {
+ instructions: TimelineInstruction[];
+ };
+ };
+ };
+ };
+}
+
+export interface TwitterList {
+ id: string;
+ name: string;
+ member_count: number;
+ subscriber_count: number;
+ creator: string;
+}
+export interface APITwitterMediaInfo {
+ original_img_url: string;
+ original_img_width: number;
+ original_img_height: number;
+ salient_rect: {
+ left: number;
+ top: number;
+ width: number;
+ height: number;
+ };
+}
+export interface APITwitterList {
+ created_at: number;
+ default_banner_media: {
+ media_info: APITwitterMediaInfo;
+ };
+ default_banner_media_results: {
+ result: {
+ id: string;
+ media_key: string;
+ media_id: string;
+ media_info: APITwitterMediaInfo;
+ __typename: "ApiMedia";
+ };
+ };
+ description: string;
+ facepile_urls: string[];
+ following: boolean;
+ id: string; //hash
+ id_str: string; // timestamp
+ is_member: boolean;
+ member_count: number;
+ members_context: string;
+ mode: string; // "private or public"
+ muting: boolean;
+ name: string;
+ pinning: boolean;
+ subscriber_count: number;
+ user_results: {
+ result: UserResult;
+ };
+}
+
+export interface TwitterListsManagementResponse {
+ data: {
+ viewer: {
+ list_management_timeline: {
+ timeline: {
+ instructions: Array<{
+ type: string;
+ entries: Array<{
+ content: {
+ __typename: string;
+ items: Array<{
+ entryId: string;
+ item: {
+ clientEventInfo: any;
+ itemContent: {
+ itemType: "TimelineTwitterList";
+ displayType: "ListWithPin"; // ?
+ list: APITwitterList;
+ };
+ };
+ }>;
+ };
+ }>;
+ }>;
+ };
+ };
+ };
+ };
+}
+
+export interface TwitterNotification {
+ id: string;
+ timestampMs: string;
+ message: {
+ text: string;
+ entities: Array<{
+ fromIndex: number;
+ toIndex: number;
+ ref: {
+ type: string;
+ screenName?: string;
+ mentionResults?: {
+ result?: {
+ legacy?: {
+ name?: string;
+ screen_name?: string;
+ };
+ };
+ };
+ };
+ }>;
+ rtl: boolean;
+ };
+ icon: {
+ id: string;
+ };
+ users: {
+ [key: string]: {
+ id: string;
+ screen_name: string;
+ name: string;
+ profile_image_url_https: string;
+ };
+ };
+}
+
+export interface TwitterNotificationsTimelineResponse {
+ globalObjects: {
+ notifications: { [id: string]: TwitterNotification };
+ users: {
+ [id: string]: {
+ id: string;
+ screen_name: string;
+ name: string;
+ profile_image_url_https: string;
+ };
+ };
+ tweets: { [id: string]: Tweet };
+ };
+ timeline: {
+ id: string;
+ instructions: Array<{
+ addEntries?: {
+ entries: Array<{
+ entryId: string;
+ sortIndex: string;
+ content: {
+ notification: {
+ id: string;
+ urls: Array<{
+ url: string;
+ expandedUrl: string;
+ displayUrl: string;
+ }>;
+ };
+ };
+ }>;
+ };
+ }>;
+ };
+}
+
+export type TwitterUser = {
+ id: string;
+ avatar: string;
+ name: string;
+ username: string;
+};
+export interface Tweet {
+ id: string;
+ text: string;
+ language: string;
+ author: TwitterUser;
+ time: number;
+ urls: Array<{
+ expandedUrl: string;
+ displayUrl: string;
+ }>;
+ media: { pics: string[]; video: { thumb: string; url: string } };
+ hashtags: string[];
+ quoting: Tweet | null;
+ liked: boolean;
+ bookmarked: boolean;
+ retweeted_by: RTMetadata | null;
+ rted: boolean;
+ replyingTo: Array<{ username: string }>;
+}
+export type RTMetadata = { author: TwitterUser; time: number };
+export type TwitterBookmark = Tweet;
diff --git a/packages/tweetdeck/src/lib/utils/id.ts b/packages/tweetdeck/src/lib/utils/id.ts
new file mode 100644
index 0000000..3008587
--- /dev/null
+++ b/packages/tweetdeck/src/lib/utils/id.ts
@@ -0,0 +1,4 @@
+export const generateId = () =>
+ typeof crypto !== "undefined" && "randomUUID" in crypto
+ ? crypto.randomUUID()
+ : Math.random().toString(36).slice(2, 11);
diff --git a/packages/tweetdeck/src/lib/utils/time.ts b/packages/tweetdeck/src/lib/utils/time.ts
new file mode 100644
index 0000000..f2802bf
--- /dev/null
+++ b/packages/tweetdeck/src/lib/utils/time.ts
@@ -0,0 +1,18 @@
+export function timeAgo(date: string | number | Date) {
+ const ts = typeof date === "string" || typeof date === "number" ? new Date(date).getTime() : date.getTime();
+ const diff = Date.now() - ts;
+ const seconds = Math.floor(diff / 1000);
+ if (seconds < 60) return `${seconds}s`;
+ const minutes = Math.floor(seconds / 60);
+ if (minutes < 60) return `${minutes}m`;
+ const hours = Math.floor(minutes / 60);
+ if (hours < 24) return `${hours}h`;
+ const days = Math.floor(hours / 24);
+ if (days < 7) return `${days}d`;
+ const weeks = Math.floor(days / 7);
+ if (weeks < 4) return `${weeks}w`;
+ const months = Math.floor(days / 30);
+ if (months < 12) return `${months}mo`;
+ const years = Math.floor(days / 365);
+ return `${years}y`;
+}
diff --git a/packages/tweetdeck/src/logo.svg b/packages/tweetdeck/src/logo.svg
new file mode 100644
index 0000000..7ef1500
--- /dev/null
+++ b/packages/tweetdeck/src/logo.svg
@@ -0,0 +1 @@
+<svg id="Bun" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 80 70"><title>Bun Logo</title><path id="Shadow" d="M71.09,20.74c-.16-.17-.33-.34-.5-.5s-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5-.33-.34-.5-.5A26.46,26.46,0,0,1,75.5,35.7c0,16.57-16.82,30.05-37.5,30.05-11.58,0-21.94-4.23-28.83-10.86l.5.5.5.5.5.5.5.5.5.5.5.5.5.5C19.55,65.3,30.14,69.75,42,69.75c20.68,0,37.5-13.48,37.5-30C79.5,32.69,76.46,26,71.09,20.74Z"/><g id="Body"><path id="Background" d="M73,35.7c0,15.21-15.67,27.54-35,27.54S3,50.91,3,35.7C3,26.27,9,17.94,18.22,13S33.18,3,38,3s8.94,4.13,19.78,10C67,17.94,73,26.27,73,35.7Z" style="fill:#fbf0df"/><path id="Bottom_Shadow" data-name="Bottom Shadow" d="M73,35.7a21.67,21.67,0,0,0-.8-5.78c-2.73,33.3-43.35,34.9-59.32,24.94A40,40,0,0,0,38,63.24C57.3,63.24,73,50.89,73,35.7Z" style="fill:#f6dece"/><path id="Light_Shine" data-name="Light Shine" d="M24.53,11.17C29,8.49,34.94,3.46,40.78,3.45A9.29,9.29,0,0,0,38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7c0,.4,0,.8,0,1.19C9.06,15.48,20.07,13.85,24.53,11.17Z" style="fill:#fffefc"/><path id="Top" d="M35.12,5.53A16.41,16.41,0,0,1,29.49,18c-.28.25-.06.73.3.59,3.37-1.31,7.92-5.23,6-13.14C35.71,5,35.12,5.12,35.12,5.53Zm2.27,0A16.24,16.24,0,0,1,39,19c-.12.35.31.65.55.36C41.74,16.56,43.65,11,37.93,5,37.64,4.74,37.19,5.14,37.39,5.49Zm2.76-.17A16.42,16.42,0,0,1,47,17.12a.33.33,0,0,0,.65.11c.92-3.49.4-9.44-7.17-12.53C40.08,4.54,39.82,5.08,40.15,5.32ZM21.69,15.76a16.94,16.94,0,0,0,10.47-9c.18-.36.75-.22.66.18-1.73,8-7.52,9.67-11.12,9.45C21.32,16.4,21.33,15.87,21.69,15.76Z" style="fill:#ccbea7;fill-rule:evenodd"/><path id="Outline" d="M38,65.75C17.32,65.75.5,52.27.5,35.7c0-10,6.18-19.33,16.53-24.92,3-1.6,5.57-3.21,7.86-4.62,1.26-.78,2.45-1.51,3.6-2.19C32,1.89,35,.5,38,.5s5.62,1.2,8.9,3.14c1,.57,2,1.19,3.07,1.87,2.49,1.54,5.3,3.28,9,5.27C69.32,16.37,75.5,25.69,75.5,35.7,75.5,52.27,58.68,65.75,38,65.75ZM38,3c-2.42,0-5,1.25-8.25,3.13-1.13.66-2.3,1.39-3.54,2.15-2.33,1.44-5,3.07-8,4.7C8.69,18.13,3,26.62,3,35.7,3,50.89,18.7,63.25,38,63.25S73,50.89,73,35.7C73,26.62,67.31,18.13,57.78,13,54,11,51.05,9.12,48.66,7.64c-1.09-.67-2.09-1.29-3-1.84C42.63,4,40.42,3,38,3Z"/></g><g id="Mouth"><g id="Background-2" data-name="Background"><path d="M45.05,43a8.93,8.93,0,0,1-2.92,4.71,6.81,6.81,0,0,1-4,1.88A6.84,6.84,0,0,1,34,47.71,8.93,8.93,0,0,1,31.12,43a.72.72,0,0,1,.8-.81H44.26A.72.72,0,0,1,45.05,43Z" style="fill:#b71422"/></g><g id="Tongue"><path id="Background-3" data-name="Background" d="M34,47.79a6.91,6.91,0,0,0,4.12,1.9,6.91,6.91,0,0,0,4.11-1.9,10.63,10.63,0,0,0,1-1.07,6.83,6.83,0,0,0-4.9-2.31,6.15,6.15,0,0,0-5,2.78C33.56,47.4,33.76,47.6,34,47.79Z" style="fill:#ff6164"/><path id="Outline-2" data-name="Outline" d="M34.16,47a5.36,5.36,0,0,1,4.19-2.08,6,6,0,0,1,4,1.69c.23-.25.45-.51.66-.77a7,7,0,0,0-4.71-1.93,6.36,6.36,0,0,0-4.89,2.36A9.53,9.53,0,0,0,34.16,47Z"/></g><path id="Outline-3" data-name="Outline" d="M38.09,50.19a7.42,7.42,0,0,1-4.45-2,9.52,9.52,0,0,1-3.11-5.05,1.2,1.2,0,0,1,.26-1,1.41,1.41,0,0,1,1.13-.51H44.26a1.44,1.44,0,0,1,1.13.51,1.19,1.19,0,0,1,.25,1h0a9.52,9.52,0,0,1-3.11,5.05A7.42,7.42,0,0,1,38.09,50.19Zm-6.17-7.4c-.16,0-.2.07-.21.09a8.29,8.29,0,0,0,2.73,4.37A6.23,6.23,0,0,0,38.09,49a6.28,6.28,0,0,0,3.65-1.73,8.3,8.3,0,0,0,2.72-4.37.21.21,0,0,0-.2-.09Z"/></g><g id="Face"><ellipse id="Right_Blush" data-name="Right Blush" cx="53.22" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><ellipse id="Left_Bluch" data-name="Left Bluch" cx="22.95" cy="40.18" rx="5.85" ry="3.44" style="fill:#febbd0"/><path id="Eyes" d="M25.7,38.8a5.51,5.51,0,1,0-5.5-5.51A5.51,5.51,0,0,0,25.7,38.8Zm24.77,0A5.51,5.51,0,1,0,45,33.29,5.5,5.5,0,0,0,50.47,38.8Z" style="fill-rule:evenodd"/><path id="Iris" d="M24,33.64a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,24,33.64Zm24.77,0a2.07,2.07,0,1,0-2.06-2.07A2.07,2.07,0,0,0,48.75,33.64Z" style="fill:#fff;fill-rule:evenodd"/></g></svg> \ No newline at end of file
diff --git a/packages/tweetdeck/src/react.svg b/packages/tweetdeck/src/react.svg
new file mode 100644
index 0000000..1ab815a
--- /dev/null
+++ b/packages/tweetdeck/src/react.svg
@@ -0,0 +1,8 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348">
+ <circle cx="0" cy="0" r="2.05" fill="#61dafb"/>
+ <g stroke="#61dafb" stroke-width="1" fill="none">
+ <ellipse rx="11" ry="4.2"/>
+ <ellipse rx="11" ry="4.2" transform="rotate(60)"/>
+ <ellipse rx="11" ry="4.2" transform="rotate(120)"/>
+ </g>
+</svg>
diff --git a/packages/tweetdeck/src/styles/index.css b/packages/tweetdeck/src/styles/index.css
new file mode 100644
index 0000000..e9a500f
--- /dev/null
+++ b/packages/tweetdeck/src/styles/index.css
@@ -0,0 +1,835 @@
+@import url("https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600&family=Inter:wght@400;500;600&display=swap");
+
+:root {
+ color-scheme: dark;
+ --bg: radial-gradient(circle at top, #15234b 0%, #050914 55%);
+ --panel: rgba(9, 14, 28, 0.9);
+ --panel-border: rgba(255, 255, 255, 0.08);
+ --soft-border: rgba(255, 255, 255, 0.15);
+ --muted: rgba(255, 255, 255, 0.6);
+ --accent: #7f5af0;
+ font-family: "Inter", "Space Grotesk", system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
+ background-color: #050914;
+}
+
+* {
+ box-sizing: border-box;
+}
+
+body {
+ margin: 0;
+ background: var(--bg);
+ color: #f5f6fb;
+ min-height: 100vh;
+}
+
+button,
+input,
+select,
+textarea {
+ font-family: inherit;
+}
+
+.app-shell {
+ min-height: 100vh;
+ display: grid;
+ grid-template-columns: 320px 1fr;
+ color: inherit;
+}
+
+.sidebar {
+ position: sticky;
+ top: 0;
+ align-self: start;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ justify-content: space-between;
+ padding: 2rem;
+ background: var(--panel);
+ border-right: 1px solid var(--panel-border);
+ gap: 2rem;
+}
+
+.brand {
+ display: flex;
+ gap: 1rem;
+ position: relative;
+ padding-bottom: 1.5rem;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+}
+
+.brand-glow {
+ width: 16px;
+ height: 16px;
+ border-radius: 50%;
+ background: linear-gradient(120deg, #7f5af0, #2cb67d);
+ box-shadow: 0 0 24px #7f5af0;
+ margin-top: 6px;
+}
+
+h1,
+h2,
+h3,
+h4 {
+ font-family: "Space Grotesk", "Inter", sans-serif;
+ margin: 0.2rem 0;
+}
+
+.eyebrow {
+ text-transform: uppercase;
+ letter-spacing: 0.2em;
+ font-size: 0.7rem;
+ color: var(--muted);
+ margin: 0;
+}
+
+.tagline {
+ margin: 0.2rem 0 0;
+ color: var(--muted);
+}
+
+.sidebar-section {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.sidebar-section header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.account-chip {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+ padding: 0.75rem 0.9rem;
+ border-radius: 12px;
+ border: 1px solid var(--panel-border);
+ cursor: pointer;
+ background: rgba(255, 255, 255, 0.02);
+}
+
+.account-chip.active {
+ border-color: currentColor;
+ background: rgba(127, 90, 240, 0.2);
+}
+
+.account-chip strong {
+ display: block;
+}
+
+.account-chip small {
+ color: var(--muted);
+}
+
+.chip-accent {
+ width: 6px;
+ height: 40px;
+ border-radius: 999px;
+}
+
+.chip-actions button {
+ border: none;
+ background: transparent;
+ color: var(--muted);
+ font-size: 1rem;
+}
+
+.account-form input,
+.account-form textarea,
+.account-form select {
+ width: 100%;
+ margin-top: 0.35rem;
+ padding: 0.65rem 0.75rem;
+ border-radius: 10px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(5, 9, 20, 0.7);
+ color: inherit;
+ resize: vertical;
+}
+
+.account-form textarea.masked {
+ filter: blur(6px);
+}
+
+.checkbox {
+ display: flex;
+ gap: 0.5rem;
+ align-items: center;
+ font-size: 0.85rem;
+}
+
+.sidebar-footer {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.button-row,
+.sidebar-footer button,
+.account-form button,
+.modal button.primary,
+.primary {
+ border: none;
+ border-radius: 999px;
+ padding: 0.75rem 1.5rem;
+ font-weight: 600;
+ background: linear-gradient(120deg, #7f5af0, #2cb67d);
+ color: #050914;
+ cursor: pointer;
+}
+
+.primary:disabled {
+ opacity: 0.5;
+ cursor: not-allowed;
+}
+
+.ghost {
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ border-radius: 999px;
+ padding: 0.35rem 0.85rem;
+ background: transparent;
+ color: inherit;
+ cursor: pointer;
+}
+
+button.ghost:disabled {
+ opacity: 0.4;
+ cursor: not-allowed;
+}
+
+.muted {
+ color: var(--muted);
+ margin: 0;
+}
+
+.muted.tiny {
+ font-size: 0.8rem;
+}
+
+.sidebar-footer .tiny {
+ font-size: 0.7rem;
+}
+
+main {
+ padding: 2.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ overflow-x: auto;
+}
+
+.column-board {
+ display: flex;
+ gap: 1rem;
+ overflow-x: auto;
+ padding-bottom: 1rem;
+}
+
+.column {
+ flex: 0 0 360px;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ padding: 1.25rem;
+ border-radius: 18px;
+ border: 1px solid var(--panel-border);
+ background: rgba(8, 13, 26, 0.9);
+ max-height: calc(100vh - 120px);
+ width: 100%;
+}
+
+.column.missing {
+ justify-content: center;
+ text-align: center;
+}
+
+.column header {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.column-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.column .tweet-stack,
+.column .chat-stack {
+ flex: 1;
+ overflow-y: auto;
+ padding-right: 0.5rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+
+.column .tweet-stack::-webkit-scrollbar,
+.column .chat-stack::-webkit-scrollbar {
+ display: none;
+}
+
+.load-more-row {
+ display: flex;
+ justify-content: center;
+ margin: 0.5rem 0 1rem;
+}
+
+.load-more-row button {
+ min-width: 140px;
+}
+
+.load-more-row p {
+ margin: 0;
+ text-align: center;
+}
+
+.fullscreen-overlay {
+ position: fixed;
+ inset: 0;
+ background: rgba(3, 5, 12, 0.95);
+ z-index: 100;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 2rem 3rem;
+}
+
+.fullscreen-content {
+ width: min(900px, 100%);
+ height: min(95vh, 100%);
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.fullscreen-card {
+ flex: 1;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+}
+
+.fullscreen-card .tweet-card {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 70vh;
+ font-size: 1.1rem;
+
+ .tweet-body {
+ flex-grow: 1;
+ display: flex;
+ flex-direction: column;
+
+ .media-grid {
+ flex-grow: 1;
+
+ }
+ }
+
+ footer {
+ button {
+ svg {
+ width: 3rem;
+ height: 3rem;
+ }
+ }
+ }
+}
+
+.fullscreen-card header {
+ font-size: 1.8rem;
+}
+
+.fullscreen-card .tweet-text {
+ font-size: 1.5rem;
+ line-height: 1.8;
+}
+
+.fullscreen-empty {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ gap: 0.75rem;
+ text-align: center;
+ font-size: 1.3rem;
+}
+
+.fullscreen-controls {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.fullscreen-column-controls {
+ display: flex;
+ justify-content: space-between;
+ gap: 1rem;
+}
+
+.fullscreen-column-controls .ghost {
+ min-width: 180px;
+}
+
+.fullscreen-close {
+ position: absolute;
+ top: 1.5rem;
+ right: 1.5rem;
+ font-size: 1.5rem;
+}
+
+.column-content {
+ display: flex;
+ flex-direction: column;
+ flex: 1;
+ min-height: 0;
+}
+
+.column-content.slide-forward {
+ animation: columnForward 0.35s ease both;
+}
+
+.column-content.slide-backward {
+ animation: columnBackward 0.35s ease both;
+}
+
+@keyframes columnForward {
+ from {
+ opacity: 0;
+ transform: translateX(24px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+@keyframes columnBackward {
+ from {
+ opacity: 0;
+ transform: translateX(-24px);
+ }
+
+ to {
+ opacity: 1;
+ transform: translateX(0);
+ }
+}
+
+.column footer {
+ text-align: center;
+}
+
+.tweet-card,
+.chat-card {
+ border-radius: 18px;
+ padding: 1rem;
+ border: 1px solid var(--soft-border);
+ background: rgba(4, 8, 18, 0.8);
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.retweet-banner {
+ margin: 0;
+ text-transform: none;
+}
+
+.tweet-replying-to {
+ opacity: 0.5;
+
+ span {
+ margin: 0 0.25ch;
+ }
+}
+
+.tweet-actions {
+ display: flex;
+ justify-content: space-between;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+ padding-top: 0.75rem;
+ margin-top: 0.25rem;
+}
+
+.tweet-actions .action {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.3rem;
+ border: 1px solid rgba(255, 255, 255, 0.15);
+ border-radius: 999px;
+ background: transparent;
+ color: inherit;
+ font-size: 0.8rem;
+ padding: 0.35rem 0.6rem;
+ cursor: pointer;
+ position: relative;
+ overflow: hidden;
+ transition: border-color 0.2s ease, background 0.2s ease, transform 0.15s ease;
+}
+
+.tweet-actions .action.active {
+ border-color: rgba(255, 255, 255, 0.35);
+ transform: scale(1.08);
+}
+
+.tweet-actions .action.like.active svg {
+ color: #f25f4c;
+}
+
+.tweet-actions .action.retweet.active svg {
+ color: #2cb67d;
+}
+
+.tweet-actions .action.bookmark.active svg {
+ color: #f0a500;
+}
+
+.tweet-actions .action::after {
+ content: "";
+ position: absolute;
+ inset: 50%;
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: currentColor;
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(1);
+ pointer-events: none;
+}
+
+.tweet-actions .action:active::after {
+ animation: ripple 0.45s ease-out;
+}
+
+@keyframes ripple {
+ 0% {
+ opacity: 0.25;
+ transform: translate(-50%, -50%) scale(0.2);
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translate(-50%, -50%) scale(8);
+ }
+}
+
+.tweet-actions .action.in-flight {
+ opacity: 0.5;
+ pointer-events: none;
+}
+
+.tweet-actions .action.copied svg {
+ color: var(--accent);
+}
+
+.tweet-actions .action:hover {
+ border-color: rgba(255, 255, 255, 0.35);
+ background: rgba(255, 255, 255, 0.08);
+}
+
+.tweet-actions svg {
+ width: 16px;
+ height: 16px;
+}
+
+.sr-only {
+ position: absolute;
+ width: 1px;
+ height: 1px;
+ padding: 0;
+ margin: -1px;
+ overflow: hidden;
+ clip: rect(0, 0, 0, 0);
+ white-space: nowrap;
+ border: 0;
+}
+
+.link-button {
+ border: none;
+ background: none;
+ color: inherit;
+ font: inherit;
+ padding: 0;
+ cursor: pointer;
+}
+
+.link-button:focus-visible {
+ outline: 2px solid var(--accent);
+ outline-offset: 2px;
+}
+
+.link-button:disabled {
+ opacity: 0.6;
+ cursor: default;
+}
+
+.tweet-card header,
+.chat-card header {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.tweet-card img {
+ border-radius: 12px;
+}
+
+.author {
+ display: flex;
+ align-items: center;
+ gap: 0.75rem;
+}
+
+.author img {
+ width: 44px;
+ height: 44px;
+ border-radius: 50%;
+}
+
+.author-meta {
+ display: flex;
+ flex-direction: column;
+ align-items: flex-start;
+ text-align: left;
+}
+
+.tweet-text {
+ white-space: pre-wrap;
+ line-height: 1.5;
+}
+
+.mention {
+ color: #2cb67d;
+}
+
+.hashtag {
+ color: #7f5af0;
+}
+
+.media-grid {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.media-grid.pics-1 {
+ grid-template-columns: 1fr;
+}
+
+.media-grid.pics-2 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.media-grid.pics-3,
+.media-grid.pics-4 {
+ grid-template-columns: repeat(2, 1fr);
+}
+
+.media-grid img {
+ width: 100%;
+}
+
+.video-wrapper video {
+ width: 100%;
+ border-radius: 14px;
+ background: #000;
+}
+
+.link-chips {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+}
+
+.link-chips a {
+ border: 1px solid var(--soft-border);
+ border-radius: 999px;
+ padding: 0.35rem 0.9rem;
+ text-decoration: none;
+ color: inherit;
+ font-size: 0.85rem;
+}
+
+.chat-card {
+ flex-direction: row;
+}
+
+.chat-avatar img,
+.chat-avatar span {
+ width: 42px;
+ height: 42px;
+ border-radius: 12px;
+ background: rgba(255, 255, 255, 0.08);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.chat-avatar img {
+ border-radius: 999px;
+}
+
+.chat-body header {
+ align-items: baseline;
+ gap: 0.4rem;
+}
+
+.chat-body p {
+ margin: 0.25rem 0 0;
+}
+
+.chat-body .dot {
+ color: var(--muted);
+}
+
+.modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.65);
+ display: grid;
+ place-items: center;
+ padding: 1rem;
+ z-index: 1000;
+}
+
+.modal {
+ width: min(520px, 100%);
+ background: rgba(5, 9, 20, 0.95);
+ border-radius: 24px;
+ border: 1px solid var(--panel-border);
+ padding: 1.75rem;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.modal header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+}
+
+.modal-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.modal select,
+.modal input {
+ border-radius: 12px;
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ background: rgba(8, 13, 26, 0.9);
+ color: inherit;
+ padding: 0.65rem 0.75rem;
+}
+
+.option-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
+ gap: 0.75rem;
+}
+
+.option {
+ border: 1px solid rgba(255, 255, 255, 0.1);
+ border-radius: 14px;
+ padding: 0.75rem;
+ text-align: left;
+ background: rgba(3, 6, 15, 0.9);
+ cursor: pointer;
+}
+
+.option.selected {
+ border-color: var(--accent);
+ background: rgba(127, 90, 240, 0.15);
+}
+
+.option p {
+ margin: 0.2rem 0 0;
+ color: var(--muted);
+}
+
+.error {
+ color: #f25f4c;
+}
+
+.column-loading {
+ text-align: center;
+ padding: 2rem 0;
+ color: var(--muted);
+}
+
+.empty-board {
+ border: 1px dashed rgba(255, 255, 255, 0.2);
+ border-radius: 24px;
+ padding: 2.5rem;
+ text-align: center;
+}
+
+.toast {
+ position: fixed;
+ bottom: 24px;
+ right: 24px;
+ padding: 0.85rem 1.25rem;
+ border-radius: 999px;
+ background: rgba(15, 25, 50, 0.9);
+ border: 1px solid rgba(255, 255, 255, 0.2);
+ animation: fadeOut 4s forwards;
+}
+
+@keyframes fadeOut {
+ 0% {
+ opacity: 1;
+ transform: translateY(0);
+ }
+
+ 80% {
+ opacity: 1;
+ }
+
+ 100% {
+ opacity: 0;
+ transform: translateY(12px);
+ }
+}
+
+@media (max-width: 1024px) {
+ .app-shell {
+ grid-template-columns: 1fr;
+ }
+
+ .sidebar {
+ position: relative;
+ min-height: unset;
+ }
+
+ main {
+ padding: 1.5rem;
+ }
+
+ .column {
+ flex-basis: 80vw;
+ }
+}
+
+
+/* language stuff */
+*[lang="th"],
+*[lang="tha"] {
+ font-size: 3rem;
+}
+
+/* .font-Thai-0 { */ \ No newline at end of file
diff --git a/packages/tweetdeck/src/styles/normalize.css b/packages/tweetdeck/src/styles/normalize.css
new file mode 100644
index 0000000..fdec4bd
--- /dev/null
+++ b/packages/tweetdeck/src/styles/normalize.css
@@ -0,0 +1,379 @@
+/*! normalize.css v8.0.1 | MIT License | github.com/necolas/normalize.css */
+
+/* Document
+ ========================================================================== */
+
+/**
+ * 1. Correct the line height in all browsers.
+ * 2. Prevent adjustments of font size after orientation changes in iOS.
+ */
+
+html {
+ line-height: 1.15;
+ /* 1 */
+ -webkit-text-size-adjust: 100%;
+ /* 2 */
+}
+
+/* Sections
+ ========================================================================== */
+
+/**
+ * Remove the margin in all browsers.
+ */
+
+body {
+ margin: 0;
+}
+
+/**
+ * Render the `main` element consistently in IE.
+ */
+
+main {
+ display: block;
+}
+
+/**
+ * Correct the font size and margin on `h1` elements within `section` and
+ * `article` contexts in Chrome, Firefox, and Safari.
+ */
+
+h1 {
+ font-size: 2em;
+ margin: 0.67em 0;
+}
+
+/* Grouping content
+ ========================================================================== */
+
+/**
+ * 1. Add the correct box sizing in Firefox.
+ * 2. Show the overflow in Edge and IE.
+ */
+
+hr {
+ box-sizing: content-box;
+ /* 1 */
+ height: 0;
+ /* 1 */
+ overflow: visible;
+ /* 2 */
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+pre {
+ font-family: monospace, monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/* Text-level semantics
+ ========================================================================== */
+
+/**
+ * Remove the gray background on active links in IE 10.
+ */
+
+a {
+ background-color: transparent;
+}
+
+/**
+ * 1. Remove the bottom border in Chrome 57-
+ * 2. Add the correct text decoration in Chrome, Edge, IE, Opera, and Safari.
+ */
+
+abbr[title] {
+ border-bottom: none;
+ /* 1 */
+ text-decoration: underline;
+ /* 2 */
+ text-decoration: underline dotted;
+ /* 2 */
+}
+
+/**
+ * Add the correct font weight in Chrome, Edge, and Safari.
+ */
+
+b,
+strong {
+ font-weight: bolder;
+}
+
+/**
+ * 1. Correct the inheritance and scaling of font size in all browsers.
+ * 2. Correct the odd `em` font sizing in all browsers.
+ */
+
+code,
+kbd,
+samp {
+ font-family: monospace, monospace;
+ /* 1 */
+ font-size: 1em;
+ /* 2 */
+}
+
+/**
+ * Add the correct font size in all browsers.
+ */
+
+small {
+ font-size: 80%;
+}
+
+/**
+ * Prevent `sub` and `sup` elements from affecting the line height in
+ * all browsers.
+ */
+
+sub,
+sup {
+ font-size: 75%;
+ line-height: 0;
+ position: relative;
+ vertical-align: baseline;
+}
+
+sub {
+ bottom: -0.25em;
+}
+
+sup {
+ top: -0.5em;
+}
+
+/* Embedded content
+ ========================================================================== */
+
+/**
+ * Remove the border on images inside links in IE 10.
+ */
+
+img {
+ border-style: none;
+}
+
+/* Forms
+ ========================================================================== */
+
+/**
+ * 1. Change the font styles in all browsers.
+ * 2. Remove the margin in Firefox and Safari.
+ */
+
+button,
+input,
+optgroup,
+select,
+textarea {
+ font-family: inherit;
+ /* 1 */
+ font-size: 100%;
+ /* 1 */
+ line-height: 1.15;
+ /* 1 */
+ margin: 0;
+ /* 2 */
+}
+
+/**
+ * Show the overflow in IE.
+ * 1. Show the overflow in Edge.
+ */
+
+button,
+input {
+ /* 1 */
+ overflow: visible;
+}
+
+/**
+ * Remove the inheritance of text transform in Edge, Firefox, and IE.
+ * 1. Remove the inheritance of text transform in Firefox.
+ */
+
+button,
+select {
+ /* 1 */
+ text-transform: none;
+}
+
+/**
+ * Correct the inability to style clickable types in iOS and Safari.
+ */
+
+button,
+[type="button"],
+[type="reset"],
+[type="submit"] {
+ -webkit-appearance: button;
+}
+
+/**
+ * Remove the inner border and padding in Firefox.
+ */
+
+button::-moz-focus-inner,
+[type="button"]::-moz-focus-inner,
+[type="reset"]::-moz-focus-inner,
+[type="submit"]::-moz-focus-inner {
+ border-style: none;
+ padding: 0;
+}
+
+/**
+ * Restore the focus styles unset by the previous rule.
+ */
+
+button:-moz-focusring,
+[type="button"]:-moz-focusring,
+[type="reset"]:-moz-focusring,
+[type="submit"]:-moz-focusring {
+ outline: 1px dotted ButtonText;
+}
+
+/**
+ * Correct the padding in Firefox.
+ */
+
+fieldset {
+ padding: 0.35em 0.75em 0.625em;
+}
+
+/**
+ * 1. Correct the text wrapping in Edge and IE.
+ * 2. Correct the color inheritance from `fieldset` elements in IE.
+ * 3. Remove the padding so developers are not caught out when they zero out
+ * `fieldset` elements in all browsers.
+ */
+
+legend {
+ box-sizing: border-box;
+ /* 1 */
+ color: inherit;
+ /* 2 */
+ display: table;
+ /* 1 */
+ max-width: 100%;
+ /* 1 */
+ padding: 0;
+ /* 3 */
+ white-space: normal;
+ /* 1 */
+}
+
+/**
+ * Add the correct vertical alignment in Chrome, Firefox, and Opera.
+ */
+
+progress {
+ vertical-align: baseline;
+}
+
+/**
+ * Remove the default vertical scrollbar in IE 10+.
+ */
+
+textarea {
+ overflow: auto;
+}
+
+/**
+ * 1. Add the correct box sizing in IE 10.
+ * 2. Remove the padding in IE 10.
+ */
+
+[type="checkbox"],
+[type="radio"] {
+ box-sizing: border-box;
+ /* 1 */
+ padding: 0;
+ /* 2 */
+}
+
+/**
+ * Correct the cursor style of increment and decrement buttons in Chrome.
+ */
+
+[type="number"]::-webkit-inner-spin-button,
+[type="number"]::-webkit-outer-spin-button {
+ height: auto;
+}
+
+/**
+ * 1. Correct the odd appearance in Chrome and Safari.
+ * 2. Correct the outline style in Safari.
+ */
+
+[type="search"] {
+ -webkit-appearance: textfield;
+ /* 1 */
+ outline-offset: -2px;
+ /* 2 */
+}
+
+/**
+ * Remove the inner padding in Chrome and Safari on macOS.
+ */
+
+[type="search"]::-webkit-search-decoration {
+ -webkit-appearance: none;
+}
+
+/**
+ * 1. Correct the inability to style clickable types in iOS and Safari.
+ * 2. Change font properties to `inherit` in Safari.
+ */
+
+::-webkit-file-upload-button {
+ -webkit-appearance: button;
+ /* 1 */
+ font: inherit;
+ /* 2 */
+}
+
+/* Interactive
+ ========================================================================== */
+
+/*
+ * Add the correct display in Edge, IE 10+, and Firefox.
+ */
+
+details {
+ display: block;
+}
+
+/*
+ * Add the correct display in all browsers.
+ */
+
+summary {
+ display: list-item;
+}
+
+/* Misc
+ ========================================================================== */
+
+/**
+ * Add the correct display in IE 10+.
+ */
+
+template {
+ display: none;
+}
+
+/**
+ * Add the correct display in IE 10.
+ */
+
+[hidden] {
+ display: none;
+} \ No newline at end of file
diff --git a/packages/tweetdeck/src/types/app.ts b/packages/tweetdeck/src/types/app.ts
new file mode 100644
index 0000000..c8c9e80
--- /dev/null
+++ b/packages/tweetdeck/src/types/app.ts
@@ -0,0 +1,92 @@
+import type {
+ Tweet,
+ TwitterList,
+ TwitterNotification,
+} from "../lib/fetching/types";
+
+export type TimelineMode =
+ | "foryou"
+ | "following"
+ | "bookmarks"
+ | "list"
+ | "chat";
+
+export interface DeckAccount {
+ id: string;
+ label: string;
+ handle?: string;
+ username?: string;
+ avatar?: string;
+ accent: string;
+ cookie: string;
+ createdAt: number;
+}
+
+export type ColumnView =
+ | {
+ type: "timeline";
+ mode: Exclude<TimelineMode, "chat">;
+ title?: string;
+ listId?: string;
+ listName?: string;
+ }
+ | {
+ type: "user";
+ userId: string;
+ username: string;
+ title?: string;
+ }
+ | {
+ type: "thread";
+ tweetId: string;
+ title?: string;
+ };
+
+export interface ColumnState {
+ stack: ColumnView[];
+}
+
+export interface ColumnSnapshot {
+ tweets: Tweet[];
+ label: string;
+}
+
+export interface DeckColumn {
+ id: string;
+ kind: TimelineMode;
+ accountId: string; // wtf is this
+ account: string; // TODO ensure this gets populated
+ title: string;
+ listId?: string;
+ listName?: string;
+ state?: ColumnState;
+}
+
+export interface TimelineState {
+ tweets: Tweet[];
+ cursorTop: string;
+ cursorBottom: string;
+ isLoading: boolean;
+ isAppending: boolean;
+ error?: string;
+}
+
+export interface FullscreenState {
+ column: DeckColumn;
+ columnLabel: string;
+ accent: string;
+ tweets: Tweet[];
+ index: number;
+ columnIndex: number;
+}
+
+export interface ChatState {
+ entries: TwitterNotification[];
+ cursor?: string;
+ isLoading: boolean;
+ error?: string;
+}
+
+export interface DeckListsCache {
+ [accountId: string]: TwitterList[];
+}