From cb1b56f5a0eddbf77446f415f2beda57c8305f85 Mon Sep 17 00:00:00 2001
From: polwex <polwex@sortug.com>
Date: Sun, 23 Nov 2025 01:12:53 +0700
Subject: wut

---
 .../tweetdeck/src/components/TimelineColumn.tsx    | 500 +++++++++++++++++++++
 1 file changed, 500 insertions(+)
 create mode 100644 packages/tweetdeck/src/components/TimelineColumn.tsx

(limited to 'packages/tweetdeck/src/components/TimelineColumn.tsx')

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",
+      };
+  }
+}
-- 
cgit v1.2.3

