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( ACCOUNTS_KEY, [], ); const [columns, setColumns] = usePersistentState( COLUMNS_KEY, [], ); const [listsCache, setListsCache] = useState({}); const [activeAccountId, setActiveAccountId] = useState( () => accounts[0]?.id, ); const [isModalOpen, setModalOpen] = useState(false); const [toast, setToast] = useState(null); const [fullscreen, setFullscreen] = useState(null); const [columnSnapshots, setColumnSnapshots] = useState< Record >({}); 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) => { 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( () => ( ), [ accounts, columns, handleRemoveColumn, handleColumnStateChange, handleColumnSnapshot, openFullscreen, ], ); return (
setActiveAccountId(id)} onAddAccount={handleAddAccount} onRemoveAccount={handleRemoveAccount} onAddColumn={() => setModalOpen(true)} />
{content}
setModalOpen(false)} onAdd={handleAddColumn} fetchLists={fetchLists} listsCache={listsCache} /> {toast && (
setToast(null)}> {toast}
)} {fullscreen && ( 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)} /> )}
); } 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;