diff options
Diffstat (limited to 'packages/tweetdeck/src/components')
| -rw-r--r-- | packages/tweetdeck/src/components/AddColumnModal.tsx | 234 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/ChatCard.tsx | 56 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/ChatColumn.tsx | 62 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/ColumnBoard.tsx | 93 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/FullscreenColumn.tsx | 134 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/Sidebar.tsx | 153 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/TimelineColumn.tsx | 500 | ||||
| -rw-r--r-- | packages/tweetdeck/src/components/TweetCard.tsx | 337 |
8 files changed, 1569 insertions, 0 deletions
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<DeckColumn, "id">) => void; + activeAccountId?: string; + fetchLists: (accountId: string) => Promise<TwitterList[]>; + 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<DeckColumn["kind"]>("foryou"); + const [accountId, setAccountId] = useState<string>( + activeAccountId || accounts[0]?.id || "", + ); + const [title, setTitle] = useState("For You"); + const [listId, setListId] = useState<string>(""); + const [listOptions, setListOptions] = useState<TwitterList[]>([]); + const [listsLoading, setListsLoading] = useState(false); + const [listsError, setListsError] = useState<string | undefined>(); + + 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 ( + <div + className="modal-backdrop" + role="dialog" + aria-modal="true" + onMouseDown={(event) => { + if (event.target === event.currentTarget) { + onClose(); + } + }} + > + <div className="modal"> + <header> + <div> + <p className="eyebrow">New column</p> + <h2>Design your stream</h2> + </div> + <button + className="ghost" + onClick={onClose} + aria-label="Close add column modal" + > + × + </button> + </header> + <form onSubmit={handleSubmit} className="modal-body"> + <label> + Account + <select + value={accountId} + onChange={(e) => setAccountId(e.target.value)} + > + {accounts.map((account) => ( + <option value={account.id} key={account.id}> + {account.label} + {account.handle ? ` (@${account.handle})` : ""} + </option> + ))} + </select> + </label> + <div className="option-grid"> + {columnOptions.map((option) => ( + <button + type="button" + key={option.id} + className={`option ${kind === option.id ? "selected" : ""}`} + onClick={() => setKind(option.id)} + > + <div> + <strong>{option.label}</strong> + <p>{option.description}</p> + </div> + </button> + ))} + </div> + {kind === "list" && ( + <label> + Choose a list + {listsError && <span className="error">{listsError}</span>} + <select + value={listId} + disabled={listsLoading} + onChange={(e) => setListId(e.target.value)} + > + <option value="" disabled> + {listsLoading ? "Loading lists..." : "Select a list"} + </option> + {listOptions.map((list) => ( + <option key={list.id} value={list.id}> + {list.name} ({list.member_count}) + </option> + ))} + </select> + </label> + )} + <label> + Column title + <input + value={title} + onChange={(e) => setTitle(e.target.value)} + placeholder="Custom title" + /> + </label> + <button className="primary" type="submit" disabled={!canSubmit}> + Add column + </button> + </form> + </div> + </div> + ); +} + +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 ( + <article className="chat-card" style={{ borderColor: accent }}> + <div className="chat-avatar"> + {firstUser?.profile_image_url_https ? ( + <img src={firstUser.profile_image_url_https} alt={firstUser.name} loading="lazy" /> + ) : ( + <span>{firstUser?.name?.[0] ?? "?"}</span> + )} + </div> + <div className="chat-body"> + <header> + <strong>{firstUser?.name ?? "Notification"}</strong> + {firstUser?.screen_name && <span className="muted">@{firstUser.screen_name}</span>} + <span className="muted dot" aria-hidden="true"> + • + </span> + <span className="muted">{timestamp}</span> + </header> + <p>{highlight(notification.message.text)}</p> + </div> + </article> + ); +} + +function highlight(text: string) { + const parts = text.split(/([@#][A-Za-z0-9_]+)/g); + return parts.map((part, index) => { + if (part.startsWith("@")) { + return ( + <span key={index} className="mention"> + {part} + </span> + ); + } + if (part.startsWith("#")) { + return ( + <span key={index} className="hashtag"> + {part} + </span> + ); + } + return <span key={index}>{part}</span>; + }); +} 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<ChatState>({ entries: [], isLoading: false }); + const [error, setError] = useState<string | undefined>(); + + 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 ( + <article className="column"> + <header> + <div> + <p className="eyebrow">Signal</p> + <h3>{column.title || "Chat"}</h3> + <p className="muted tiny">Mentions, follows and notifications for {account.label}</p> + </div> + <div className="column-actions"> + <button className="ghost" onClick={refresh} aria-label="Refresh chat"> + ↻ + </button> + <button className="ghost" onClick={onRemove} aria-label="Remove column"> + × + </button> + </div> + </header> + {error && <p className="error">{error}</p>} + {state.isLoading && !state.entries.length ? ( + <div className="column-loading">Loading…</div> + ) : ( + <div className="chat-stack"> + {state.entries.map(entry => ( + <ChatCard key={entry.id} notification={entry} accent={account.accent} /> + ))} + {!state.entries.length && <p className="muted">No recent notifications.</p>} + </div> + )} + </article> + ); +} 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 ( + <section className="empty-board"> + <p className="eyebrow">No columns yet</p> + <h2>Build your deck</h2> + <p>Add some columns from the left panel to start streaming timelines.</p> + </section> + ); + } + + return ( + <section className="column-board"> + {columns.map((column, columnIndex) => { + const account = accountMap[column.accountId]; + if (!account) { + return ( + <div className="column missing" key={column.id}> + <header> + <div> + <p className="eyebrow">Account missing</p> + <h3>{column.title}</h3> + </div> + <button className="ghost" onClick={() => onRemove(column.id)}> + Remove + </button> + </header> + <p className="muted">The account for this column was removed.</p> + </div> + ); + } + if (isChatColumn(column)) { + return ( + <ChatColumn + key={column.id} + column={column} + account={account} + onRemove={() => onRemove(column.id)} + /> + ); + } + return ( + <TimelineColumn + key={column.id} + column={column as TimelineConfig} + account={account} + onRemove={() => onRemove(column.id)} + onStateChange={onStateChange} + onSnapshot={onSnapshot} + onEnterFullscreen={onEnterFullscreen} + columnIndex={columnIndex} + /> + ); + })} + </section> + ); +} + +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 ( + <div className="fullscreen-overlay"> + <button + className="ghost fullscreen-close" + onClick={onExit} + aria-label="Close fullscreen view" + > + × + </button> + <div className="fullscreen-content"> + <header> + <p className="eyebrow"> + {state.columnLabel}@{state.column.account} + </p> + <p className="muted tiny"> + {hasTweets + ? `${state.index + 1} / ${state.tweets.length}` + : "0 / 0"} + </p> + </header> + <div className="fullscreen-card"> + {hasTweets && tweet ? ( + <TweetCard tweet={tweet} accent={state.accent} /> + ) : ( + <div className="fullscreen-empty"> + <p>No tweets loaded for this column yet.</p> + <p className="muted"> + Try refreshing the column or exit fullscreen. + </p> + </div> + )} + </div> + <div className="fullscreen-controls"> + <button + className="ghost" + onClick={() => onNavigate(-1)} + disabled={!hasTweets || state.index === 0} + > + ↑ Previous tweet + </button> + <button + className="ghost" + onClick={() => onNavigate(1)} + disabled={!hasTweets || state.index >= state.tweets.length - 1} + > + Next tweet ↓ + </button> + </div> + <div className="fullscreen-column-controls"> + <button + className="ghost" + onClick={() => onSwitchColumn(-1)} + disabled={!hasPrevColumn} + > + ← Previous column + </button> + <button + className="ghost" + onClick={() => (hasNextColumn ? onSwitchColumn(1) : onAddColumn())} + > + {hasNextColumn ? "Next column →" : "+ Add column →"} + </button> + </div> + </div> + </div> + ); +} 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 ( + <aside className="sidebar"> + <div> + <div className="brand"> + <div className="brand-glow" /> + <div> + <p className="eyebrow">Project Starling</p> + <h1>Open TweetDeck</h1> + <p className="tagline"> + Multi-account Twitter cockpit powered by Bun. + </p> + </div> + </div> + + <section className="sidebar-section"> + <header> + <p className="eyebrow">Accounts</p> + <button className="ghost" onClick={() => setIsAdding((v) => !v)}> + {isAdding ? "Close" : "Add"} + </button> + </header> + {!accounts.length && !isAdding && ( + <p className="muted"> + Add a Twitter session cookie to start streaming timelines. You can + rename the account later once data loads. + </p> + )} + {accounts.map((account) => ( + <div + role="button" + tabIndex={0} + key={account.id} + className={`account-chip ${account.id === activeAccountId ? "active" : ""}`} + onClick={() => onActivate(account.id)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + onActivate(account.id); + } + }} + > + <span + className="chip-accent" + style={{ background: account.accent }} + /> + <span> + <strong>{account.label}</strong> + {account.handle ? <small>@{account.handle}</small> : null} + </span> + <span className="chip-actions"> + <button + type="button" + className="ghost" + onClick={(event) => { + event.stopPropagation(); + onRemoveAccount(account.id); + }} + aria-label={`Remove ${account.label}`} + > + × + </button> + </span> + </div> + ))} + {isAdding && ( + <form className="account-form" onSubmit={handleSubmit}> + <label> + Twitter session cookie + <textarea + className={!showCookie ? "masked" : undefined} + placeholder="Paste the entire Cookie header" + value={cookie} + onChange={(e) => setCookie(e.target.value)} + rows={4} + /> + </label> + <label className="checkbox"> + <input + type="checkbox" + checked={showCookie} + onChange={(e) => setShowCookie(e.target.checked)} + /> + Reveal cookie contents + </label> + <small className="muted"> + Cookie stays in your browser via localStorage. It is only sent + to your Bun server when fetching timelines. + </small> + <button + className="primary" + type="submit" + disabled={!cookie.trim()} + > + Save account + </button> + </form> + )} + </section> + </div> + + <div className="sidebar-footer"> + <button + className="primary wide" + onClick={onAddColumn} + disabled={!accounts.length} + > + + Add column + </button> + <p className="muted tiny"> + Need a cookie? Open x.com, inspect network requests and copy the + request `Cookie` header. Keep it secret. + </p> + </div> + </aside> + ); +} 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", + }; + } +} diff --git a/packages/tweetdeck/src/components/TweetCard.tsx b/packages/tweetdeck/src/components/TweetCard.tsx new file mode 100644 index 0000000..7cd2936 --- /dev/null +++ b/packages/tweetdeck/src/components/TweetCard.tsx @@ -0,0 +1,337 @@ +import { useCallback, useState } from "react"; +import { Bookmark, Heart, Link2, MessageCircle, Repeat2 } from "lucide-react"; +import type { Tweet } from "../lib/fetching/types"; +import { timeAgo } from "../lib/utils/time"; +import { LangText } from "prosody-ui"; + +interface TweetCardProps { + tweet: Tweet; + accent: string; + allowBookmarkRemoval?: boolean; + onRemoveBookmark?: (tweetId: string) => void; + onReply?: (tweetId: string, text: string) => Promise<unknown>; + onToggleRetweet?: (tweetId: string, next: boolean) => Promise<unknown>; + onToggleLike?: (tweetId: string, next: boolean) => Promise<unknown>; + onToggleBookmark?: (tweetId: string, next: boolean) => Promise<unknown>; + onOpenAuthor?: (author: Tweet["author"]) => void; + onOpenThread?: (tweet: Tweet) => void; + isQuote?: boolean; +} + +type ActionKind = "reply" | "retweet" | "like" | "bookmark" | null; + +export function TweetCard(props: TweetCardProps) { + const { + tweet, + accent, + allowBookmarkRemoval, + onRemoveBookmark, + onOpenAuthor, + onOpenThread, + isQuote, + } = props; + const timestamp = timeAgo(tweet.time); + + return ( + <article + className="tweet-card" + lang={tweet.language} + style={{ borderColor: accent }} + > + {tweet.retweeted_by && ( + <p + onClick={() => onOpenAuthor?.(tweet.retweeted_by!.author)} + className="muted tiny retweet-banner" + > + Retweeted by {tweet.retweeted_by.author.name} + <span>{timeAgo(tweet.retweeted_by.time)}</span> + </p> + )} + <header> + <div className="author"> + <img + src={tweet.author.avatar} + alt={tweet.author.name} + loading="lazy" + /> + <button + type="button" + className="link-button author-meta" + onClick={() => onOpenAuthor?.(tweet.author)} + title={`View @${tweet.author.username}`} + disabled={!onOpenAuthor} + > + <strong>{tweet.author.name}</strong> + <span className="muted">@{tweet.author.username}</span> + </button> + </div> + <div className="meta"> + <button + type="button" + className="link-button muted" + onClick={() => onOpenThread?.(tweet)} + title="Open thread" + disabled={!onOpenThread} + > + {timestamp} + </button> + {allowBookmarkRemoval && onRemoveBookmark && ( + <button + className="ghost" + onClick={() => onRemoveBookmark(tweet.id)} + > + Remove + </button> + )} + </div> + </header> + {isQuote && tweet.replyingTo.length > 0 && ( + <div className="tweet-replying-to"> + <span>replying to</span> + {tweet.replyingTo.map((rt) => ( + <span key={rt.username}>@{rt.username}</span> + ))} + </div> + )} + <div className="tweet-body"> + <div className="tweet-text"> + <LangText lang={tweet.language} text={tweet.text} /> + {/*renderText(tweet.text)}*/} + </div> + {!!tweet.media.pics.length && ( + <div + className={`media-grid pics-${Math.min(tweet.media.pics.length, 4)}`} + > + {tweet.media.pics.map((pic) => ( + <img key={pic} src={pic} alt="Tweet media" loading="lazy" /> + ))} + </div> + )} + {tweet.media.video.url && ( + <div className="video-wrapper"> + <video controls preload="none" poster={tweet.media.video.thumb}> + <source src={tweet.media.video.url} /> + </video> + </div> + )} + {!!tweet.urls.length && ( + <div className="link-chips"> + {tweet.urls.map((link) => ( + <a + key={link.expandedUrl} + href={link.expandedUrl} + target="_blank" + rel="noreferrer" + > + {link.displayUrl} + </a> + ))} + </div> + // end body + )} + {tweet.quoting && <Quote tweet={tweet.quoting} />} + </div> + {!isQuote && <Actions {...props} />} + </article> + ); +} + +type Token = { type: "text" | "mention" | "hashtag" | "url"; value: string }; + +function tokenize(text: string): Token[] { + const tokens: Token[] = []; + const regex = /(https?:\/\/\S+|@[A-Za-z0-9_]+|#[A-Za-z0-9_]+)/g; + let lastIndex = 0; + for (const match of text.matchAll(regex)) { + const value = match[0]; + const index = match.index ?? 0; + if (index > lastIndex) { + tokens.push({ type: "text", value: text.slice(lastIndex, index) }); + } + if (value.startsWith("http")) { + tokens.push({ type: "url", value }); + } else if (value.startsWith("@")) { + tokens.push({ type: "mention", value }); + } else if (value.startsWith("#")) { + tokens.push({ type: "hashtag", value }); + } + lastIndex = index + value.length; + } + if (lastIndex < text.length) { + tokens.push({ type: "text", value: text.slice(lastIndex) }); + } + return tokens; +} + +function renderText(text: string) { + return tokenize(text).map((token, index) => { + if (token.type === "text") { + return token.value.split("\n").map((segment, segmentIndex, arr) => ( + <span key={`${index}-${segmentIndex}`}> + {segment} + {segmentIndex < arr.length - 1 ? <br /> : null} + </span> + )); + } + if (token.type === "url") { + return ( + <a key={index} href={token.value} target="_blank" rel="noreferrer"> + {token.value} + </a> + ); + } + if (token.type === "mention") { + return ( + <span key={index} className="mention"> + {token.value} + </span> + ); + } + return ( + <span key={index} className="hashtag"> + {token.value} + </span> + ); + }); +} + +function Actions(props: TweetCardProps) { + const { tweet, onReply, onToggleRetweet, onToggleLike, onToggleBookmark } = + props; + + const tweetUrl = `https://x.com/${tweet.author.username}/status/${tweet.id}`; + const [copied, setCopied] = useState(false); + const [pendingAction, setPendingAction] = useState<ActionKind>(null); + + const copyLink = useCallback(async () => { + try { + if (navigator?.clipboard?.writeText) { + await navigator.clipboard.writeText(tweetUrl); + } else if (typeof document !== "undefined") { + const textarea = document.createElement("textarea"); + textarea.value = tweetUrl; + textarea.style.position = "fixed"; + textarea.style.opacity = "0"; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand("copy"); + document.body.removeChild(textarea); + } + setCopied(true); + setTimeout(() => setCopied(false), 1800); + } catch (error) { + console.warn("Failed to copy tweet link", error); + } + }, [tweetUrl]); + + const executeAction = useCallback( + async (kind: ActionKind, fn?: () => Promise<unknown>) => { + if (!fn) return; + setPendingAction(kind); + try { + await fn(); + } catch (error) { + console.error(`Failed to perform ${kind ?? "action"}`, error); + } finally { + setPendingAction(null); + } + }, + [], + ); + + const handleReply = useCallback(() => { + if (!onReply || typeof window === "undefined") return; + const prefill = `@${tweet.author.username} `; + const text = window.prompt("Reply", prefill); + if (!text || !text.trim()) return; + executeAction("reply", () => onReply(tweet.id, text.trim())); + }, [executeAction, onReply, tweet.author.username, tweet.id]); + + const handleRetweet = useCallback(() => { + if (!onToggleRetweet) return; + const next = !tweet.rted; + executeAction("retweet", () => onToggleRetweet(tweet.id, next)); + }, [executeAction, onToggleRetweet, tweet.id, tweet.rted]); + + const handleLike = useCallback(() => { + if (!onToggleLike) return; + const next = !tweet.liked; + executeAction("like", () => onToggleLike(tweet.id, next)); + }, [executeAction, onToggleLike, tweet.id, tweet.liked]); + + const handleBookmark = useCallback(() => { + if (!onToggleBookmark) return; + const next = !tweet.bookmarked; + executeAction("bookmark", () => onToggleBookmark(tweet.id, next)); + }, [executeAction, onToggleBookmark, tweet.bookmarked, tweet.id]); + + return ( + <footer className="tweet-actions"> + <button + type="button" + className={`action ${pendingAction === "reply" ? "in-flight" : ""}`} + aria-label="Reply" + title="Reply" + disabled={pendingAction === "reply"} + onClick={handleReply} + > + <MessageCircle /> + <span className="sr-only">Reply</span> + </button> + <button + type="button" + className={`action retweet ${tweet.rted ? "active" : ""} ${pendingAction === "retweet" ? "in-flight" : ""}`} + aria-label={tweet.rted ? "Undo Retweet" : "Retweet"} + aria-pressed={tweet.rted} + title={tweet.rted ? "Undo Retweet" : "Retweet"} + disabled={pendingAction === "retweet"} + onClick={handleRetweet} + > + <Repeat2 /> + <span className="sr-only">Retweet</span> + </button> + <button + type="button" + className={`action like ${tweet.liked ? "active" : ""} ${pendingAction === "like" ? "in-flight" : ""}`} + aria-label={tweet.liked ? "Undo like" : "Like"} + aria-pressed={tweet.liked} + title={tweet.liked ? "Undo like" : "Like"} + disabled={pendingAction === "like"} + onClick={handleLike} + > + <Heart /> + <span className="sr-only">Like</span> + </button> + <button + type="button" + className={`action bookmark ${tweet.bookmarked ? "active" : ""} ${pendingAction === "bookmark" ? "in-flight" : ""}`} + aria-label={tweet.bookmarked ? "Remove bookmark" : "Bookmark"} + aria-pressed={tweet.bookmarked} + title={tweet.bookmarked ? "Remove bookmark" : "Bookmark"} + disabled={pendingAction === "bookmark"} + onClick={handleBookmark} + > + <Bookmark /> + <span className="sr-only">Bookmark</span> + </button> + <button + type="button" + className={`action ${copied ? "copied" : ""}`} + aria-label="Copy link" + title="Copy link" + onClick={copyLink} + > + <Link2 /> + <span className="sr-only">Copy link</span> + </button> + </footer> + ); +} + +function Quote({ tweet }: { tweet: Tweet }) { + return ( + <div className="tweet-quote"> + <TweetCard tweet={tweet} accent="" isQuote={true} /> + </div> + ); +} |
