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; }; type TimelineView = Extract; 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({ tweets: [], cursorTop: "", cursorBottom: "", isLoading: false, isAppending: false, }); const [error, setError] = useState(); const [transitionDirection, setTransitionDirection] = useState< "forward" | "backward" | null >(null); const baseView = useMemo( () => createBaseView(column), [column.kind, column.title, column.listId, column.listName], ); const initialStack = useMemo(() => { return column.state?.stack?.length ? column.state.stack : [baseView]; }, [column.state, baseView]); const [viewStack, setViewStack] = useState(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 = { 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 (

{descriptor.badge}

{descriptor.label}

{descriptor.description} · {account.label}

{viewStack.length > 1 &&

{breadcrumbs}

}
{canGoBack && ( )}
{error &&

{error}

} {state.isLoading && !state.tweets.length ? (
Loading…
) : (
{state.tweets .filter((t) => t.language === "th") .slice(0, 10) .map((tweet) => ( ))} {!state.tweets.length && !state.isLoading && (

No tweets yet. Try refreshing.

)} {state.cursorBottom ? (
) : ( state.tweets.length > 0 && (

End of feed

) )}
)}
); } 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", }; } }