diff options
Diffstat (limited to 'packages/tweetdeck/src')
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[]; +} |
