diff options
Diffstat (limited to 'packages/tweetdeck/src/components/TimelineColumn.tsx')
| -rw-r--r-- | packages/tweetdeck/src/components/TimelineColumn.tsx | 500 |
1 files changed, 500 insertions, 0 deletions
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", + }; + } +} |
