import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Box, type InputRenderable } from "../../../ui"; import { EmptyState, Tabs, usePaneHeaderTabs, } from "../../../components"; import type { PaneProps, TickerResearchTabProps } from "../../../types/plugin"; import { usePaneInstance, usePaneInstanceId } from "../../../state/app/context"; import { usePluginPaneState, usePluginState } from "../../runtime"; import { apiClient, type CloudTweetQueryType, type CloudTweetSearchResponse } from "../../../api-client"; import { truncateWithEllipsis } from "../../../utils/text-wrap"; import { DEFAULT_TWEET_HOURS, DEFAULT_TWEET_LIMIT, EMPTY_FEED_STATE, TWEET_SEARCH_SCHEMA_VERSION, TWITTER_FEED_LAUNCH_SCHEMA_VERSION, TWITTER_FEED_LAUNCH_STATE_KEY, createFeed, deriveFeedTitle, normalizeFeedQuery, normalizeFeeds, type PersistedTwitterFeedState, type TwitterFeed, type TwitterFeedLaunchRequest, } from "./model"; import { TweetSearchTable, tweetDetailStateKey } from "./table"; import { TwitterFeedSearchBar } from "./search-bar"; import { useTwitterFeedFooter } from "./footer"; import { useTwitterFeedKeyboard } from "./keyboard"; import { usePaneTickerIdentity } from "../../../state/hooks/pane-ticker"; const FEED_TABLE_ID = "twitter-feed-search"; export function TwitterTickerTab({ focused, width, height }: TickerResearchTabProps) { const { symbol } = usePaneTickerIdentity(); const load = useCallback((offset: number) => { if (!symbol) throw new Error("No ticker selected"); return apiClient.getCloudTickerTweets({ ticker: symbol, hours: DEFAULT_TWEET_HOURS, limit: DEFAULT_TWEET_LIMIT, offset, includeReplies: false, }); }, [symbol]); if (!symbol) { return ; } return ( ); } export function TwitterFeedPane({ focused, width, height }: PaneProps) { const paneId = usePaneInstanceId(); const paneInstance = usePaneInstance(); const [legacyPersistedState, setLegacyPersistedState] = usePluginState( `twitter-feed:${paneId}`, EMPTY_FEED_STATE, { schemaVersion: TWEET_SEARCH_SCHEMA_VERSION }, ); const [panePersistedState, setPanePersistedState] = usePluginPaneState( "feeds", null, ); const persistedState = panePersistedState ?? legacyPersistedState; const [launchRequest, setLaunchRequest] = usePluginState( TWITTER_FEED_LAUNCH_STATE_KEY, null, { schemaVersion: TWITTER_FEED_LAUNCH_SCHEMA_VERSION }, ); const [activeFeedId, setActiveFeedId] = usePluginPaneState("activeFeedId", null); const [searchFocused, setSearchFocused] = useState(false); const [searchFocusToken, setSearchFocusToken] = useState(0); const searchInputRef = useRef(null); const initializedRef = useRef(false); const feeds = useMemo(() => normalizeFeeds(persistedState), [persistedState]); useEffect(() => { if (panePersistedState !== null) return; const legacyFeeds = normalizeFeeds(legacyPersistedState); if (legacyFeeds.length === 0) return; setPanePersistedState({ feeds: legacyFeeds }); setLegacyPersistedState(EMPTY_FEED_STATE); }, [legacyPersistedState, panePersistedState, setLegacyPersistedState, setPanePersistedState]); const focusSearch = useCallback(() => { setSearchFocused(true); setSearchFocusToken((current) => current + 1); }, []); const blurSearch = useCallback(() => { setSearchFocused(false); }, []); const updateFeeds = useCallback((updater: (feeds: TwitterFeed[]) => TwitterFeed[]) => { setPanePersistedState((current) => ({ feeds: updater(normalizeFeeds(current ?? persistedState)), })); }, [persistedState, setPanePersistedState]); const addFeed = useCallback((query = "", queryType: CloudTweetQueryType = "Latest") => { const feed = createFeed(query, queryType); updateFeeds((current) => [...current, feed]); setActiveFeedId(feed.id); focusSearch(); return feed.id; }, [focusSearch, setActiveFeedId, updateFeeds]); const openOrCreateFeed = useCallback(( query: string, queryType: CloudTweetQueryType = "Latest", options?: { focusSearch?: boolean }, ) => { const normalizedQuery = normalizeFeedQuery(query); if (!normalizedQuery) { if (feeds.length === 0) addFeed("", queryType); return; } let nextActiveId: string | null = null; updateFeeds((current) => { const existing = current.find((feed) => normalizeFeedQuery(feed.query) === normalizedQuery); if (existing) { nextActiveId = existing.id; return current; } const feed = createFeed(query, queryType); nextActiveId = feed.id; return [...current, feed]; }); if (nextActiveId) setActiveFeedId(nextActiveId); if (options?.focusSearch) focusSearch(); }, [addFeed, feeds.length, focusSearch, setActiveFeedId, updateFeeds]); const updateFeedQuery = useCallback((feedId: string, query: string) => { const now = Date.now(); updateFeeds((current) => current.map((feed) => ( feed.id === feedId ? { ...feed, query, title: deriveFeedTitle(query), updatedAt: now, lastError: null, } : feed ))); }, [updateFeeds]); useEffect(() => { if (initializedRef.current) return; initializedRef.current = true; if (feeds.length > 0) return; const seedQuery = typeof paneInstance?.params?.query === "string" ? paneInstance.params.query : ""; const seedType = paneInstance?.params?.queryType === "Top" ? "Top" : "Latest"; const feed = createFeed(seedQuery, seedType); setPanePersistedState({ feeds: [feed] }); setActiveFeedId(feed.id); if (!seedQuery.trim()) focusSearch(); }, [feeds.length, focusSearch, paneInstance?.params?.query, paneInstance?.params?.queryType, setActiveFeedId, setPanePersistedState]); useEffect(() => { if (!launchRequest) return; if (launchRequest.targetPaneId && launchRequest.targetPaneId !== paneId) return; const queryType = launchRequest.queryType === "Top" ? "Top" : "Latest"; if (launchRequest.query.trim()) { openOrCreateFeed(launchRequest.query, queryType); } else if (feeds.length === 0) { addFeed("", queryType); } setLaunchRequest(null); }, [addFeed, feeds.length, launchRequest, openOrCreateFeed, paneId, setLaunchRequest]); useEffect(() => { if (feeds.length === 0) { if (activeFeedId !== null) setActiveFeedId(null); return; } if (!activeFeedId || !feeds.some((feed) => feed.id === activeFeedId)) { setActiveFeedId(feeds[0]!.id); } }, [activeFeedId, feeds, setActiveFeedId]); const activeFeed = feeds.find((feed) => feed.id === activeFeedId) ?? feeds[0] ?? null; const removeFeed = useCallback((feedId: string) => { let nextActiveId: string | null = null; let createdEmpty = false; updateFeeds((current) => { const next = current.filter((feed) => feed.id !== feedId); if (next.length === 0) { const feed = createFeed("", "Latest"); nextActiveId = feed.id; createdEmpty = true; return [feed]; } nextActiveId = activeFeedId === feedId ? next[0]!.id : activeFeedId && next.some((feed) => feed.id === activeFeedId) ? activeFeedId : next[0]!.id; return next; }); if (nextActiveId) setActiveFeedId(nextActiveId); if (createdEmpty) focusSearch(); }, [activeFeedId, focusSearch, setActiveFeedId, updateFeeds]); const cycleFeeds = useCallback((direction: -1 | 1) => { if (!activeFeed || feeds.length <= 1) return; const index = feeds.findIndex((feed) => feed.id === activeFeed.id); const nextIndex = (index + direction + feeds.length) % feeds.length; setActiveFeedId(feeds[nextIndex]!.id); }, [activeFeed, feeds, setActiveFeedId]); // The search bar sits above the list, so searching from an open tweet goes // back to the list first rather than focusing a bar that is not drawn. const [tweetOpen, setTweetOpen] = usePluginPaneState(tweetDetailStateKey(FEED_TABLE_ID), false); const searchFeed = useCallback(() => { setTweetOpen(false); focusSearch(); }, [focusSearch, setTweetOpen]); useTwitterFeedKeyboard({ activeFeed, addFeed, blurSearch, cycleFeeds, focusSearch: searchFeed, focused, removeFeed, searchFocused, tweetOpen, }); const activeFeedIdValue = activeFeed?.id ?? null; const activeFeedQuery = activeFeed?.query.trim() ?? ""; const activeFeedQueryType = activeFeed?.queryType ?? "Latest"; const searchEnabled = activeFeedQuery.length > 0; const loadActiveFeed = useCallback((offset: number) => { if (!activeFeedQuery) throw new Error("No X feed selected"); return apiClient.searchCloudTweets({ query: activeFeedQuery, queryType: activeFeedQueryType, hours: DEFAULT_TWEET_HOURS, limit: DEFAULT_TWEET_LIMIT, offset, }); }, [activeFeedQuery, activeFeedQueryType]); const markFeedResult = useCallback((result: CloudTweetSearchResponse) => { if (!activeFeedIdValue) return; const now = Date.now(); updateFeeds((current) => current.map((feed) => ( feed.id === activeFeedIdValue ? { ...feed, lastSuccessAt: now, lastError: null, title: deriveFeedTitle(result.query) } : feed ))); }, [activeFeedIdValue, updateFeeds]); const markFeedError = useCallback((message: string) => { if (!activeFeedIdValue) return; updateFeeds((current) => current.map((feed) => ( feed.id === activeFeedIdValue ? { ...feed, lastError: message } : feed ))); }, [activeFeedIdValue, updateFeeds]); const addEmptyFeed = useCallback(() => { addFeed(); }, [addFeed]); useTwitterFeedFooter({ activeFeed, addFeed: addEmptyFeed, focusSearch: searchFeed, removeFeed, tweetOpen, }); const feedTabs = useMemo(() => feeds.map((feed) => ({ label: truncateWithEllipsis(feed.title, 18), value: feed.id, onClose: removeFeed, onDoubleClick: searchFeed, })), [feeds, removeFeed, searchFeed]); const tabsInHeader = usePaneHeaderTabs({ tabs: feedTabs, activeValue: activeFeed?.id ?? null, onSelect: setActiveFeedId, focused: focused && !searchFocused, onAdd: () => addFeed(), closeMode: "active", }); const tabRows = tabsInHeader ? 0 : 1; const searchBar = activeFeed ? ( ) : null; return ( {!tabsInHeader && ( addFeed()} focused={focused && !searchFocused} /> )} {!activeFeed ? ( ) : ( )} ); }