gloomberb 0.8.1 → 0.9.0
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 +106 -54
- package/package.json +14 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- package/src/api-client/chat.ts +8 -0
- package/src/api-client/index.test.ts +30 -0
- package/src/api-client/index.ts +4 -0
- package/src/api-client/normalizers.ts +1 -0
- package/src/api-client/types.ts +1 -0
- package/src/app/global-shortcuts.test.tsx +54 -4
- package/src/app/global-shortcuts.ts +11 -5
- package/src/capabilities/factories.ts +4 -0
- package/src/components/chart/comparison/data.test.ts +54 -0
- package/src/components/chart/comparison/data.ts +47 -24
- package/src/components/chart/core/projection.ts +51 -27
- package/src/components/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- package/src/components/data-table/stack-view.test.tsx +14 -11
- package/src/components/data-table/stack-view.tsx +1 -2
- package/src/components/data-table/view.test.tsx +24 -18
- package/src/components/data-table/view.tsx +313 -83
- package/src/components/feed-data-table/stack-view.tsx +6 -8
- package/src/components/layout/pane/footer/index.test.tsx +6 -0
- package/src/components/layout/pane/footer/index.tsx +14 -2
- package/src/components/layout/pane/title.ts +5 -1
- package/src/components/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
- package/src/components/pane-settings-dialog/tui.tsx +1 -1
- package/src/components/ticker/list-table-view.tsx +17 -25
- package/src/components/ui/choice-dialog.tsx +12 -2
- package/src/components/ui/multi-select/dialog.tsx +17 -5
- package/src/components/ui/remote-image.tsx +1 -14
- package/src/components/ui/shortcut-hint.tsx +14 -2
- package/src/components/ui/table-layout.test.ts +2 -2
- package/src/components/ui/table-layout.ts +8 -2
- package/src/components/ui/tabs.tsx +115 -86
- package/src/components/ui/ui.test.tsx +83 -4
- package/src/core/state/app/types.ts +12 -4
- package/src/market-data/coordinator/auxiliary.ts +29 -1
- package/src/market-data/coordinator/index.ts +17 -1
- package/src/market-data/hooks.tsx +17 -1
- package/src/market-data/selectors.ts +4 -0
- package/src/plugins/builtin/account-management/form-components.tsx +0 -4
- package/src/plugins/builtin/account-management/model.test.ts +54 -0
- package/src/plugins/builtin/account-management/model.ts +13 -3
- package/src/plugins/builtin/account-management/pane.tsx +5 -11
- package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
- package/src/plugins/builtin/alerts/pane.tsx +5 -3
- package/src/plugins/builtin/analytics/index.tsx +0 -3
- package/src/plugins/builtin/analytics/view.tsx +6 -5
- package/src/plugins/builtin/broker-manager/index.tsx +6 -6
- package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
- package/src/plugins/builtin/changelog/index.tsx +40 -27
- package/src/plugins/builtin/changelog/model.test.ts +77 -0
- package/src/plugins/builtin/changelog/model.ts +98 -0
- package/src/plugins/builtin/chat/channel-labels.ts +53 -0
- package/src/plugins/builtin/chat/channels.test.ts +28 -0
- package/src/plugins/builtin/chat/channels.ts +36 -19
- package/src/plugins/builtin/chat/chat.test.tsx +264 -5
- package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
- package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
- package/src/plugins/builtin/chat/content/composer.tsx +23 -2
- package/src/plugins/builtin/chat/content/index.tsx +66 -4
- package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
- package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
- package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
- package/src/plugins/builtin/chat/content/types.ts +1 -0
- package/src/plugins/builtin/chat/controller/index.ts +41 -0
- package/src/plugins/builtin/chat/controller/messages.ts +18 -0
- package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
- package/src/plugins/builtin/chat/controller/utils.ts +6 -3
- package/src/plugins/builtin/chat/controller/view.ts +1 -1
- package/src/plugins/builtin/chat/controller.test.ts +189 -0
- package/src/plugins/builtin/chat/edit-window.ts +24 -0
- package/src/plugins/builtin/chat/layout.ts +94 -31
- package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
- package/src/plugins/builtin/chat/message/render-state.ts +5 -1
- package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
- package/src/plugins/builtin/chat/message/types.ts +2 -0
- package/src/plugins/builtin/chat/pane.tsx +35 -7
- package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
- package/src/plugins/builtin/chat/status-widget.tsx +29 -11
- package/src/plugins/builtin/chat/test-harness.tsx +2 -0
- package/src/plugins/builtin/cloud/plugin.tsx +3 -0
- package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
- package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
- package/src/plugins/builtin/earnings/index.tsx +8 -7
- package/src/plugins/builtin/econ/index.tsx +6 -8
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +326 -164
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- package/src/plugins/builtin/holders/pane.tsx +7 -6
- package/src/plugins/builtin/market-movers/index.tsx +6 -3
- package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
- package/src/plugins/builtin/options/view.tsx +16 -8
- package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
- package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
- package/src/plugins/builtin/research/index.test.ts +30 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
- package/src/plugins/builtin/sec/filing-documents.ts +66 -0
- package/src/plugins/builtin/sec/index.tsx +132 -20
- package/src/plugins/builtin/sectors/index.tsx +13 -23
- package/src/plugins/builtin/shared/ticker-request.ts +1 -0
- package/src/plugins/builtin/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -0
- package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
- package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
- package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
- package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/builtin/world-indices/index.tsx +9 -6
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- package/src/plugins/prediction-markets/pane.tsx +10 -9
- package/src/plugins/registry/index.ts +3 -0
- package/src/plugins/runtime/context.tsx +1 -0
- package/src/plugins/runtime/index.tsx +4 -0
- package/src/react/use-throttled-commit-value.ts +116 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
- package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
- package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
- package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
- package/src/renderers/electrobun/view/host/input.tsx +23 -2
- package/src/renderers/electrobun/view/http-fetch.ts +10 -3
- package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
- package/src/sources/gloomberb-cloud/index.ts +4 -0
- package/src/sources/provider-router/cache.ts +1 -0
- package/src/sources/provider-router/document-routes.ts +45 -0
- package/src/sources/provider-router/index.ts +4 -0
- package/src/sources/sec-edgar/content.ts +9 -3
- package/src/sources/sec-edgar.test.ts +108 -1
- package/src/sources/sec-edgar.ts +105 -1
- package/src/sources/yahoo-finance.ts +4 -0
- package/src/state/app/context/index.test.tsx +16 -0
- package/src/test-support/plugin-runtime.ts +1 -0
- package/src/tickers/search/index.ts +1 -0
- package/src/types/data-provider.ts +11 -0
- package/src/ui/host.tsx +1 -0
- package/src/version.ts +1 -1
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
import { Text } from "../../../ui";
|
|
2
2
|
import { useEffect, useMemo, useRef, useState } from "react";
|
|
3
3
|
import type { GloomPlugin } from "../../../types/plugin";
|
|
4
|
-
import type { SecFilingItem } from "../../../types/data-provider";
|
|
5
|
-
import { useResolvedEntryValue, useSecFilingContent, useSecFilingsQuery } from "../../../market-data/hooks";
|
|
4
|
+
import type { SecFilingDocument, SecFilingItem } from "../../../types/data-provider";
|
|
5
|
+
import { useResolvedEntryValue, useSecFilingContent, useSecFilingDocuments, useSecFilingsQuery } from "../../../market-data/hooks";
|
|
6
6
|
import { instrumentFromTicker } from "../../../market-data/request-types";
|
|
7
|
-
import {
|
|
7
|
+
import { useDebouncedPluginPaneState } from "../../runtime";
|
|
8
8
|
import { usePaneTicker } from "../../../state/app/context";
|
|
9
9
|
import { colors } from "../../../theme/colors";
|
|
10
10
|
import { FeedDataTableStackView, Spinner, useExternalLinkFooter, type FeedDataTableItem } from "../../../components";
|
|
@@ -17,6 +17,14 @@ import {
|
|
|
17
17
|
formatFilingMetaDate,
|
|
18
18
|
renderFilingNotice,
|
|
19
19
|
} from "./filing-display";
|
|
20
|
+
import {
|
|
21
|
+
documentContentKey,
|
|
22
|
+
documentContentTarget,
|
|
23
|
+
documentHeading,
|
|
24
|
+
formatCompactDocumentLabel,
|
|
25
|
+
isDefaultVisibleFilingDocument,
|
|
26
|
+
isInlineExhibitDocument,
|
|
27
|
+
} from "./filing-documents";
|
|
20
28
|
|
|
21
29
|
const SEC_FILING_LIMIT = 50;
|
|
22
30
|
const OWNERSHIP_FORMS = new Set(["3", "4", "5"]);
|
|
@@ -80,6 +88,50 @@ function buildDetailBody(filing: SecFilingItem): string {
|
|
|
80
88
|
: "No additional SEC filing description is available for this entry.";
|
|
81
89
|
}
|
|
82
90
|
|
|
91
|
+
function buildDetailBodyWithDocuments({
|
|
92
|
+
filing,
|
|
93
|
+
documents,
|
|
94
|
+
documentsLoading,
|
|
95
|
+
contentCache,
|
|
96
|
+
primaryContent,
|
|
97
|
+
}: {
|
|
98
|
+
filing: SecFilingItem;
|
|
99
|
+
documents: SecFilingDocument[];
|
|
100
|
+
documentsLoading: boolean;
|
|
101
|
+
contentCache: Map<string, string | null>;
|
|
102
|
+
primaryContent: string;
|
|
103
|
+
}): string {
|
|
104
|
+
const lines: string[] = [];
|
|
105
|
+
lines.push("Documents");
|
|
106
|
+
if (documentsLoading && documents.length === 0) {
|
|
107
|
+
lines.push("Loading filing documents...");
|
|
108
|
+
} else if (documents.length === 0) {
|
|
109
|
+
lines.push("No filing documents were listed for this filing.");
|
|
110
|
+
} else {
|
|
111
|
+
const visibleDocuments = documents.filter(isDefaultVisibleFilingDocument);
|
|
112
|
+
lines.push(...visibleDocuments.map(formatCompactDocumentLabel));
|
|
113
|
+
const hiddenCount = documents.length - visibleDocuments.length;
|
|
114
|
+
if (hiddenCount > 0) lines.push(`+ ${hiddenCount} support documents hidden`);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const exhibits = documents.filter(isInlineExhibitDocument);
|
|
118
|
+
if (exhibits.length > 0) {
|
|
119
|
+
lines.push("", "Inline Exhibits");
|
|
120
|
+
for (const document of exhibits) {
|
|
121
|
+
const key = documentContentKey(filing, document);
|
|
122
|
+
const hasContent = contentCache.has(key);
|
|
123
|
+
const content = contentCache.get(key);
|
|
124
|
+
lines.push("", documentHeading(document));
|
|
125
|
+
lines.push(hasContent
|
|
126
|
+
? content || "Readable document content was not available for this exhibit."
|
|
127
|
+
: "Loading exhibit content...");
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
lines.push("", "Primary Filing Content", primaryContent);
|
|
132
|
+
return lines.join("\n");
|
|
133
|
+
}
|
|
134
|
+
|
|
83
135
|
function buildForm4Preview(content: string | null): string | null {
|
|
84
136
|
if (!content) return null;
|
|
85
137
|
const tx = parseForm4Xml(content);
|
|
@@ -134,6 +186,8 @@ function toFeedItems(
|
|
|
134
186
|
selectedAccessionNumber: string | undefined,
|
|
135
187
|
contentCache: Map<string, string | null>,
|
|
136
188
|
loadingContent: boolean,
|
|
189
|
+
selectedDocuments: SecFilingDocument[],
|
|
190
|
+
loadingDocuments: boolean,
|
|
137
191
|
): FeedDataTableItem[] {
|
|
138
192
|
return filings.map((filing) => {
|
|
139
193
|
const displayTitle = getFilingDisplayTitle(filing);
|
|
@@ -152,6 +206,19 @@ function toFeedItems(
|
|
|
152
206
|
const form4Detail = isOwnership && hasFetchedContent
|
|
153
207
|
? buildForm4Detail(fetchedContent ?? null, filing)
|
|
154
208
|
: null;
|
|
209
|
+
const selected = filing.accessionNumber === selectedAccessionNumber;
|
|
210
|
+
const primaryDetailBody = loadingContent && selected
|
|
211
|
+
? "Loading filing content..."
|
|
212
|
+
: form4Detail ?? fetchedContent ?? fallbackBody;
|
|
213
|
+
const detailBody = selected
|
|
214
|
+
? buildDetailBodyWithDocuments({
|
|
215
|
+
filing,
|
|
216
|
+
documents: selectedDocuments,
|
|
217
|
+
documentsLoading: loadingDocuments,
|
|
218
|
+
contentCache,
|
|
219
|
+
primaryContent: primaryDetailBody,
|
|
220
|
+
})
|
|
221
|
+
: form4Detail ?? fallbackBody;
|
|
155
222
|
|
|
156
223
|
const enrichedTitle = formDesc
|
|
157
224
|
? `${displayTitle} — ${formDesc}`
|
|
@@ -170,13 +237,7 @@ function toFeedItems(
|
|
|
170
237
|
`Accession ${filing.accessionNumber}`,
|
|
171
238
|
...(filing.items ? [`Items ${filing.items}`] : []),
|
|
172
239
|
],
|
|
173
|
-
detailBody
|
|
174
|
-
? (
|
|
175
|
-
loadingContent
|
|
176
|
-
? "Loading filing content..."
|
|
177
|
-
: form4Detail ?? fetchedContent ?? fallbackBody
|
|
178
|
-
)
|
|
179
|
-
: form4Detail ?? fallbackBody,
|
|
240
|
+
detailBody,
|
|
180
241
|
};
|
|
181
242
|
});
|
|
182
243
|
}
|
|
@@ -184,7 +245,7 @@ function toFeedItems(
|
|
|
184
245
|
function SecView({ width, height, focused }: { width: number; height: number; focused: boolean }) {
|
|
185
246
|
const { ticker } = usePaneTicker();
|
|
186
247
|
const selectionKey = `selectedIdx:${ticker?.metadata.ticker ?? "none"}`;
|
|
187
|
-
const [selectedIdx, setSelectedIdx] =
|
|
248
|
+
const [selectedIdx, setSelectedIdx] = useDebouncedPluginPaneState<number>(selectionKey, 0);
|
|
188
249
|
const [contentCache, setContentCache] = useState<Map<string, string | null>>(new Map());
|
|
189
250
|
const [openItemId, setOpenItemId] = useState<string | null>(null);
|
|
190
251
|
const contentFetchRef = useRef(0);
|
|
@@ -204,28 +265,67 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
|
|
|
204
265
|
contentFetchRef.current += 1;
|
|
205
266
|
}, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
|
|
206
267
|
|
|
207
|
-
const selected = filings[selectedIdx];
|
|
208
268
|
const openFiling = openItemId
|
|
209
269
|
? filings.find((filing) => filing.accessionNumber === openItemId) ?? null
|
|
210
270
|
: null;
|
|
211
|
-
const
|
|
271
|
+
const cachedOpenContent = openFiling ? contentCache.get(openFiling.accessionNumber) : undefined;
|
|
212
272
|
const filingContentEntry = useSecFilingContent(
|
|
213
|
-
|
|
273
|
+
openFiling && cachedOpenContent === undefined ? openFiling : null,
|
|
214
274
|
);
|
|
215
275
|
const filingContent = useResolvedEntryValue(filingContentEntry);
|
|
216
276
|
const loadingContent = filingContentEntry?.phase === "loading" || filingContentEntry?.phase === "refreshing";
|
|
277
|
+
const documentsEntry = useSecFilingDocuments(openFiling ?? null);
|
|
278
|
+
const openDocuments = useResolvedEntryValue(documentsEntry) ?? [];
|
|
279
|
+
const loadingDocuments = !!openFiling && (
|
|
280
|
+
documentsEntry?.phase === "idle"
|
|
281
|
+
|| documentsEntry?.phase === "loading"
|
|
282
|
+
|| documentsEntry?.phase === "refreshing"
|
|
283
|
+
);
|
|
217
284
|
|
|
218
285
|
useEffect(() => {
|
|
219
|
-
if (!
|
|
220
|
-
if (
|
|
286
|
+
if (!openFiling) return;
|
|
287
|
+
if (cachedOpenContent !== undefined) return;
|
|
221
288
|
if (filingContentEntry?.phase === "error") {
|
|
222
|
-
setContentCache((prev) => new Map(prev).set(
|
|
289
|
+
setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, null));
|
|
223
290
|
return;
|
|
224
291
|
}
|
|
225
292
|
if (filingContent !== null) {
|
|
226
|
-
setContentCache((prev) => new Map(prev).set(
|
|
293
|
+
setContentCache((prev) => new Map(prev).set(openFiling.accessionNumber, filingContent));
|
|
294
|
+
}
|
|
295
|
+
}, [cachedOpenContent, filingContent, filingContentEntry?.phase, openFiling]);
|
|
296
|
+
|
|
297
|
+
const documentFetchStartedRef = useRef(new Set<string>());
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (!openFiling || openDocuments.length === 0) return;
|
|
300
|
+
const coordinator = getSharedMarketDataCoordinator();
|
|
301
|
+
if (!coordinator) return;
|
|
302
|
+
const gen = contentFetchRef.current;
|
|
303
|
+
const documentsToFetch = openDocuments.filter((document) => {
|
|
304
|
+
if (!isInlineExhibitDocument(document)) return false;
|
|
305
|
+
const key = documentContentKey(openFiling, document);
|
|
306
|
+
return !contentCache.has(key) && !documentFetchStartedRef.current.has(key);
|
|
307
|
+
});
|
|
308
|
+
if (documentsToFetch.length === 0) return;
|
|
309
|
+
|
|
310
|
+
for (const document of documentsToFetch) {
|
|
311
|
+
documentFetchStartedRef.current.add(documentContentKey(openFiling, document));
|
|
227
312
|
}
|
|
228
|
-
|
|
313
|
+
|
|
314
|
+
(async () => {
|
|
315
|
+
for (const document of documentsToFetch) {
|
|
316
|
+
if (contentFetchRef.current !== gen) return;
|
|
317
|
+
const key = documentContentKey(openFiling, document);
|
|
318
|
+
try {
|
|
319
|
+
const entry = await coordinator.loadSecFilingContent(documentContentTarget(openFiling, document));
|
|
320
|
+
if (contentFetchRef.current !== gen) return;
|
|
321
|
+
setContentCache((prev) => new Map(prev).set(key, entry.data ?? entry.lastGoodData ?? null));
|
|
322
|
+
} catch {
|
|
323
|
+
if (contentFetchRef.current !== gen) return;
|
|
324
|
+
setContentCache((prev) => new Map(prev).set(key, null));
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
})();
|
|
328
|
+
}, [contentCache, openFiling, openDocuments]);
|
|
229
329
|
|
|
230
330
|
useEffect(() => {
|
|
231
331
|
if (filings.length > 0 && selectedIdx >= filings.length) {
|
|
@@ -235,6 +335,11 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
|
|
|
235
335
|
|
|
236
336
|
// Background prefetch Form 4 content for enriched previews
|
|
237
337
|
const prefetchStartedRef = useRef(new Set<string>());
|
|
338
|
+
useEffect(() => {
|
|
339
|
+
prefetchStartedRef.current.clear();
|
|
340
|
+
documentFetchStartedRef.current.clear();
|
|
341
|
+
}, [eligibleTicker, ticker?.metadata.exchange, ticker?.metadata.ticker]);
|
|
342
|
+
|
|
238
343
|
useEffect(() => {
|
|
239
344
|
if (filings.length === 0) return;
|
|
240
345
|
const coordinator = getSharedMarketDataCoordinator();
|
|
@@ -288,7 +393,14 @@ function SecView({ width, height, focused }: { width: number; height: number; fo
|
|
|
288
393
|
width={width}
|
|
289
394
|
height={height}
|
|
290
395
|
focused={focused}
|
|
291
|
-
items={toFeedItems(
|
|
396
|
+
items={toFeedItems(
|
|
397
|
+
filings,
|
|
398
|
+
openFiling?.accessionNumber,
|
|
399
|
+
contentCache,
|
|
400
|
+
loadingContent,
|
|
401
|
+
openDocuments,
|
|
402
|
+
loadingDocuments,
|
|
403
|
+
)}
|
|
292
404
|
selectedIdx={selectedIdx}
|
|
293
405
|
onSelect={setSelectedIdx}
|
|
294
406
|
onOpenItemIdChange={setOpenItemId}
|
|
@@ -71,11 +71,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
|
|
|
71
71
|
label: collection.label,
|
|
72
72
|
value: collection.id,
|
|
73
73
|
})), []);
|
|
74
|
-
const selectedIdx = selectedEtf
|
|
75
|
-
? sortedRows.findIndex((row) => row.etf === selectedEtf)
|
|
76
|
-
: -1;
|
|
77
|
-
const activeIdx = selectedIdx >= 0 ? selectedIdx : (sortedRows.length > 0 ? 0 : -1);
|
|
78
|
-
|
|
79
74
|
const fetchAll = useCallback(() => {
|
|
80
75
|
fetchGenRef.current += 1;
|
|
81
76
|
const gen = fetchGenRef.current;
|
|
@@ -189,19 +184,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
|
|
|
189
184
|
setSortPreference((current) => nextSortPreference(current, columnId));
|
|
190
185
|
}, [setSortPreference]);
|
|
191
186
|
|
|
192
|
-
const selectIndex = useCallback((index: number) => {
|
|
193
|
-
const row = sortedRows[index];
|
|
194
|
-
if (row) setSelectedEtf(row.etf);
|
|
195
|
-
}, [setSelectedEtf, sortedRows]);
|
|
196
|
-
|
|
197
|
-
const selectRow = useCallback((row: SectorRow) => {
|
|
198
|
-
if (row.etf === selectedEtf) {
|
|
199
|
-
openRow(row);
|
|
200
|
-
return;
|
|
201
|
-
}
|
|
202
|
-
setSelectedEtf(row.etf);
|
|
203
|
-
}, [openRow, selectedEtf, setSelectedEtf]);
|
|
204
|
-
|
|
205
187
|
const handleTableKeyDown = useCallback((event: DataTableKeyEvent) => {
|
|
206
188
|
if (event.name === "r") {
|
|
207
189
|
event.preventDefault?.();
|
|
@@ -291,9 +273,19 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
|
|
|
291
273
|
return (
|
|
292
274
|
<DataTableView<SectorRow, SectorColumn>
|
|
293
275
|
focused={focused}
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
276
|
+
selection={{
|
|
277
|
+
kind: "id",
|
|
278
|
+
selectedId: selectedEtf,
|
|
279
|
+
getId: (row) => row.etf,
|
|
280
|
+
onChange: (id, row, _index, reason) => {
|
|
281
|
+
if (reason === "pointer" && id === selectedEtf) {
|
|
282
|
+
openRow(row);
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
setSelectedEtf(id);
|
|
286
|
+
},
|
|
287
|
+
}}
|
|
288
|
+
onActivate={openRow}
|
|
297
289
|
onRootKeyDown={handleTableKeyDown}
|
|
298
290
|
rootBefore={rootBefore}
|
|
299
291
|
rootWidth={width}
|
|
@@ -305,8 +297,6 @@ function SectorPerformancePane({ focused, width, height }: PaneProps) {
|
|
|
305
297
|
sortDirection={sortPreference.direction}
|
|
306
298
|
onHeaderClick={handleHeaderClick}
|
|
307
299
|
getItemKey={(row) => row.etf}
|
|
308
|
-
isSelected={(row) => row.etf === selectedEtf}
|
|
309
|
-
onSelect={selectRow}
|
|
310
300
|
renderCell={renderCell}
|
|
311
301
|
emptyStateTitle="No sector data available"
|
|
312
302
|
showHorizontalScrollbar={false}
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { SubstackAuthState } from "./types";
|
|
2
|
+
import {
|
|
3
|
+
parseErrorText,
|
|
4
|
+
resolveSubstackUrl,
|
|
5
|
+
storeSubstackAuth,
|
|
6
|
+
substackFetch,
|
|
7
|
+
SUBSTACK_ORIGIN,
|
|
8
|
+
} from "./store";
|
|
9
|
+
|
|
10
|
+
export async function requestSubstackMagicLink(email: string): Promise<void> {
|
|
11
|
+
const normalizedEmail = email.trim();
|
|
12
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizedEmail)) {
|
|
13
|
+
throw new Error("Enter a valid email address");
|
|
14
|
+
}
|
|
15
|
+
const response = await substackFetch(`${SUBSTACK_ORIGIN}/api/v1/email-login`, {
|
|
16
|
+
method: "POST",
|
|
17
|
+
headers: {
|
|
18
|
+
"content-type": "application/json",
|
|
19
|
+
},
|
|
20
|
+
body: JSON.stringify({ email: normalizedEmail }),
|
|
21
|
+
});
|
|
22
|
+
if (!response.ok) {
|
|
23
|
+
const detail = await parseErrorText(response);
|
|
24
|
+
throw new Error(`Substack login link failed (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function splitSetCookieHeader(header: string): string[] {
|
|
29
|
+
const parts: string[] = [];
|
|
30
|
+
let start = 0;
|
|
31
|
+
let inExpires = false;
|
|
32
|
+
for (let i = 0; i < header.length; i += 1) {
|
|
33
|
+
const char = header[i];
|
|
34
|
+
if (char === ",") {
|
|
35
|
+
const fragment = header.slice(start, i);
|
|
36
|
+
inExpires = /expires=/i.test(fragment) && !/;\s*(?:max-age|domain|path|secure|httponly|samesite)\b/i.test(fragment);
|
|
37
|
+
if (!inExpires) {
|
|
38
|
+
parts.push(fragment.trim());
|
|
39
|
+
start = i + 1;
|
|
40
|
+
}
|
|
41
|
+
} else if (char === ";") {
|
|
42
|
+
inExpires = false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
const tail = header.slice(start).trim();
|
|
46
|
+
if (tail) parts.push(tail);
|
|
47
|
+
return parts;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function getSetCookieHeaders(headers: Headers): string[] {
|
|
51
|
+
const withGetSetCookie = headers as Headers & { getSetCookie?: () => string[] };
|
|
52
|
+
const direct = withGetSetCookie.getSetCookie?.();
|
|
53
|
+
if (direct && direct.length > 0) return direct;
|
|
54
|
+
const header = headers.get("set-cookie");
|
|
55
|
+
return header ? splitSetCookieHeader(header) : [];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function parseCookiesFromSetCookie(headers: string[]): Map<string, string> {
|
|
59
|
+
const cookies = new Map<string, string>();
|
|
60
|
+
for (const header of headers) {
|
|
61
|
+
const first = header.split(";")[0] ?? "";
|
|
62
|
+
const eq = first.indexOf("=");
|
|
63
|
+
if (eq <= 0) continue;
|
|
64
|
+
const name = first.slice(0, eq).trim();
|
|
65
|
+
const value = first.slice(eq + 1).trim();
|
|
66
|
+
if (!name) continue;
|
|
67
|
+
cookies.set(name, value);
|
|
68
|
+
}
|
|
69
|
+
return cookies;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function cookieHeaderFromJar(jar: Map<string, string>): string {
|
|
73
|
+
return [...jar.entries()].map(([name, value]) => `${name}=${value}`).join("; ");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function mergeSetCookies(cookieJar: Map<string, string>, response: Response): void {
|
|
77
|
+
for (const [name, value] of parseCookiesFromSetCookie(getSetCookieHeaders(response.headers))) {
|
|
78
|
+
cookieJar.set(name, value);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async function followRedirectsForCookies(startUrl: string, cookieJar: Map<string, string>): Promise<void> {
|
|
83
|
+
let currentUrl = startUrl;
|
|
84
|
+
for (let redirectCount = 0; redirectCount < 8; redirectCount += 1) {
|
|
85
|
+
const response = await substackFetch(currentUrl, {
|
|
86
|
+
method: "GET",
|
|
87
|
+
redirect: "manual",
|
|
88
|
+
headers: cookieJar.size > 0 ? { cookie: cookieHeaderFromJar(cookieJar) } : undefined,
|
|
89
|
+
});
|
|
90
|
+
mergeSetCookies(cookieJar, response);
|
|
91
|
+
|
|
92
|
+
if (response.status < 300 || response.status >= 400) break;
|
|
93
|
+
const location = response.headers.get("location");
|
|
94
|
+
if (!location) break;
|
|
95
|
+
const nextUrl = resolveSubstackUrl(location, currentUrl);
|
|
96
|
+
if (!nextUrl) break;
|
|
97
|
+
currentUrl = nextUrl;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function completeSubstackMagicLink(link: string, email: string): Promise<SubstackAuthState> {
|
|
102
|
+
const currentUrl = resolveSubstackUrl(link);
|
|
103
|
+
if (!currentUrl) throw new Error("Paste the full Substack magic-link URL");
|
|
104
|
+
|
|
105
|
+
const cookieJar = new Map<string, string>();
|
|
106
|
+
await followRedirectsForCookies(currentUrl, cookieJar);
|
|
107
|
+
|
|
108
|
+
const sid = cookieJar.get("substack.sid");
|
|
109
|
+
if (!sid) {
|
|
110
|
+
throw new Error("Substack did not return a session cookie. Request a new login link and paste the full URL.");
|
|
111
|
+
}
|
|
112
|
+
const auth: SubstackAuthState = {
|
|
113
|
+
email: email.trim(),
|
|
114
|
+
sid,
|
|
115
|
+
lli: cookieJar.get("substack.lli") ?? "1",
|
|
116
|
+
loggedInAt: Date.now(),
|
|
117
|
+
};
|
|
118
|
+
storeSubstackAuth(auth);
|
|
119
|
+
return auth;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export async function completeSubstackOtpLogin(code: string, email: string): Promise<SubstackAuthState> {
|
|
123
|
+
const normalizedCode = code.trim();
|
|
124
|
+
const normalizedEmail = email.trim();
|
|
125
|
+
if (!/^\d{6}$/.test(normalizedCode)) {
|
|
126
|
+
throw new Error("Enter the 6-digit Substack email code");
|
|
127
|
+
}
|
|
128
|
+
if (!/^[^@\s]+@[^@\s]+\.[^@\s]+$/.test(normalizedEmail)) {
|
|
129
|
+
throw new Error("Enter a valid email address");
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
const cookieJar = new Map<string, string>();
|
|
133
|
+
const response = await substackFetch(`${SUBSTACK_ORIGIN}/api/v1/email-otp-login/complete`, {
|
|
134
|
+
method: "POST",
|
|
135
|
+
redirect: "manual",
|
|
136
|
+
credentials: "include",
|
|
137
|
+
headers: {
|
|
138
|
+
"content-type": "application/json",
|
|
139
|
+
},
|
|
140
|
+
body: JSON.stringify({
|
|
141
|
+
code: normalizedCode,
|
|
142
|
+
email: normalizedEmail,
|
|
143
|
+
redirect: "",
|
|
144
|
+
for_pub: null,
|
|
145
|
+
island_magic_signin: false,
|
|
146
|
+
}),
|
|
147
|
+
});
|
|
148
|
+
mergeSetCookies(cookieJar, response);
|
|
149
|
+
|
|
150
|
+
const isRedirect = response.status >= 300 && response.status < 400;
|
|
151
|
+
if (!response.ok && !isRedirect) {
|
|
152
|
+
const detail = await parseErrorText(response);
|
|
153
|
+
throw new Error(`Substack code login failed (${response.status})${detail ? `: ${detail}` : ""}`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let redirectUrl = resolveSubstackUrl(response.headers.get("location"));
|
|
157
|
+
if (!redirectUrl && response.ok) {
|
|
158
|
+
try {
|
|
159
|
+
const payload = await response.clone().json() as { redirect?: unknown };
|
|
160
|
+
redirectUrl = resolveSubstackUrl(payload.redirect);
|
|
161
|
+
} catch {
|
|
162
|
+
redirectUrl = null;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if (!cookieJar.get("substack.sid") && redirectUrl) {
|
|
167
|
+
await followRedirectsForCookies(redirectUrl, cookieJar);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const sid = cookieJar.get("substack.sid");
|
|
171
|
+
if (!sid) {
|
|
172
|
+
const capturedCookieNames = [...cookieJar.keys()];
|
|
173
|
+
throw new Error(
|
|
174
|
+
`Substack accepted the code but did not return a session cookie. status=${response.status} redirect=${redirectUrl ? "yes" : "no"} cookies=${capturedCookieNames.length ? capturedCookieNames.join(",") : "none"}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const auth: SubstackAuthState = {
|
|
178
|
+
email: normalizedEmail,
|
|
179
|
+
sid,
|
|
180
|
+
lli: cookieJar.get("substack.lli") ?? "1",
|
|
181
|
+
loggedInAt: Date.now(),
|
|
182
|
+
};
|
|
183
|
+
storeSubstackAuth(auth);
|
|
184
|
+
return auth;
|
|
185
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { PersistedResourceValue } from "../../../../types/persistence";
|
|
2
|
+
import {
|
|
3
|
+
sortSubscriptionsByLatest,
|
|
4
|
+
} from "../normalize";
|
|
5
|
+
import type {
|
|
6
|
+
SubstackArticleDetail,
|
|
7
|
+
SubstackArticleSummary,
|
|
8
|
+
SubstackPublication,
|
|
9
|
+
} from "../types";
|
|
10
|
+
import type { SubstackCachedData, SubstackHomeData, SubstackPublicationFeedPage } from "./types";
|
|
11
|
+
import { readResource } from "./store";
|
|
12
|
+
|
|
13
|
+
const PUBLICATION_ARCHIVE_MAX_CACHED_PAGES = 20;
|
|
14
|
+
|
|
15
|
+
export function publicationCacheKey(publication: SubstackPublication, offset: number): string {
|
|
16
|
+
return `${publication.baseUrl ?? publication.subdomain ?? publication.id}:offset:${offset}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cachedDataFromResource<T>(entry: PersistedResourceValue<T> | null): SubstackCachedData<T> | null {
|
|
20
|
+
if (!entry) return null;
|
|
21
|
+
return {
|
|
22
|
+
data: entry.value,
|
|
23
|
+
fetchedAt: entry.fetchedAt,
|
|
24
|
+
stale: Boolean(entry.stale),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getCachedSubstackHome(): SubstackHomeData | null {
|
|
29
|
+
const subscriptionsEntry = readResource<SubstackPublication[]>("subscriptions", "me", false);
|
|
30
|
+
const feedEntry = readResource<SubstackArticleSummary[]>("feed", "subscribed", false);
|
|
31
|
+
if (!subscriptionsEntry || !feedEntry) return null;
|
|
32
|
+
return {
|
|
33
|
+
subscriptions: sortSubscriptionsByLatest(subscriptionsEntry.value, feedEntry.value),
|
|
34
|
+
feed: feedEntry.value,
|
|
35
|
+
fetchedAt: Math.max(subscriptionsEntry.fetchedAt, feedEntry.fetchedAt),
|
|
36
|
+
stale: Boolean(subscriptionsEntry.stale) || Boolean(feedEntry.stale),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getCachedSubstackPublicationFeed(
|
|
41
|
+
publication: SubstackPublication,
|
|
42
|
+
): SubstackCachedData<SubstackPublicationFeedPage> | null {
|
|
43
|
+
let offset: number | null = 0;
|
|
44
|
+
let pageCount = 0;
|
|
45
|
+
let merged: SubstackPublicationFeedPage | null = null;
|
|
46
|
+
let fetchedAt = 0;
|
|
47
|
+
let stale = false;
|
|
48
|
+
|
|
49
|
+
while (offset != null && pageCount < PUBLICATION_ARCHIVE_MAX_CACHED_PAGES) {
|
|
50
|
+
const entry: PersistedResourceValue<SubstackPublicationFeedPage> | null = readResource<SubstackPublicationFeedPage>(
|
|
51
|
+
"publication",
|
|
52
|
+
publicationCacheKey(publication, offset),
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
if (!entry) break;
|
|
56
|
+
const itemsById = new Map<string, SubstackArticleSummary>();
|
|
57
|
+
for (const article of merged?.items ?? []) itemsById.set(article.id, article);
|
|
58
|
+
for (const article of entry.value.items) itemsById.set(article.id, article);
|
|
59
|
+
merged = {
|
|
60
|
+
items: [...itemsById.values()],
|
|
61
|
+
nextOffset: entry.value.nextOffset,
|
|
62
|
+
hasMore: entry.value.hasMore,
|
|
63
|
+
};
|
|
64
|
+
fetchedAt = Math.max(fetchedAt, entry.fetchedAt);
|
|
65
|
+
stale = stale || Boolean(entry.stale);
|
|
66
|
+
offset = entry.value.nextOffset;
|
|
67
|
+
pageCount += 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!merged) return null;
|
|
71
|
+
return { data: merged, fetchedAt, stale };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getCachedSubstackArticleDetail(
|
|
75
|
+
article: Pick<SubstackArticleSummary, "id">,
|
|
76
|
+
): SubstackCachedData<SubstackArticleDetail> | null {
|
|
77
|
+
return cachedDataFromResource(readResource<SubstackArticleDetail>("post", article.id, false));
|
|
78
|
+
}
|