import { useCallback, useEffect, useRef, useState } from "react";
import { Box, Text, type InputRenderable } from "../../../../ui";
import { usePaneFooter } from "../../../../components";
import { InlineQuickAddRow } from "../../../../components/ui";
import { useShortcut } from "../../../../react/input";
import { isPlainKey } from "../../../../utils/keyboard";
import { useAppDispatch, useAppSelector } from "../../../../state/app/context";
import { useAppInputCapture } from "../../../../state/app/input-capture";
import { t, tf } from "../../../../i18n";
import { getSharedRegistry } from "../../../registry";
import { usePluginAppActions } from "../../../runtime";
import { colors, priceColor } from "../../../../theme/colors";
import { formatPercentRaw } from "../../../../utils/format";
import { formatMarketPrice } from "../../../../market-data/market/format";
import { upsertTickerFromSearchResult } from "../../../../tickers/search";
import type { TickerRecord } from "../../../../types/ticker";
import { addTickerToPortfolio, addTickerToWatchlist } from "../mutations";
import {
IDLE_VALIDATION,
isPlausibleTickerQuery,
normalizeQuickAddQuery,
resolveQuickAddValidation,
tickerNameFromValidation,
type QuickAddCollectionKind,
type QuickAddValidation,
} from "./resolution";
const QUICK_ADD_DEBOUNCE_MS = 300;
export type { QuickAddCollectionKind } from "./resolution";
function QuickAddPreview({
validation,
collectionKind,
submitting,
}: {
validation: QuickAddValidation;
collectionKind: QuickAddCollectionKind;
submitting: boolean;
}) {
if (submitting) {
return {t("adding...")};
}
if (validation.status === "idle") {
return ;
}
if (validation.status === "checking") {
return {tf("{query} checking...", { query: validation.query })};
}
if (validation.status === "missing" || validation.status === "error") {
return {t(validation.message)};
}
if (validation.status === "duplicate") {
return {tf("{symbol} already in {collection}", {
symbol: validation.symbol,
collection: t(collectionKind === "portfolio" ? "Portfolio" : "Watchlist"),
})};
}
const quote = validation.quote;
const assetCategory = validation.ticker?.metadata.assetCategory
?? (validation.resolved.kind === "provider" ? validation.resolved.result.type : undefined);
const priceText = quote?.price != null
? formatMarketPrice(quote.price, { assetCategory, maxWidth: 12 })
: "-";
const changeValue = quote?.changePercent;
const name = tickerNameFromValidation(validation);
const showSymbol = normalizeQuickAddQuery(validation.symbol) !== validation.query;
const symbolPrefix = showSymbol ? `${validation.symbol} ` : "";
return (
{`${symbolPrefix}${priceText} `}
{formatPercentRaw(changeValue)}
{name ? {` ${name}`} : null}
);
}
export function QuickAddTickerInput({
collectionId,
collectionKind,
collectionName,
focused,
width,
onAdded,
onFocusChange,
}: {
collectionId: string;
collectionKind: QuickAddCollectionKind;
collectionName: string;
focused: boolean;
width: number;
onAdded: (symbol: string) => void;
onFocusChange?: (focused: boolean) => void;
}) {
const dispatch = useAppDispatch();
const { notify } = usePluginAppActions();
const tickers = useAppSelector((state) => state.tickers);
const financials = useAppSelector((state) => state.financials);
const inputRef = useRef(null);
const validationSeqRef = useRef(0);
const [inputFocused, setInputFocused] = useState(false);
const [inputValue, setInputValue] = useState("");
const [validation, setValidation] = useState(IDLE_VALIDATION);
const [submitting, setSubmitting] = useState(false);
useAppInputCapture(inputFocused && focused);
const focusInput = useCallback(() => {
if (!focused) return;
setInputFocused(true);
queueMicrotask(() => inputRef.current?.focus?.());
}, [focused]);
const blurInput = useCallback(() => {
setInputFocused(false);
}, []);
const resetInput = useCallback(() => {
setInputValue("");
setValidation(IDLE_VALIDATION);
}, []);
useEffect(() => {
onFocusChange?.(inputFocused && focused);
}, [focused, inputFocused, onFocusChange]);
useEffect(() => {
if (!focused && inputFocused) {
blurInput();
}
}, [blurInput, focused, inputFocused]);
const validateQuery = useCallback((query: string): Promise => {
return resolveQuickAddValidation({
query,
collectionId,
collectionKind,
tickers,
financials,
});
}, [collectionId, collectionKind, financials, tickers]);
useEffect(() => {
const query = normalizeQuickAddQuery(inputValue);
validationSeqRef.current += 1;
const seq = validationSeqRef.current;
if (!query) {
setValidation(IDLE_VALIDATION);
return;
}
if (!isPlausibleTickerQuery(query)) {
setValidation({ status: "missing", query, message: "Use a ticker symbol" });
return;
}
setValidation({ status: "checking", query });
const timeoutId = setTimeout(() => {
void validateQuery(query).then((result) => {
if (validationSeqRef.current === seq) {
setValidation(result);
}
});
}, QUICK_ADD_DEBOUNCE_MS);
return () => clearTimeout(timeoutId);
}, [inputValue, validateQuery]);
const submitInput = useCallback(async (submittedValue?: string) => {
const query = normalizeQuickAddQuery(submittedValue ?? inputValue);
if (!query || submitting) return;
setSubmitting(true);
try {
const currentValidation = (
validation.query === query
&& (validation.status === "ready" || validation.status === "duplicate")
)
? validation
: await validateQuery(query);
setValidation(currentValidation);
if (currentValidation.status === "duplicate") {
notify({
type: "info",
body: tf("{symbol} is already in {collection}.", {
symbol: currentValidation.symbol,
collection: collectionName,
}),
});
return;
}
if (currentValidation.status !== "ready") {
const body = currentValidation.status === "idle"
? "Ticker symbol is required."
: currentValidation.status === "checking"
? "Ticker lookup is still in progress."
: currentValidation.message;
notify({ type: "error", body });
return;
}
const registry = getSharedRegistry();
if (!registry) {
notify({ type: "error", body: t("Ticker lookup unavailable.") });
return;
}
let ticker: TickerRecord;
let created = false;
if (currentValidation.resolved.kind === "local") {
ticker = currentValidation.resolved.ticker;
} else {
const result = await upsertTickerFromSearchResult(registry.tickerRepository, currentValidation.resolved.result);
ticker = result.ticker;
created = result.created;
}
const result = collectionKind === "portfolio"
? addTickerToPortfolio(ticker, collectionId)
: addTickerToWatchlist(ticker, collectionId);
if (!result.changed) {
notify({
type: "info",
body: tf("{symbol} is already in {collection}.", {
symbol: ticker.metadata.ticker,
collection: collectionName,
}),
});
return;
}
await registry.tickerRepository.saveTicker(result.ticker);
dispatch({ type: "UPDATE_TICKER", ticker: result.ticker });
if (created) {
registry.events.emit("ticker:added", {
symbol: result.ticker.metadata.ticker,
ticker: result.ticker,
});
}
onAdded(result.ticker.metadata.ticker);
notify({
type: "success",
body: tf("Added {symbol} to {collection}.", {
symbol: result.ticker.metadata.ticker,
collection: collectionName,
}),
});
resetInput();
queueMicrotask(() => inputRef.current?.focus?.());
} catch {
setValidation({ status: "error", query, message: "Ticker add failed" });
notify({ type: "error", body: tf("Failed to add {symbol}.", { symbol: query }) });
} finally {
setSubmitting(false);
}
}, [
collectionId,
collectionKind,
collectionName,
dispatch,
inputValue,
notify,
onAdded,
resetInput,
submitting,
validateQuery,
validation,
]);
useShortcut((event) => {
if (!focused) return;
if (inputFocused) {
if (event.name === "escape") {
event.preventDefault?.();
event.stopPropagation?.();
resetInput();
blurInput();
}
return;
}
if (event.targetEditable) return;
// `a` is the footer's hint below; `n` reaches the same row.
if (isPlainKey(event, "a", "n")) {
event.preventDefault?.();
event.stopPropagation?.();
focusInput();
}
}, { phase: "before", allowEditable: true });
// The row shows only its placeholder, so the footer says which key reaches it.
usePaneFooter("portfolio-list:quick-add", () => ({
hints: inputFocused ? [] : [{ id: "add", key: "a", label: "dd", onPress: focusInput }],
}), [focusInput, inputFocused]);
return (
setInputValue(value.toUpperCase())}
onSubmit={(value) => { void submitInput(value); }}
onFocus={() => setInputFocused(true)}
onBlur={blurInput}
onCancel={() => {
inputRef.current?.blur?.();
resetInput();
blurInput();
}}
preview={(
)}
/>
);
}