From cb1b56f5a0eddbf77446f415f2beda57c8305f85 Mon Sep 17 00:00:00 2001 From: polwex Date: Sun, 23 Nov 2025 01:12:53 +0700 Subject: wut --- .../tweetdeck/src/components/AddColumnModal.tsx | 234 ++++++++++ packages/tweetdeck/src/components/ChatCard.tsx | 56 +++ packages/tweetdeck/src/components/ChatColumn.tsx | 62 +++ packages/tweetdeck/src/components/ColumnBoard.tsx | 93 ++++ .../tweetdeck/src/components/FullscreenColumn.tsx | 134 ++++++ packages/tweetdeck/src/components/Sidebar.tsx | 153 +++++++ .../tweetdeck/src/components/TimelineColumn.tsx | 500 +++++++++++++++++++++ packages/tweetdeck/src/components/TweetCard.tsx | 337 ++++++++++++++ 8 files changed, 1569 insertions(+) create mode 100644 packages/tweetdeck/src/components/AddColumnModal.tsx create mode 100644 packages/tweetdeck/src/components/ChatCard.tsx create mode 100644 packages/tweetdeck/src/components/ChatColumn.tsx create mode 100644 packages/tweetdeck/src/components/ColumnBoard.tsx create mode 100644 packages/tweetdeck/src/components/FullscreenColumn.tsx create mode 100644 packages/tweetdeck/src/components/Sidebar.tsx create mode 100644 packages/tweetdeck/src/components/TimelineColumn.tsx create mode 100644 packages/tweetdeck/src/components/TweetCard.tsx (limited to 'packages/tweetdeck/src/components') diff --git a/packages/tweetdeck/src/components/AddColumnModal.tsx b/packages/tweetdeck/src/components/AddColumnModal.tsx new file mode 100644 index 0000000..b7098ed --- /dev/null +++ b/packages/tweetdeck/src/components/AddColumnModal.tsx @@ -0,0 +1,234 @@ +import { useEffect, useMemo, useState } from "react"; +import type { DeckAccount, DeckColumn, DeckListsCache } from "../types/app"; +import type { TwitterList } from "../lib/fetching/types"; + +interface AddColumnModalProps { + accounts: DeckAccount[]; + isOpen: boolean; + onClose: () => void; + onAdd: (column: Omit) => void; + activeAccountId?: string; + fetchLists: (accountId: string) => Promise; + listsCache: DeckListsCache; +} + +const columnOptions = [ + { + id: "foryou", + label: "For You", + description: "Twitter's AI-ranked firehose", + }, + { + id: "following", + label: "Following", + description: "Chronological feed of people you follow", + }, + { id: "bookmarks", label: "Bookmarks", description: "Your saved deep dives" }, + { id: "list", label: "List", description: "Curate a topic-specific stream" }, + { id: "chat", label: "Chat", description: "Mentions & notifications" }, +] as const; + +export function AddColumnModal({ + accounts, + activeAccountId, + isOpen, + onClose, + onAdd, + fetchLists, + listsCache, +}: AddColumnModalProps) { + const [kind, setKind] = useState("foryou"); + const [accountId, setAccountId] = useState( + activeAccountId || accounts[0]?.id || "", + ); + const [title, setTitle] = useState("For You"); + const [listId, setListId] = useState(""); + const [listOptions, setListOptions] = useState([]); + const [listsLoading, setListsLoading] = useState(false); + const [listsError, setListsError] = useState(); + + useEffect(() => { + if (!isOpen) return; + setAccountId(activeAccountId || accounts[0]?.id || ""); + }, [isOpen, activeAccountId, accounts]); + + useEffect(() => { + setTitle(defaultTitle(kind)); + }, [kind]); + + useEffect(() => { + if (!isOpen || kind !== "list" || !accountId) return; + let mounted = true; + async function loadLists() { + try { + setListsLoading(true); + setListsError(undefined); + const cached = listsCache[accountId]; + if (cached) { + setListOptions(cached); + return; + } + const lists = await fetchLists(accountId); + if (!mounted) return; + setListOptions(lists); + } catch (error) { + setListsError( + error instanceof Error ? error.message : "Failed to load lists", + ); + } finally { + if (mounted) setListsLoading(false); + } + } + loadLists(); + return () => { + mounted = false; + }; + }, [accountId, fetchLists, isOpen, kind, listsCache]); + + useEffect(() => { + if (!isOpen) return; + setListId(""); + setListOptions([]); + }, [accountId, isOpen, kind]); + + const selectedAccount = useMemo( + () => accounts.find((account) => account.id === accountId), + [accountId, accounts], + ); + + const canSubmit = Boolean(selectedAccount && (kind !== "list" || listId)); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!canSubmit || !selectedAccount) return; + onAdd({ + accountId: selectedAccount.id, + account: selectedAccount.username || "", + kind, + title: title.trim() || defaultTitle(kind), + listId: listId || undefined, + listName: + kind === "list" + ? listOptions.find((list) => list.id === listId)?.name + : undefined, + }); + onClose(); + }; + + useEffect(() => { + if (!isOpen) return; + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") onClose(); + }; + window.addEventListener("keydown", handleKeyDown); + return () => window.removeEventListener("keydown", handleKeyDown); + }, [isOpen, onClose]); + + if (!isOpen) return null; + + return ( +
{ + if (event.target === event.currentTarget) { + onClose(); + } + }} + > +
+
+
+

New column

+

Design your stream

+
+ +
+
+ +
+ {columnOptions.map((option) => ( + + ))} +
+ {kind === "list" && ( + + )} + + +
+
+
+ ); +} + +function defaultTitle(kind: DeckColumn["kind"]) { + switch (kind) { + case "following": + return "Following"; + case "bookmarks": + return "Bookmarks"; + case "list": + return "List"; + case "chat": + return "Chat"; + default: + return "For You"; + } +} diff --git a/packages/tweetdeck/src/components/ChatCard.tsx b/packages/tweetdeck/src/components/ChatCard.tsx new file mode 100644 index 0000000..d7d885c --- /dev/null +++ b/packages/tweetdeck/src/components/ChatCard.tsx @@ -0,0 +1,56 @@ +import type { TwitterNotification } from "../lib/fetching/types"; +import { timeAgo } from "../lib/utils/time"; + +interface ChatCardProps { + notification: TwitterNotification; + accent: string; +} + +export function ChatCard({ notification, accent }: ChatCardProps) { + const firstUser = Object.values(notification.users)[0]; + const timestamp = timeAgo(Number(notification.timestampMs)); + + return ( +
+
+ {firstUser?.profile_image_url_https ? ( + {firstUser.name} + ) : ( + {firstUser?.name?.[0] ?? "?"} + )} +
+
+
+ {firstUser?.name ?? "Notification"} + {firstUser?.screen_name && @{firstUser.screen_name}} + + {timestamp} +
+

{highlight(notification.message.text)}

+
+
+ ); +} + +function highlight(text: string) { + const parts = text.split(/([@#][A-Za-z0-9_]+)/g); + return parts.map((part, index) => { + if (part.startsWith("@")) { + return ( + + {part} + + ); + } + if (part.startsWith("#")) { + return ( + + {part} + + ); + } + return {part}; + }); +} diff --git a/packages/tweetdeck/src/components/ChatColumn.tsx b/packages/tweetdeck/src/components/ChatColumn.tsx new file mode 100644 index 0000000..0c336e5 --- /dev/null +++ b/packages/tweetdeck/src/components/ChatColumn.tsx @@ -0,0 +1,62 @@ +import { useCallback, useEffect, useState } from "react"; +import type { DeckAccount, DeckColumn, ChatState } from "../types/app"; +import { twitterClient } from "../lib/client/twitterClient"; +import { ChatCard } from "./ChatCard"; + +interface ChatColumnProps { + column: DeckColumn & { kind: "chat" }; + account: DeckAccount; + onRemove: () => void; +} + +export function ChatColumn({ column, account, onRemove }: ChatColumnProps) { + const [state, setState] = useState({ entries: [], isLoading: false }); + const [error, setError] = useState(); + + const refresh = useCallback(async () => { + setState(prev => ({ ...prev, isLoading: true })); + setError(undefined); + try { + const notifications = await twitterClient.notifications({ cookie: account.cookie }); + setState({ entries: notifications, isLoading: false }); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to refresh chat"); + setState(prev => ({ ...prev, isLoading: false })); + } + }, [account.cookie]); + + useEffect(() => { + refresh(); + }, [refresh, account.id]); + + return ( +
+
+
+

Signal

+

{column.title || "Chat"}

+

Mentions, follows and notifications for {account.label}

+
+
+ + +
+
+ {error &&

{error}

} + {state.isLoading && !state.entries.length ? ( +
Loading…
+ ) : ( +
+ {state.entries.map(entry => ( + + ))} + {!state.entries.length &&

No recent notifications.

} +
+ )} +
+ ); +} diff --git a/packages/tweetdeck/src/components/ColumnBoard.tsx b/packages/tweetdeck/src/components/ColumnBoard.tsx new file mode 100644 index 0000000..87da3e1 --- /dev/null +++ b/packages/tweetdeck/src/components/ColumnBoard.tsx @@ -0,0 +1,93 @@ +import { useMemo } from "react"; +import type { + ColumnSnapshot, + ColumnState, + DeckAccount, + DeckColumn, + FullscreenState, +} from "../types/app"; +import { TimelineColumn, type TimelineConfig } from "./TimelineColumn"; +import { ChatColumn } from "./ChatColumn"; + +interface ColumnBoardProps { + columns: DeckColumn[]; + accounts: DeckAccount[]; + onRemove: (id: string) => void; + onStateChange: (columnId: string, state: ColumnState) => void; + onSnapshot: (columnId: string, snapshot: ColumnSnapshot) => void; + onEnterFullscreen: (payload: FullscreenState) => void; +} + +export function ColumnBoard({ + columns, + accounts, + onRemove, + onStateChange, + onSnapshot, + onEnterFullscreen, +}: ColumnBoardProps) { + const accountMap = useMemo( + () => Object.fromEntries(accounts.map(account => [account.id, account])), + [accounts], + ); + + if (!columns.length) { + return ( +
+

No columns yet

+

Build your deck

+

Add some columns from the left panel to start streaming timelines.

+
+ ); + } + + return ( +
+ {columns.map((column, columnIndex) => { + const account = accountMap[column.accountId]; + if (!account) { + return ( +
+
+
+

Account missing

+

{column.title}

+
+ +
+

The account for this column was removed.

+
+ ); + } + if (isChatColumn(column)) { + return ( + onRemove(column.id)} + /> + ); + } + return ( + onRemove(column.id)} + onStateChange={onStateChange} + onSnapshot={onSnapshot} + onEnterFullscreen={onEnterFullscreen} + columnIndex={columnIndex} + /> + ); + })} +
+ ); +} + +function isChatColumn(column: DeckColumn): column is DeckColumn & { kind: "chat" } { + return column.kind === "chat"; +} diff --git a/packages/tweetdeck/src/components/FullscreenColumn.tsx b/packages/tweetdeck/src/components/FullscreenColumn.tsx new file mode 100644 index 0000000..66959b8 --- /dev/null +++ b/packages/tweetdeck/src/components/FullscreenColumn.tsx @@ -0,0 +1,134 @@ +import { useEffect } from "react"; +import type { FullscreenState } from "../types/app"; +import { TweetCard } from "./TweetCard"; + +interface FullscreenColumnProps { + state: FullscreenState; + onExit: () => void; + onNavigate: (step: number) => void; + onSwitchColumn: (step: number) => void; + hasPrevColumn: boolean; + hasNextColumn: boolean; + onAddColumn: () => void; +} + +export function FullscreenColumn({ + state, + onExit, + onNavigate, + onSwitchColumn, + hasPrevColumn, + hasNextColumn, + onAddColumn, +}: FullscreenColumnProps) { + console.log({ state }); + const tweet = state.tweets[state.index]; + const hasTweets = state.tweets.length > 0; + + useEffect(() => { + const handler = (event: KeyboardEvent) => { + if (event.key === "Escape") { + onExit(); + return; + } + if (event.key === "ArrowDown") { + event.preventDefault(); + onNavigate(1); + } + if (event.key === "ArrowUp") { + event.preventDefault(); + onNavigate(-1); + } + if (event.key === "ArrowRight") { + event.preventDefault(); + if (hasNextColumn) { + onSwitchColumn(1); + } else { + onAddColumn(); + } + } + if (event.key === "ArrowLeft") { + event.preventDefault(); + if (hasPrevColumn) { + onSwitchColumn(-1); + } + } + }; + window.addEventListener("keydown", handler); + return () => window.removeEventListener("keydown", handler); + }, [ + onExit, + onNavigate, + onSwitchColumn, + hasPrevColumn, + hasNextColumn, + onAddColumn, + ]); + + return ( +
+ +
+
+

+ {state.columnLabel}@{state.column.account} +

+

+ {hasTweets + ? `${state.index + 1} / ${state.tweets.length}` + : "0 / 0"} +

+
+
+ {hasTweets && tweet ? ( + + ) : ( +
+

No tweets loaded for this column yet.

+

+ Try refreshing the column or exit fullscreen. +

+
+ )} +
+
+ + +
+
+ + +
+
+
+ ); +} diff --git a/packages/tweetdeck/src/components/Sidebar.tsx b/packages/tweetdeck/src/components/Sidebar.tsx new file mode 100644 index 0000000..3d9b85d --- /dev/null +++ b/packages/tweetdeck/src/components/Sidebar.tsx @@ -0,0 +1,153 @@ +import { useEffect, useState } from "react"; +import type { DeckAccount } from "../types/app"; +import { twitterClient } from "@/lib/client/twitterClient"; + +export interface NewAccountInput { + cookie: string; +} + +interface SidebarProps { + accounts: DeckAccount[]; + activeAccountId?: string; + onActivate: (id: string) => void; + onAddAccount: (payload: NewAccountInput) => void; + onRemoveAccount: (id: string) => void; + onAddColumn: () => void; +} + +export function Sidebar({ + accounts, + activeAccountId, + onActivate, + onAddAccount, + onRemoveAccount, + onAddColumn, +}: SidebarProps) { + const [isAdding, setIsAdding] = useState(!accounts.length); + const [cookie, setCookie] = useState(""); + const [showCookie, setShowCookie] = useState(false); + + const handleSubmit = (event: React.FormEvent) => { + event.preventDefault(); + if (!cookie.trim()) return; + onAddAccount({ cookie: decodeURIComponent(cookie.trim()) }); + setCookie(""); + setIsAdding(false); + }; + + return ( +