summaryrefslogtreecommitdiff
path: root/packages/tweetdeck/src/components/TimelineColumn.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'packages/tweetdeck/src/components/TimelineColumn.tsx')
-rw-r--r--packages/tweetdeck/src/components/TimelineColumn.tsx500
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",
+ };
+ }
+}