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 ? (
) : (
)}
);
}