gloomberb 0.7.2 → 0.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -132
- package/package.json +1 -1
- package/src/app.tsx +73 -67
- package/src/components/chart/chart-data.ts +7 -2
- package/src/components/chart/chart-pane-settings.ts +7 -4
- package/src/components/chart/chart-renderer.ts +158 -47
- package/src/components/chart/chart-types.ts +20 -2
- package/src/components/chart/chart.test.ts +179 -13
- package/src/components/chart/comparison-chart-renderer.test.ts +14 -30
- package/src/components/chart/comparison-chart-renderer.ts +50 -11
- package/src/components/chart/comparison-stock-chart.tsx +129 -129
- package/src/components/chart/market-session.ts +187 -0
- package/src/components/chart/native/chart-rasterizer.ts +108 -19
- package/src/components/chart/price-axis-labels.tsx +25 -9
- package/src/components/chart/stock-chart-renderer-switch.test.tsx +97 -1
- package/src/components/chart/stock-chart.tsx +95 -49
- package/src/components/chart/time-axis-label.tsx +56 -2
- package/src/components/command-bar/command-bar.test.tsx +446 -120
- package/src/components/command-bar/command-bar.tsx +535 -384
- package/src/components/command-bar/command-registry.ts +16 -6
- package/src/components/command-bar/list-model.ts +8 -4
- package/src/components/command-bar/root-shortcuts.ts +4 -5
- package/src/components/command-bar/view-model.test.ts +21 -0
- package/src/components/command-bar/view-model.ts +17 -4
- package/src/components/command-bar/workflow-ops.ts +11 -32
- package/src/components/data-table-view.test.tsx +24 -1
- package/src/components/data-table-view.tsx +3 -2
- package/src/components/feed-data-table-stack-view.tsx +3 -2
- package/src/components/layout/detached-pane-shell.tsx +2 -2
- package/src/components/layout/floating-pane.tsx +9 -2
- package/src/components/layout/pane-footer.tsx +1 -1
- package/src/components/layout/pane-header.tsx +4 -2
- package/src/components/layout/pane-sizing.ts +12 -0
- package/src/components/layout/pane.tsx +24 -2
- package/src/components/layout/shell.tsx +24 -10
- package/src/components/layout/status-bar.test.tsx +4 -1
- package/src/components/layout/status-bar.tsx +4 -24
- package/src/components/markdown-text.tsx +53 -8
- package/src/components/onboarding/onboarding-wizard.tsx +7 -6
- package/src/components/pane-settings-dialog.tsx +9 -6
- package/src/components/pane-template-wizard.tsx +3 -2
- package/src/components/price-selector-dialog.tsx +3 -2
- package/src/components/price-sparkline-view.tsx +463 -0
- package/src/components/price-sparkline.ts +46 -0
- package/src/components/table-view-shared.tsx +21 -3
- package/src/components/ticker-list-table-view.tsx +3 -2
- package/src/components/ticker-list-table.tsx +15 -66
- package/src/components/toggle-list.tsx +3 -3
- package/src/components/ui/choice-dialog.tsx +3 -2
- package/src/components/ui/multi-select-dialog.tsx +3 -2
- package/src/components/ui/native-select.tsx +3 -3
- package/src/core/state/app-state.ts +210 -80
- package/src/core/state/session-persistence.ts +25 -0
- package/src/data/config-store-node.ts +113 -25
- package/src/data/config-store.test.ts +65 -3
- package/src/data/plugin-state-store.ts +39 -13
- package/src/data/sqlite-cache.test.ts +13 -0
- package/src/market-data/coordinator-subscriptions.test.ts +102 -18
- package/src/market-data/coordinator.test.ts +122 -2
- package/src/market-data/coordinator.ts +200 -56
- package/src/market-data/hooks.test.tsx +31 -0
- package/src/market-data/hooks.tsx +1 -0
- package/src/news/aggregator.test.ts +41 -0
- package/src/news/aggregator.ts +61 -9
- package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
- package/src/plugins/builtin/ai/screener-pane.tsx +3 -0
- package/src/plugins/builtin/ai/settings.ts +3 -0
- package/src/plugins/builtin/broker-manager/index.tsx +4 -3
- package/src/plugins/builtin/chat-controller.test.ts +398 -17
- package/src/plugins/builtin/chat-controller.ts +263 -48
- package/src/plugins/builtin/chat.test.tsx +312 -6
- package/src/plugins/builtin/chat.tsx +245 -66
- package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
- package/src/plugins/builtin/correlation/colors.test.ts +39 -0
- package/src/plugins/builtin/correlation/colors.ts +87 -0
- package/src/plugins/builtin/correlation/index.test.tsx +151 -0
- package/src/plugins/builtin/correlation/index.tsx +92 -21
- package/src/plugins/builtin/debug.tsx +4 -3
- package/src/plugins/builtin/econ/index.tsx +3 -2
- package/src/plugins/builtin/help.test.tsx +2 -2
- package/src/plugins/builtin/holders/index.tsx +25 -13
- package/src/plugins/builtin/layout-manager.tsx +1 -7
- package/src/plugins/builtin/market-movers/index.tsx +72 -40
- package/src/plugins/builtin/news-wire/breaking-pane.test.tsx +156 -0
- package/src/plugins/builtin/news-wire/breaking-pane.tsx +0 -90
- package/src/plugins/builtin/news-wire/index.tsx +1 -3
- package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
- package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
- package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
- package/src/plugins/builtin/options.tsx +9 -17
- package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
- package/src/plugins/builtin/portfolio-list/metrics.ts +13 -2
- package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
- package/src/plugins/builtin/portfolio-list/pane.tsx +139 -23
- package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
- package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
- package/src/plugins/builtin/portfolio-list.test.tsx +311 -9
- package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
- package/src/plugins/builtin/sectors/index.tsx +275 -43
- package/src/plugins/builtin/sectors/sector-data.ts +41 -1
- package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
- package/src/plugins/builtin/ticker-detail/pane.tsx +18 -7
- package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +445 -63
- package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
- package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
- package/src/plugins/builtin/world-indices/index.tsx +58 -19
- package/src/plugins/ibkr/trading-pane.tsx +3 -2
- package/src/plugins/plugin-runtime.tsx +2 -2
- package/src/plugins/prediction-markets/settings.ts +4 -0
- package/src/plugins/registry.test.ts +55 -0
- package/src/plugins/registry.ts +11 -4
- package/src/plugins/ticker-navigation.test.ts +35 -5
- package/src/plugins/ticker-navigation.ts +16 -22
- package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
- package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
- package/src/renderers/electrobun/bun/application-menu.ts +6 -1
- package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
- package/src/renderers/electrobun/bun/index.ts +52 -13
- package/src/renderers/electrobun/view/app-services.ts +51 -9
- package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
- package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
- package/src/renderers/electrobun/view/main.tsx +0 -16
- package/src/renderers/electrobun/view/styles.css +23 -21
- package/src/renderers/electrobun/view/ui-host.tsx +6 -4
- package/src/renderers/opentui/host.tsx +2 -1
- package/src/renderers/opentui/start.tsx +1 -1
- package/src/renderers/opentui/test-utils.tsx +6 -5
- package/src/sources/gloomberb-cloud.test.ts +55 -2
- package/src/sources/gloomberb-cloud.ts +180 -29
- package/src/sources/provider-router.test.ts +567 -10
- package/src/sources/provider-router.ts +323 -93
- package/src/sources/yahoo-finance.test.ts +37 -0
- package/src/sources/yahoo-finance.ts +48 -2
- package/src/state/app-bootstrap.test.ts +50 -23
- package/src/state/app-bootstrap.ts +29 -13
- package/src/state/app-context.test.ts +113 -0
- package/src/state/app-context.tsx +8 -4
- package/src/state/session-persistence.test.ts +5 -3
- package/src/state/use-inline-tickers.ts +3 -0
- package/src/state/use-quote-streaming.ts +11 -0
- package/src/theme/colors.ts +13 -14
- package/src/types/config.ts +9 -4
- package/src/types/data-provider.ts +5 -1
- package/src/types/financials.ts +1 -0
- package/src/types/plugin.ts +8 -1
- package/src/ui/context-menu.tsx +4 -4
- package/src/utils/api-client.test.ts +248 -0
- package/src/utils/api-client.ts +178 -26
- package/src/utils/exchanges.test.ts +16 -0
- package/src/utils/exchanges.ts +33 -0
- package/src/utils/fuzzy-search.ts +12 -1
- package/src/utils/keyboard.ts +31 -0
- package/src/utils/options.ts +1 -1
- package/src/utils/price-history.test.ts +28 -1
- package/src/utils/price-history.ts +16 -0
- package/src/utils/quote-freshness.test.ts +150 -0
- package/src/utils/quote-freshness.ts +42 -1
- package/src/utils/quote-resolution.ts +2 -0
- package/src/utils/quote-time.test.ts +34 -0
- package/src/utils/quote-time.ts +18 -5
- package/src/utils/ticker-search.test.ts +2 -2
- package/src/utils/ticker-search.ts +48 -1
- package/src/version.ts +1 -1
- package/src/plugins/builtin/news-wire/digest-store.ts +0 -44
|
@@ -11,9 +11,11 @@ import {
|
|
|
11
11
|
} from "../../../components";
|
|
12
12
|
import { useShortcut } from "../../../react/input";
|
|
13
13
|
import { blendHex, colors, priceColor } from "../../../theme/colors";
|
|
14
|
+
import { higherContrast } from "../../../theme/color-utils";
|
|
14
15
|
import type { HolderData, HolderRecord } from "../../../types/financials";
|
|
15
16
|
import type { GloomPlugin } from "../../../types/plugin";
|
|
16
17
|
import { formatCompact, formatPercent, formatPercentRaw, padTo } from "../../../utils/format";
|
|
18
|
+
import { isPlainKey } from "../../../utils/keyboard";
|
|
17
19
|
import { useAssetData, usePluginPaneState } from "../../plugin-runtime";
|
|
18
20
|
import { createTickerSurfacePaneTemplate } from "../ticker-surface";
|
|
19
21
|
|
|
@@ -445,6 +447,14 @@ function desktopTileColor(row: HolderRow): string {
|
|
|
445
447
|
return blendHex(colors.panel, row.changePercent > 0 ? colors.positive : colors.negative, intensity);
|
|
446
448
|
}
|
|
447
449
|
|
|
450
|
+
function tileTextColor(backgroundColor: string): string {
|
|
451
|
+
return higherContrast(
|
|
452
|
+
higherContrast(colors.text, colors.textBright, backgroundColor),
|
|
453
|
+
colors.selectedText,
|
|
454
|
+
backgroundColor,
|
|
455
|
+
);
|
|
456
|
+
}
|
|
457
|
+
|
|
448
458
|
function Tile({ tile, selected, currency, marketCap, onSelect }: {
|
|
449
459
|
tile: TileLayout;
|
|
450
460
|
selected: boolean;
|
|
@@ -455,7 +465,8 @@ function Tile({ tile, selected, currency, marketCap, onSelect }: {
|
|
|
455
465
|
const renderWidth = Math.max(1, tile.width - (tile.width > 2 ? 1 : 0));
|
|
456
466
|
const renderHeight = Math.max(1, tile.height - (tile.height > 2 ? 1 : 0));
|
|
457
467
|
const innerWidth = Math.max(1, renderWidth - 1);
|
|
458
|
-
const
|
|
468
|
+
const backgroundColor = selected ? colors.selected : tileColor(tile.row);
|
|
469
|
+
const textColor = tileTextColor(backgroundColor);
|
|
459
470
|
const attributes = selected ? TextAttributes.BOLD : TextAttributes.NONE;
|
|
460
471
|
const label = tile.row.name;
|
|
461
472
|
const amount = tile.row.value != null
|
|
@@ -471,7 +482,7 @@ function Tile({ tile, selected, currency, marketCap, onSelect }: {
|
|
|
471
482
|
top={tile.y}
|
|
472
483
|
width={renderWidth}
|
|
473
484
|
height={renderHeight}
|
|
474
|
-
backgroundColor={
|
|
485
|
+
backgroundColor={backgroundColor}
|
|
475
486
|
onMouseDown={(event: PreventableMouseEvent) => {
|
|
476
487
|
event.preventDefault();
|
|
477
488
|
onSelect();
|
|
@@ -516,6 +527,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
|
|
|
516
527
|
const canShowLabel = tile.width >= 4 && tile.height >= 1.4;
|
|
517
528
|
const isTiny = tile.width < 5 || tile.height < 2;
|
|
518
529
|
const backgroundColor = desktopTileColor(tile.row);
|
|
530
|
+
const textColor = tileTextColor(backgroundColor);
|
|
519
531
|
const style: CSSProperties = {
|
|
520
532
|
position: "absolute",
|
|
521
533
|
left: `calc(${pct(tile.x, chartWidth)} + 1px)`,
|
|
@@ -531,14 +543,14 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
|
|
|
531
543
|
? `1px solid ${colors.textBright}`
|
|
532
544
|
: hovered
|
|
533
545
|
? `1px solid ${blendHex(colors.textBright, backgroundColor, 0.55)}`
|
|
534
|
-
:
|
|
546
|
+
: `1px solid ${blendHex(backgroundColor, colors.textBright, 0.11)}`,
|
|
535
547
|
boxShadow: selected
|
|
536
|
-
?
|
|
548
|
+
? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.55)}`
|
|
537
549
|
: hovered
|
|
538
|
-
?
|
|
550
|
+
? `inset 0 0 0 1px ${blendHex(backgroundColor, colors.textBright, 0.18)}`
|
|
539
551
|
: "none",
|
|
540
552
|
backgroundColor,
|
|
541
|
-
color:
|
|
553
|
+
color: textColor,
|
|
542
554
|
display: "flex",
|
|
543
555
|
flexDirection: "column",
|
|
544
556
|
alignItems: "stretch",
|
|
@@ -557,7 +569,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
|
|
|
557
569
|
whiteSpace: "nowrap",
|
|
558
570
|
lineHeight: 1.1,
|
|
559
571
|
letterSpacing: 0,
|
|
560
|
-
textShadow:
|
|
572
|
+
textShadow: `0 1px 1px ${blendHex(backgroundColor, colors.bg, 0.35)}`,
|
|
561
573
|
};
|
|
562
574
|
|
|
563
575
|
return (
|
|
@@ -573,7 +585,7 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
|
|
|
573
585
|
>
|
|
574
586
|
{canShowLabel && (
|
|
575
587
|
<Text
|
|
576
|
-
fg=
|
|
588
|
+
fg={textColor}
|
|
577
589
|
attributes={selected ? TextAttributes.BOLD : TextAttributes.NONE}
|
|
578
590
|
style={{
|
|
579
591
|
...textStyle,
|
|
@@ -586,10 +598,10 @@ function DesktopTile({ tile, chartWidth, chartHeight, selected, hovered, currenc
|
|
|
586
598
|
)}
|
|
587
599
|
{canShowDetails && (
|
|
588
600
|
<>
|
|
589
|
-
<Text fg=
|
|
590
|
-
<Text fg=
|
|
601
|
+
<Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{amount}</Text>
|
|
602
|
+
<Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{ownership ?? change}</Text>
|
|
591
603
|
{ownership && canShowChange && (
|
|
592
|
-
<Text fg=
|
|
604
|
+
<Text fg={textColor} style={{ ...textStyle, fontSize: 12, fontWeight: 600 }}>{change}</Text>
|
|
593
605
|
)}
|
|
594
606
|
</>
|
|
595
607
|
)}
|
|
@@ -810,7 +822,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
|
|
|
810
822
|
|
|
811
823
|
useShortcut((event) => {
|
|
812
824
|
if (!focused || viewMode !== "chart") return;
|
|
813
|
-
if (event
|
|
825
|
+
if (isPlainKey(event, "j", "down")) {
|
|
814
826
|
event.preventDefault?.();
|
|
815
827
|
event.stopPropagation?.();
|
|
816
828
|
const nextIdx = Math.min((selectedIdx >= 0 ? selectedIdx : 0) + 1, sortedRows.length - 1);
|
|
@@ -818,7 +830,7 @@ function HoldersView({ focused, width, height }: { focused: boolean; width: numb
|
|
|
818
830
|
if (nextRow) setSelectedId(nextRow.id);
|
|
819
831
|
return;
|
|
820
832
|
}
|
|
821
|
-
if (event
|
|
833
|
+
if (isPlainKey(event, "k", "up")) {
|
|
822
834
|
event.preventDefault?.();
|
|
823
835
|
event.stopPropagation?.();
|
|
824
836
|
const nextIdx = Math.max((selectedIdx >= 0 ? selectedIdx : 0) - 1, 0);
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { saveConfig } from "../../data/config-store";
|
|
2
1
|
import { findPaneInstance, type LayoutConfig } from "../../types/config";
|
|
3
2
|
import type { AppNotificationRequest, GloomPlugin, GloomPluginContext } from "../../types/plugin";
|
|
4
3
|
import type { AppAction } from "../../state/app-context";
|
|
@@ -30,15 +29,10 @@ export function clearLayoutManagerDispatch() {
|
|
|
30
29
|
getStateRef = null;
|
|
31
30
|
}
|
|
32
31
|
|
|
33
|
-
function persistLayout(
|
|
32
|
+
function persistLayout(_ctx: Pick<GloomPluginContext, "getConfig">, layout: LayoutConfig) {
|
|
34
33
|
if (!dispatchRef) return;
|
|
35
34
|
dispatchRef({ type: "PUSH_LAYOUT_HISTORY" });
|
|
36
35
|
dispatchRef({ type: "UPDATE_LAYOUT", layout });
|
|
37
|
-
const config = ctx.getConfig();
|
|
38
|
-
const layouts = config.layouts.map((savedLayout, index) => (
|
|
39
|
-
index === config.activeLayoutIndex ? { ...savedLayout, layout } : savedLayout
|
|
40
|
-
));
|
|
41
|
-
saveConfig({ ...config, layout, layouts }).catch(() => {});
|
|
42
36
|
}
|
|
43
37
|
|
|
44
38
|
function getFocusedPane(layout: LayoutConfig, focusedPaneId: string | null) {
|
|
@@ -158,6 +158,39 @@ function createRows(quotes: ScreenerQuote[]): MarketMoverRow[] {
|
|
|
158
158
|
}));
|
|
159
159
|
}
|
|
160
160
|
|
|
161
|
+
function summaryQuoteFromQuote(
|
|
162
|
+
symbol: string,
|
|
163
|
+
quote: { name?: string; price: number; change: number; changePercent: number },
|
|
164
|
+
): MarketSummaryQuote {
|
|
165
|
+
return {
|
|
166
|
+
symbol,
|
|
167
|
+
name: quote.name ?? symbol,
|
|
168
|
+
price: quote.price,
|
|
169
|
+
change: quote.change,
|
|
170
|
+
changePercent: quote.changePercent,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function screenerQuoteFromQuote(symbol: string, quote: { name?: string; price?: number; change?: number; changePercent?: number; volume?: number; currency?: string }): ScreenerQuote {
|
|
175
|
+
return {
|
|
176
|
+
symbol,
|
|
177
|
+
name: quote.name ?? symbol,
|
|
178
|
+
price: quote.price ?? 0,
|
|
179
|
+
change: quote.change ?? 0,
|
|
180
|
+
changePercent: quote.changePercent ?? 0,
|
|
181
|
+
volume: quote.volume ?? 0,
|
|
182
|
+
avgVolume: 0,
|
|
183
|
+
volumeRatio: 0,
|
|
184
|
+
marketCap: undefined,
|
|
185
|
+
currency: quote.currency ?? "USD",
|
|
186
|
+
fiftyTwoWeekHigh: undefined,
|
|
187
|
+
fiftyTwoWeekLow: undefined,
|
|
188
|
+
dayHigh: undefined,
|
|
189
|
+
dayLow: undefined,
|
|
190
|
+
exchange: "",
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
161
194
|
function buildColumns(width: number): MarketMoverColumn[] {
|
|
162
195
|
const rankWidth = 3;
|
|
163
196
|
const tickerWidth = 8;
|
|
@@ -227,28 +260,34 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
227
260
|
useEffect(() => {
|
|
228
261
|
if (!dataProvider) return;
|
|
229
262
|
const loadSummary = async () => {
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
)
|
|
247
|
-
|
|
263
|
+
if (dataProvider.getQuotesBatch) {
|
|
264
|
+
const batchResults = await dataProvider.getQuotesBatch(
|
|
265
|
+
MARKET_SUMMARY_SYMBOLS.map((symbol) => ({ symbol, exchange: "" })),
|
|
266
|
+
).catch(() => []);
|
|
267
|
+
const bySymbol = new Map(batchResults.map((result) => [result.target.symbol, result]));
|
|
268
|
+
setSummaryQuotes(
|
|
269
|
+
MARKET_SUMMARY_SYMBOLS
|
|
270
|
+
.map((symbol) => {
|
|
271
|
+
const result = bySymbol.get(symbol);
|
|
272
|
+
return result?.quote ? summaryQuoteFromQuote(symbol, result.quote) : null;
|
|
273
|
+
})
|
|
274
|
+
.filter((quote): quote is MarketSummaryQuote => !!quote),
|
|
275
|
+
);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const results = await Promise.all(MARKET_SUMMARY_SYMBOLS.map(async (symbol) => {
|
|
280
|
+
try {
|
|
281
|
+
const quote = await dataProvider.getQuote(symbol, "");
|
|
282
|
+
return quote ? summaryQuoteFromQuote(symbol, quote) : null;
|
|
283
|
+
} catch {
|
|
284
|
+
return null;
|
|
285
|
+
}
|
|
286
|
+
}));
|
|
248
287
|
setSummaryQuotes(
|
|
249
288
|
MARKET_SUMMARY_SYMBOLS
|
|
250
289
|
.map((s) => results.find((r) => r.symbol === s))
|
|
251
|
-
.filter((r): r is MarketSummaryQuote => r
|
|
290
|
+
.filter((r): r is MarketSummaryQuote => !!r),
|
|
252
291
|
);
|
|
253
292
|
};
|
|
254
293
|
loadSummary();
|
|
@@ -280,33 +319,26 @@ function MarketMoversPane({ focused, width, height }: PaneProps) {
|
|
|
280
319
|
const resolved: ScreenerQuote[] = [];
|
|
281
320
|
|
|
282
321
|
if (dataProvider) {
|
|
283
|
-
|
|
284
|
-
|
|
322
|
+
const targets = trending.slice(0, 25).map(({ symbol }) => ({ symbol, exchange: "" }));
|
|
323
|
+
if (dataProvider.getQuotesBatch) {
|
|
324
|
+
const batchResults = await dataProvider.getQuotesBatch(targets).catch(() => []);
|
|
325
|
+
for (const result of batchResults) {
|
|
326
|
+
if (fetchGenRef.current !== gen) return;
|
|
327
|
+
if (result.quote) {
|
|
328
|
+
resolved.push(screenerQuoteFromQuote(result.target.symbol, result.quote));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
await Promise.allSettled(targets.map(async ({ symbol }) => {
|
|
285
333
|
try {
|
|
286
334
|
const q = await dataProvider.getQuote(symbol, "");
|
|
287
335
|
if (fetchGenRef.current !== gen) return;
|
|
288
336
|
if (q) {
|
|
289
|
-
resolved.push(
|
|
290
|
-
symbol,
|
|
291
|
-
name: q.name ?? symbol,
|
|
292
|
-
price: q.price ?? 0,
|
|
293
|
-
change: q.change ?? 0,
|
|
294
|
-
changePercent: q.changePercent ?? 0,
|
|
295
|
-
volume: q.volume ?? 0,
|
|
296
|
-
avgVolume: 0,
|
|
297
|
-
volumeRatio: 0,
|
|
298
|
-
marketCap: undefined,
|
|
299
|
-
currency: q.currency ?? "USD",
|
|
300
|
-
fiftyTwoWeekHigh: undefined,
|
|
301
|
-
fiftyTwoWeekLow: undefined,
|
|
302
|
-
dayHigh: undefined,
|
|
303
|
-
dayLow: undefined,
|
|
304
|
-
exchange: "",
|
|
305
|
-
});
|
|
337
|
+
resolved.push(screenerQuoteFromQuote(symbol, q));
|
|
306
338
|
}
|
|
307
339
|
} catch { /* skip */ }
|
|
308
|
-
})
|
|
309
|
-
|
|
340
|
+
}));
|
|
341
|
+
}
|
|
310
342
|
}
|
|
311
343
|
|
|
312
344
|
if (fetchGenRef.current !== gen) return;
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { afterEach, describe, expect, test } from "bun:test";
|
|
2
|
+
import { act } from "react";
|
|
3
|
+
import { PaneFooterBar, PaneFooterProvider } from "../../../components/layout/pane-footer";
|
|
4
|
+
import type { NewsService } from "../../../news/aggregator";
|
|
5
|
+
import { setSharedNewsService } from "../../../news/hooks";
|
|
6
|
+
import type { NewsArticle, NewsQueryState } from "../../../news/types";
|
|
7
|
+
import { testRender } from "../../../renderers/opentui/test-utils";
|
|
8
|
+
import { AppContext, PaneInstanceProvider, createInitialState } from "../../../state/app-context";
|
|
9
|
+
import { createStatefulTestPluginRuntime } from "../../../test-support/plugin-runtime";
|
|
10
|
+
import { createDefaultConfig } from "../../../types/config";
|
|
11
|
+
import { Box } from "../../../ui";
|
|
12
|
+
import { PluginRenderProvider } from "../../plugin-runtime";
|
|
13
|
+
import { __setDetectedProvidersForTests } from "../ai/providers";
|
|
14
|
+
import { setAiRunHost } from "../ai/runner";
|
|
15
|
+
import { BreakingPane } from "./breaking-pane";
|
|
16
|
+
|
|
17
|
+
const PANE_ID = "news-breaking:test";
|
|
18
|
+
|
|
19
|
+
let testSetup: Awaited<ReturnType<typeof testRender>> | undefined;
|
|
20
|
+
|
|
21
|
+
function makeArticle(): NewsArticle {
|
|
22
|
+
return {
|
|
23
|
+
id: "story-1",
|
|
24
|
+
title: "Chip stocks rally on new AI demand",
|
|
25
|
+
url: "https://example.com/story",
|
|
26
|
+
source: "example",
|
|
27
|
+
publishedAt: new Date("2026-05-13T12:00:00.000Z"),
|
|
28
|
+
summary: "Semiconductor names moved higher after hyperscaler capex commentary.",
|
|
29
|
+
topic: "earnings",
|
|
30
|
+
topics: ["earnings"],
|
|
31
|
+
sectors: ["information_technology"],
|
|
32
|
+
categories: ["earnings", "information_technology"],
|
|
33
|
+
tickers: ["AMD", "NVDA"],
|
|
34
|
+
sentiment: "positive",
|
|
35
|
+
scores: {
|
|
36
|
+
importance: 85,
|
|
37
|
+
urgency: 80,
|
|
38
|
+
marketImpact: 75,
|
|
39
|
+
novelty: 60,
|
|
40
|
+
confidence: 90,
|
|
41
|
+
},
|
|
42
|
+
isBreaking: true,
|
|
43
|
+
isDeveloping: false,
|
|
44
|
+
importance: 85,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function createReadyNewsService(articles: NewsArticle[]): { service: NewsService; getQueryStateCalls: () => number } {
|
|
49
|
+
const state: NewsQueryState = {
|
|
50
|
+
phase: "ready",
|
|
51
|
+
articles,
|
|
52
|
+
error: null,
|
|
53
|
+
updatedAt: Date.now(),
|
|
54
|
+
sourceIds: ["test"],
|
|
55
|
+
};
|
|
56
|
+
const listeners = new Set<() => void>();
|
|
57
|
+
let queryStateCalls = 0;
|
|
58
|
+
const service = {
|
|
59
|
+
subscribe(listener: () => void) {
|
|
60
|
+
listeners.add(listener);
|
|
61
|
+
return () => {
|
|
62
|
+
listeners.delete(listener);
|
|
63
|
+
};
|
|
64
|
+
},
|
|
65
|
+
getVersion() {
|
|
66
|
+
return 1;
|
|
67
|
+
},
|
|
68
|
+
getQueryState() {
|
|
69
|
+
queryStateCalls += 1;
|
|
70
|
+
return state;
|
|
71
|
+
},
|
|
72
|
+
async load() {
|
|
73
|
+
return state;
|
|
74
|
+
},
|
|
75
|
+
async loadStory() {
|
|
76
|
+
return null;
|
|
77
|
+
},
|
|
78
|
+
} as unknown as NewsService;
|
|
79
|
+
return { service, getQueryStateCalls: () => queryStateCalls };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function createHarness() {
|
|
83
|
+
const config = createDefaultConfig("/tmp/gloomberb-breaking-news");
|
|
84
|
+
config.layout.instances.push({
|
|
85
|
+
instanceId: PANE_ID,
|
|
86
|
+
paneId: "news-breaking",
|
|
87
|
+
title: "Breaking News",
|
|
88
|
+
});
|
|
89
|
+
const state = createInitialState(config);
|
|
90
|
+
state.focusedPaneId = PANE_ID;
|
|
91
|
+
|
|
92
|
+
return (
|
|
93
|
+
<AppContext value={{ state, dispatch: () => {} }}>
|
|
94
|
+
<PaneInstanceProvider paneId={PANE_ID}>
|
|
95
|
+
<PluginRenderProvider pluginId="news" runtime={createStatefulTestPluginRuntime()}>
|
|
96
|
+
<PaneFooterProvider>
|
|
97
|
+
{(footer) => (
|
|
98
|
+
<Box flexDirection="column" width={90} height={18}>
|
|
99
|
+
<BreakingPane focused width={90} height={17} />
|
|
100
|
+
<PaneFooterBar footer={footer} focused width={90} />
|
|
101
|
+
</Box>
|
|
102
|
+
)}
|
|
103
|
+
</PaneFooterProvider>
|
|
104
|
+
</PluginRenderProvider>
|
|
105
|
+
</PaneInstanceProvider>
|
|
106
|
+
</AppContext>
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
afterEach(async () => {
|
|
111
|
+
setSharedNewsService(null);
|
|
112
|
+
setAiRunHost(null);
|
|
113
|
+
__setDetectedProvidersForTests(null);
|
|
114
|
+
if (testSetup) {
|
|
115
|
+
await act(async () => {
|
|
116
|
+
testSetup!.renderer.destroy();
|
|
117
|
+
});
|
|
118
|
+
testSetup = undefined;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
describe("BreakingPane", () => {
|
|
123
|
+
test("does not run local AI providers when breaking news is mounted", async () => {
|
|
124
|
+
let runCalls = 0;
|
|
125
|
+
const newsService = createReadyNewsService([makeArticle()]);
|
|
126
|
+
setSharedNewsService(newsService.service);
|
|
127
|
+
__setDetectedProvidersForTests([
|
|
128
|
+
{
|
|
129
|
+
id: "claude",
|
|
130
|
+
name: "Claude",
|
|
131
|
+
command: "claude",
|
|
132
|
+
available: true,
|
|
133
|
+
buildArgs: () => [],
|
|
134
|
+
},
|
|
135
|
+
]);
|
|
136
|
+
setAiRunHost({
|
|
137
|
+
run() {
|
|
138
|
+
runCalls += 1;
|
|
139
|
+
return {
|
|
140
|
+
done: Promise.resolve("digest"),
|
|
141
|
+
cancel: () => {},
|
|
142
|
+
};
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
await act(async () => {
|
|
147
|
+
testSetup = await testRender(createHarness(), { width: 90, height: 18 });
|
|
148
|
+
await Bun.sleep(20);
|
|
149
|
+
await testSetup.renderOnce();
|
|
150
|
+
await testSetup.renderOnce();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(runCalls).toBe(0);
|
|
154
|
+
expect(newsService.getQueryStateCalls()).toBeGreaterThan(0);
|
|
155
|
+
});
|
|
156
|
+
});
|
|
@@ -1,13 +1,8 @@
|
|
|
1
1
|
import { Box } from "../../../ui";
|
|
2
|
-
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
3
2
|
import type { PaneProps } from "../../../types/plugin";
|
|
4
3
|
import { useLoadNewsStory, useNewsArticles } from "../../../news/hooks";
|
|
5
4
|
import { useDebouncedPluginPaneState, usePluginPaneState } from "../../plugin-runtime";
|
|
6
5
|
import { Spinner } from "../../../components";
|
|
7
|
-
import type { MarketNewsItem } from "../../../types/news-source";
|
|
8
|
-
import { detectProviders, getAiProvider, resolveDefaultAiProviderId } from "../ai/providers";
|
|
9
|
-
import { runAiPrompt } from "../ai/runner";
|
|
10
|
-
import { getDigest, setDigest, isDigestInFlight, markDigestInFlight, clearDigestInFlight } from "./digest-store";
|
|
11
6
|
import { NewsDetailView, useNewsArticleDetail } from "./news-detail-view";
|
|
12
7
|
import { NewsArticleStackView, type NewsSortPreference } from "./news-table";
|
|
13
8
|
import { useNewsArticleFooter } from "./news-footer";
|
|
@@ -15,19 +10,7 @@ import { NEWS_QUERY_PRESETS } from "./news-query-presets";
|
|
|
15
10
|
import { usePersistedNewsArticles } from "./persisted-articles";
|
|
16
11
|
import { useNewsReadState } from "./read-state";
|
|
17
12
|
|
|
18
|
-
const DIGEST_PROMPT = `You are a financial news wire editor. Condense this headline and summary into a single concise actionable bullet point for a professional trader. Include why it matters and potential market impact. Keep it under 120 characters. Respond with ONLY the bullet text, nothing else.
|
|
19
|
-
|
|
20
|
-
Headline: {title}
|
|
21
|
-
Summary: {summary}`;
|
|
22
|
-
|
|
23
13
|
const DEFAULT_SORT: NewsSortPreference = { columnId: "importance", direction: "desc" };
|
|
24
|
-
const BRAILLE_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
25
|
-
|
|
26
|
-
function buildDigestPrompt(item: MarketNewsItem): string {
|
|
27
|
-
return DIGEST_PROMPT
|
|
28
|
-
.replace("{title}", item.title)
|
|
29
|
-
.replace("{summary}", item.summary ?? item.title);
|
|
30
|
-
}
|
|
31
14
|
|
|
32
15
|
export function BreakingPane({ focused, width, height }: PaneProps) {
|
|
33
16
|
const breakingState = useNewsArticles(NEWS_QUERY_PRESETS.breaking);
|
|
@@ -35,86 +18,14 @@ export function BreakingPane({ focused, width, height }: PaneProps) {
|
|
|
35
18
|
const loading = breakingState.phase === "loading" || (breakingState.phase === "refreshing" && articles.length === 0);
|
|
36
19
|
const [selectedArticleId, setSelectedArticleId] = useDebouncedPluginPaneState<string | null>("breaking:selectedArticleId", null);
|
|
37
20
|
const [sortPreference, setSortPreference] = usePluginPaneState<NewsSortPreference>("breaking:sort", DEFAULT_SORT);
|
|
38
|
-
const [digestVersion, setDigestVersion] = useState(0);
|
|
39
|
-
const [aiAvailable, setAiAvailable] = useState(false);
|
|
40
|
-
const [aiError, setAiError] = useState<string | null>(null);
|
|
41
|
-
const [aiRunning, setAiRunning] = useState(false);
|
|
42
|
-
const [spinFrame, setSpinFrame] = useState(0);
|
|
43
|
-
const processingRef = useRef(false);
|
|
44
21
|
const loadNewsStory = useLoadNewsStory();
|
|
45
22
|
const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
|
|
46
23
|
const { readArticleIds, markArticleRead } = useNewsReadState();
|
|
47
24
|
|
|
48
|
-
useEffect(() => {
|
|
49
|
-
const providers = detectProviders();
|
|
50
|
-
setAiAvailable(providers.some((provider) => provider.available));
|
|
51
|
-
}, []);
|
|
52
|
-
|
|
53
|
-
useEffect(() => {
|
|
54
|
-
if (!aiRunning) return;
|
|
55
|
-
const id = setInterval(() => setSpinFrame((frame) => (frame + 1) % BRAILLE_FRAMES.length), 80);
|
|
56
|
-
return () => clearInterval(id);
|
|
57
|
-
}, [aiRunning]);
|
|
58
|
-
|
|
59
|
-
useEffect(() => {
|
|
60
|
-
if (!aiAvailable || articles.length === 0) return;
|
|
61
|
-
if (processingRef.current) return;
|
|
62
|
-
|
|
63
|
-
const providerId = resolveDefaultAiProviderId();
|
|
64
|
-
const provider = getAiProvider(providerId);
|
|
65
|
-
if (!provider?.available) return;
|
|
66
|
-
|
|
67
|
-
const toDigest = articles.filter((article) => !getDigest(article.id) && !isDigestInFlight(article.id));
|
|
68
|
-
if (toDigest.length === 0) return;
|
|
69
|
-
|
|
70
|
-
processingRef.current = true;
|
|
71
|
-
setAiRunning(true);
|
|
72
|
-
|
|
73
|
-
(async () => {
|
|
74
|
-
for (const article of toDigest) {
|
|
75
|
-
if (getDigest(article.id) || isDigestInFlight(article.id)) continue;
|
|
76
|
-
markDigestInFlight(article.id);
|
|
77
|
-
|
|
78
|
-
try {
|
|
79
|
-
const result = await runAiPrompt({
|
|
80
|
-
provider,
|
|
81
|
-
prompt: buildDigestPrompt(article),
|
|
82
|
-
}).done;
|
|
83
|
-
|
|
84
|
-
const digest = result.trim().slice(0, 150);
|
|
85
|
-
if (digest) {
|
|
86
|
-
setDigest(article.id, digest);
|
|
87
|
-
setDigestVersion((version) => version + 1);
|
|
88
|
-
}
|
|
89
|
-
} catch (error) {
|
|
90
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
91
|
-
if (message.includes("Credit") || message.includes("quota") || message.includes("rate limit")) {
|
|
92
|
-
setAiError(message);
|
|
93
|
-
break;
|
|
94
|
-
}
|
|
95
|
-
} finally {
|
|
96
|
-
clearDigestInFlight(article.id);
|
|
97
|
-
}
|
|
98
|
-
}
|
|
99
|
-
setAiRunning(false);
|
|
100
|
-
processingRef.current = false;
|
|
101
|
-
})();
|
|
102
|
-
}, [aiAvailable, articles]);
|
|
103
|
-
|
|
104
|
-
const titleForArticle = useCallback((article: MarketNewsItem) => (
|
|
105
|
-
getDigest(article.id) ?? article.title
|
|
106
|
-
), [digestVersion]);
|
|
107
|
-
|
|
108
|
-
const footerInfo = useMemo(() => [
|
|
109
|
-
...(aiRunning ? [{ id: "running", parts: [{ text: BRAILLE_FRAMES[spinFrame] ?? "running", tone: "positive" as const }] }] : []),
|
|
110
|
-
...(aiError ? [{ id: "paused", parts: [{ text: "AI paused", tone: "warning" as const }] }] : []),
|
|
111
|
-
], [aiError, aiRunning, spinFrame]);
|
|
112
|
-
|
|
113
25
|
useNewsArticleFooter({
|
|
114
26
|
registrationId: "news-wire:breaking",
|
|
115
27
|
focused,
|
|
116
28
|
article: detailArticle,
|
|
117
|
-
info: footerInfo,
|
|
118
29
|
});
|
|
119
30
|
|
|
120
31
|
const detailContent = detailArticle ? (
|
|
@@ -152,7 +63,6 @@ export function BreakingPane({ focused, width, height }: PaneProps) {
|
|
|
152
63
|
columns={["time", "title", "tickers", "importance"]}
|
|
153
64
|
emptyStateTitle="No breaking news"
|
|
154
65
|
emptyStateHint="Breaking stories appear when high-priority headlines arrive."
|
|
155
|
-
titleForArticle={titleForArticle}
|
|
156
66
|
/>
|
|
157
67
|
);
|
|
158
68
|
}
|
|
@@ -8,7 +8,6 @@ import {
|
|
|
8
8
|
BREAKING_NEWS_NOTIFICATIONS_ENABLED_KEY,
|
|
9
9
|
setupBreakingNewsNotifications,
|
|
10
10
|
} from "./breaking-notifications";
|
|
11
|
-
import { setDigestPersistence } from "./digest-store";
|
|
12
11
|
import {
|
|
13
12
|
addUserNewsFeed,
|
|
14
13
|
getEnabledNewsFeeds,
|
|
@@ -39,7 +38,7 @@ const TopPane = createNewsPresetPane({
|
|
|
39
38
|
paneKey: "top",
|
|
40
39
|
title: "Top News",
|
|
41
40
|
query: NEWS_QUERY_PRESETS.top,
|
|
42
|
-
columns: ["
|
|
41
|
+
columns: ["time", "title", "tickers", "importance"],
|
|
43
42
|
defaultSort: { columnId: "importance", direction: "desc" },
|
|
44
43
|
emptyStateTitle: "No top stories yet",
|
|
45
44
|
emptyStateHint: "Try refreshing later as new headlines are ranked.",
|
|
@@ -56,7 +55,6 @@ const FeedPane = createNewsPresetPane({
|
|
|
56
55
|
});
|
|
57
56
|
|
|
58
57
|
export function registerNewsWireFeatures(ctx: GloomPluginContext): () => void {
|
|
59
|
-
setDigestPersistence(ctx.persistence);
|
|
60
58
|
ctx.registerPane({ id: "news-top", name: "Top News", icon: "T", component: TopPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 90, height: 30 } });
|
|
61
59
|
ctx.registerPane({ id: "news-feed", name: "News Feed", icon: "N", component: FeedPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 100, height: 35 } });
|
|
62
60
|
ctx.registerPane({ id: "news-industry", name: "Sector News", icon: "S", component: IndustryPane, defaultPosition: "right", defaultMode: "floating", defaultFloatingSize: { width: 100, height: 35 } });
|
|
@@ -8,6 +8,7 @@ import { TickerBadge } from "../../../components/ticker-badge";
|
|
|
8
8
|
import { ExternalLink, ExternalLinkText } from "../../../components/ui";
|
|
9
9
|
import { collectNewsDisplayTickers } from "../../../news/ticker-symbols";
|
|
10
10
|
import { useInlineTickers } from "../../../state/use-inline-tickers";
|
|
11
|
+
import { isPlainKey } from "../../../utils/keyboard";
|
|
11
12
|
import { wrapTextLines } from "../../../utils/text-wrap";
|
|
12
13
|
|
|
13
14
|
function hasStoryItems(article: MarketNewsItem | null): boolean {
|
|
@@ -196,13 +197,13 @@ export function NewsDetailView({ item, focused, width, showTitle = true }: {
|
|
|
196
197
|
|
|
197
198
|
useShortcut((event) => {
|
|
198
199
|
if (!focused) return;
|
|
199
|
-
if (event
|
|
200
|
+
if (isPlainKey(event, "j", "down")) {
|
|
200
201
|
event.stopPropagation?.();
|
|
201
202
|
event.preventDefault?.();
|
|
202
203
|
scrollBy(1);
|
|
203
204
|
return;
|
|
204
205
|
}
|
|
205
|
-
if (event
|
|
206
|
+
if (isPlainKey(event, "k", "up")) {
|
|
206
207
|
event.stopPropagation?.();
|
|
207
208
|
event.preventDefault?.();
|
|
208
209
|
scrollBy(-1);
|
|
@@ -45,6 +45,9 @@ export function NewsPresetPane({
|
|
|
45
45
|
`${paneKey}:sort`,
|
|
46
46
|
defaultSort,
|
|
47
47
|
);
|
|
48
|
+
const effectiveSortPreference = columns.includes(sortPreference.columnId)
|
|
49
|
+
? sortPreference
|
|
50
|
+
: defaultSort;
|
|
48
51
|
const loadNewsStory = useLoadNewsStory();
|
|
49
52
|
const { detailArticle, openArticle, closeDetail } = useNewsArticleDetail(articles, loadNewsStory);
|
|
50
53
|
const { readArticleIds, markArticleRead } = useNewsReadState();
|
|
@@ -79,7 +82,7 @@ export function NewsPresetPane({
|
|
|
79
82
|
readArticleIds={readArticleIds}
|
|
80
83
|
selectedArticleId={selectedArticleId}
|
|
81
84
|
setSelectedArticleId={setSelectedArticleId}
|
|
82
|
-
sortPreference={
|
|
85
|
+
sortPreference={effectiveSortPreference}
|
|
83
86
|
setSortPreference={setSortPreference}
|
|
84
87
|
onOpenArticle={openArticle}
|
|
85
88
|
onArticleRead={markArticleRead}
|
|
@@ -65,10 +65,16 @@ function samePersistedArticles(left: PersistedNewsArticle[], right: PersistedNew
|
|
|
65
65
|
if (left.length !== right.length) return false;
|
|
66
66
|
return left.every((article, index) => {
|
|
67
67
|
const other = right[index];
|
|
68
|
-
return other?.id === article.id &&
|
|
68
|
+
return other?.id === article.id &&
|
|
69
|
+
other.publishedAt === article.publishedAt &&
|
|
70
|
+
storyItemsSignature(other.items) === storyItemsSignature(article.items);
|
|
69
71
|
});
|
|
70
72
|
}
|
|
71
73
|
|
|
74
|
+
function storyItemsSignature(items: PersistedNewsStoryItem[] | undefined): string {
|
|
75
|
+
return items?.map((item) => `${item.id}:${item.publishedAt}`).join("|") ?? "";
|
|
76
|
+
}
|
|
77
|
+
|
|
72
78
|
export function usePersistedNewsArticles(key: string, articles: MarketNewsItem[]): MarketNewsItem[] {
|
|
73
79
|
const [persistedArticles, setPersistedArticles] = usePluginPaneState<PersistedNewsArticle[]>(
|
|
74
80
|
key,
|