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.
Files changed (196) hide show
  1. package/README.md +106 -54
  2. package/package.json +14 -13
  3. package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
  4. package/src/api-client/chat.ts +8 -0
  5. package/src/api-client/index.test.ts +30 -0
  6. package/src/api-client/index.ts +4 -0
  7. package/src/api-client/normalizers.ts +1 -0
  8. package/src/api-client/types.ts +1 -0
  9. package/src/app/global-shortcuts.test.tsx +54 -4
  10. package/src/app/global-shortcuts.ts +11 -5
  11. package/src/capabilities/factories.ts +4 -0
  12. package/src/components/chart/comparison/data.test.ts +54 -0
  13. package/src/components/chart/comparison/data.ts +47 -24
  14. package/src/components/chart/core/projection.ts +51 -27
  15. package/src/components/command-bar/action-runtime.ts +1 -0
  16. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  17. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  18. package/src/components/command-bar/list/view.tsx +3 -1
  19. package/src/components/command-bar/pane-templates/items.ts +4 -1
  20. package/src/components/command-bar/panel/body.tsx +1 -0
  21. package/src/components/command-bar/routes/root/results.ts +0 -30
  22. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  23. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  24. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  25. package/src/components/command-bar/routing/launch-request.ts +14 -7
  26. package/src/components/command-bar/routing/list-state.ts +1 -3
  27. package/src/components/command-bar/surface/index.test.tsx +76 -4
  28. package/src/components/command-bar/surface/index.tsx +0 -2
  29. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  30. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  31. package/src/components/command-bar/view-model.ts +3 -3
  32. package/src/components/data-table/stack-view.test.tsx +14 -11
  33. package/src/components/data-table/stack-view.tsx +1 -2
  34. package/src/components/data-table/view.test.tsx +24 -18
  35. package/src/components/data-table/view.tsx +313 -83
  36. package/src/components/feed-data-table/stack-view.tsx +6 -8
  37. package/src/components/layout/pane/footer/index.test.tsx +6 -0
  38. package/src/components/layout/pane/footer/index.tsx +14 -2
  39. package/src/components/layout/pane/title.ts +5 -1
  40. package/src/components/layout/shell/index.test.tsx +120 -2
  41. package/src/components/layout/shell/index.tsx +2 -0
  42. package/src/components/layout/shell/menu.ts +1 -7
  43. package/src/components/layout/shell/pane/actions.ts +8 -0
  44. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  45. package/src/components/layout/shell/shortcuts.ts +14 -6
  46. package/src/components/layout/shell/window-mode/index.ts +8 -3
  47. package/src/components/layout/window-edit/presentation.ts +4 -4
  48. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  49. package/src/components/pane-settings-dialog/field-dialogs.tsx +0 -2
  50. package/src/components/pane-settings-dialog/tui.tsx +1 -1
  51. package/src/components/ticker/list-table-view.tsx +17 -25
  52. package/src/components/ui/choice-dialog.tsx +12 -2
  53. package/src/components/ui/multi-select/dialog.tsx +17 -5
  54. package/src/components/ui/remote-image.tsx +1 -14
  55. package/src/components/ui/shortcut-hint.tsx +14 -2
  56. package/src/components/ui/table-layout.test.ts +2 -2
  57. package/src/components/ui/table-layout.ts +8 -2
  58. package/src/components/ui/tabs.tsx +115 -86
  59. package/src/components/ui/ui.test.tsx +83 -4
  60. package/src/core/state/app/types.ts +12 -4
  61. package/src/market-data/coordinator/auxiliary.ts +29 -1
  62. package/src/market-data/coordinator/index.ts +17 -1
  63. package/src/market-data/hooks.tsx +17 -1
  64. package/src/market-data/selectors.ts +4 -0
  65. package/src/plugins/builtin/account-management/form-components.tsx +0 -4
  66. package/src/plugins/builtin/account-management/model.test.ts +54 -0
  67. package/src/plugins/builtin/account-management/model.ts +13 -3
  68. package/src/plugins/builtin/account-management/pane.tsx +5 -11
  69. package/src/plugins/builtin/account-management/password-dialog.tsx +1 -1
  70. package/src/plugins/builtin/alerts/pane.tsx +5 -3
  71. package/src/plugins/builtin/analytics/index.tsx +0 -3
  72. package/src/plugins/builtin/analytics/view.tsx +6 -5
  73. package/src/plugins/builtin/broker-manager/index.tsx +6 -6
  74. package/src/plugins/builtin/buildout/pane/index.tsx +6 -6
  75. package/src/plugins/builtin/changelog/index.tsx +40 -27
  76. package/src/plugins/builtin/changelog/model.test.ts +77 -0
  77. package/src/plugins/builtin/changelog/model.ts +98 -0
  78. package/src/plugins/builtin/chat/channel-labels.ts +53 -0
  79. package/src/plugins/builtin/chat/channels.test.ts +28 -0
  80. package/src/plugins/builtin/chat/channels.ts +36 -19
  81. package/src/plugins/builtin/chat/chat.test.tsx +264 -5
  82. package/src/plugins/builtin/chat/content/channel-navigation.ts +50 -11
  83. package/src/plugins/builtin/chat/content/composer-runtime.test.tsx +144 -0
  84. package/src/plugins/builtin/chat/content/composer-runtime.ts +151 -20
  85. package/src/plugins/builtin/chat/content/composer.tsx +23 -2
  86. package/src/plugins/builtin/chat/content/index.tsx +66 -4
  87. package/src/plugins/builtin/chat/content/layout-metrics.ts +3 -1
  88. package/src/plugins/builtin/chat/content/shortcuts.ts +27 -1
  89. package/src/plugins/builtin/chat/content/transcript.tsx +8 -0
  90. package/src/plugins/builtin/chat/content/types.ts +1 -0
  91. package/src/plugins/builtin/chat/controller/index.ts +41 -0
  92. package/src/plugins/builtin/chat/controller/messages.ts +18 -0
  93. package/src/plugins/builtin/chat/controller/notifications.ts +10 -4
  94. package/src/plugins/builtin/chat/controller/utils.ts +6 -3
  95. package/src/plugins/builtin/chat/controller/view.ts +1 -1
  96. package/src/plugins/builtin/chat/controller.test.ts +189 -0
  97. package/src/plugins/builtin/chat/edit-window.ts +24 -0
  98. package/src/plugins/builtin/chat/layout.ts +94 -31
  99. package/src/plugins/builtin/chat/message/action-chip.tsx +1 -0
  100. package/src/plugins/builtin/chat/message/desktop.tsx +53 -20
  101. package/src/plugins/builtin/chat/message/inline-tokens.tsx +16 -10
  102. package/src/plugins/builtin/chat/message/render-state.ts +5 -1
  103. package/src/plugins/builtin/chat/message/terminal.tsx +49 -20
  104. package/src/plugins/builtin/chat/message/types.ts +2 -0
  105. package/src/plugins/builtin/chat/pane.tsx +35 -7
  106. package/src/plugins/builtin/chat/sidebar.test.tsx +72 -0
  107. package/src/plugins/builtin/chat/status-widget.tsx +29 -11
  108. package/src/plugins/builtin/chat/test-harness.tsx +2 -0
  109. package/src/plugins/builtin/cloud/plugin.tsx +3 -0
  110. package/src/plugins/builtin/cloud-tweets/table.tsx +7 -11
  111. package/src/plugins/builtin/congress-trades/pane.tsx +14 -22
  112. package/src/plugins/builtin/earnings/index.tsx +8 -7
  113. package/src/plugins/builtin/econ/index.tsx +6 -8
  114. package/src/plugins/builtin/help/components.tsx +1 -1
  115. package/src/plugins/builtin/help/index.test.tsx +105 -30
  116. package/src/plugins/builtin/help/index.tsx +326 -164
  117. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  118. package/src/plugins/builtin/holders/pane.tsx +7 -6
  119. package/src/plugins/builtin/market-movers/index.tsx +6 -3
  120. package/src/plugins/builtin/news/wire/news/table.tsx +7 -6
  121. package/src/plugins/builtin/options/view.tsx +16 -8
  122. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  123. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  124. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  125. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  126. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  127. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  128. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  129. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  130. package/src/plugins/builtin/research/analyst-pane.tsx +1 -2
  131. package/src/plugins/builtin/research/corporate-actions-pane.tsx +358 -7
  132. package/src/plugins/builtin/research/index.test.ts +30 -1
  133. package/src/plugins/builtin/research/relative-valuation-pane.tsx +6 -8
  134. package/src/plugins/builtin/sec/filing-documents.ts +66 -0
  135. package/src/plugins/builtin/sec/index.tsx +132 -20
  136. package/src/plugins/builtin/sectors/index.tsx +13 -23
  137. package/src/plugins/builtin/shared/ticker-request.ts +1 -0
  138. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  139. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  140. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  141. package/src/plugins/builtin/substack/api/store.ts +186 -0
  142. package/src/plugins/builtin/substack/api/types.ts +40 -0
  143. package/src/plugins/builtin/substack/api.test.ts +298 -0
  144. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  145. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  146. package/src/plugins/builtin/substack/content.ts +478 -0
  147. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  148. package/src/plugins/builtin/substack/index.tsx +49 -0
  149. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  150. package/src/plugins/builtin/substack/model.test.ts +155 -0
  151. package/src/plugins/builtin/substack/normalize.ts +343 -0
  152. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  153. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  154. package/src/plugins/builtin/substack/pane.tsx +500 -0
  155. package/src/plugins/builtin/substack/table.ts +111 -0
  156. package/src/plugins/builtin/substack/types.ts +71 -0
  157. package/src/plugins/builtin/substack/utils.ts +131 -0
  158. package/src/plugins/builtin/thirteenf/pane.tsx +26 -25
  159. package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +5 -4
  160. package/src/plugins/builtin/ticker-detail/data-panes/historical-prices.tsx +5 -4
  161. package/src/plugins/builtin/ticker-detail/data-panes/provider-search.tsx +6 -5
  162. package/src/plugins/builtin/ticker-detail/financials/tab.tsx +10 -8
  163. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  164. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  165. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  166. package/src/plugins/builtin/world-indices/index.tsx +9 -6
  167. package/src/plugins/catalog-ui.ts +2 -0
  168. package/src/plugins/pane-manager/layout-state.ts +11 -0
  169. package/src/plugins/pane-manager.ts +1 -1
  170. package/src/plugins/prediction-markets/pane.tsx +10 -9
  171. package/src/plugins/registry/index.ts +3 -0
  172. package/src/plugins/runtime/context.tsx +1 -0
  173. package/src/plugins/runtime/index.tsx +4 -0
  174. package/src/react/use-throttled-commit-value.ts +116 -0
  175. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  176. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  177. package/src/renderers/electrobun/view/cli-pane-shot-entry.tsx +1 -0
  178. package/src/renderers/electrobun/view/data-table/index.tsx +3 -2
  179. package/src/renderers/electrobun/view/data-table/row.tsx +2 -2
  180. package/src/renderers/electrobun/view/host/input.tsx +23 -2
  181. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  182. package/src/renderers/electrobun/view/remote/asset-data-client.ts +1 -0
  183. package/src/sources/gloomberb-cloud/index.ts +4 -0
  184. package/src/sources/provider-router/cache.ts +1 -0
  185. package/src/sources/provider-router/document-routes.ts +45 -0
  186. package/src/sources/provider-router/index.ts +4 -0
  187. package/src/sources/sec-edgar/content.ts +9 -3
  188. package/src/sources/sec-edgar.test.ts +108 -1
  189. package/src/sources/sec-edgar.ts +105 -1
  190. package/src/sources/yahoo-finance.ts +4 -0
  191. package/src/state/app/context/index.test.tsx +16 -0
  192. package/src/test-support/plugin-runtime.ts +1 -0
  193. package/src/tickers/search/index.ts +1 -0
  194. package/src/types/data-provider.ts +11 -0
  195. package/src/ui/host.tsx +1 -0
  196. package/src/version.ts +1 -1
@@ -0,0 +1,359 @@
1
+ import { useCallback, type RefObject } from "react";
2
+ import { Box, ScrollBox, Text, TextAttributes, useRendererHost, type ScrollBoxRenderable } from "../../../ui";
3
+ import { Spinner } from "../../../components";
4
+ import { RemoteImage } from "../../../components/ui";
5
+ import { TickerBadgeText } from "../../../components/ticker/badge/text";
6
+ import { useInlineTickers } from "../../../state/hooks/inline-tickers";
7
+ import { colors } from "../../../theme/colors";
8
+ import type {
9
+ SubstackArticleDetail,
10
+ SubstackArticleSummary,
11
+ SubstackContentBlock,
12
+ } from "./types";
13
+
14
+ function articleBlockText(block: SubstackContentBlock): string {
15
+ switch (block.type) {
16
+ case "heading":
17
+ case "paragraph":
18
+ case "quote":
19
+ case "listItem":
20
+ return block.text;
21
+ case "embed":
22
+ return [block.text, block.url].filter(Boolean).join("\n");
23
+ default:
24
+ return "";
25
+ }
26
+ }
27
+
28
+ function wrappedTextProps(width: number) {
29
+ return {
30
+ width,
31
+ wrapText: true,
32
+ wrapMode: "word",
33
+ style: {
34
+ minWidth: 0,
35
+ whiteSpace: "pre-wrap",
36
+ overflowWrap: "anywhere",
37
+ },
38
+ } as const;
39
+ }
40
+
41
+ function normalizedTwitterUsername(username: string | null | undefined): string | null {
42
+ const normalized = username?.trim().replace(/^@/, "") ?? "";
43
+ return /^[A-Za-z0-9_]{1,15}$/.test(normalized) ? normalized : null;
44
+ }
45
+
46
+ function TweetEmbedView({
47
+ block,
48
+ lineWidth,
49
+ imageWidth,
50
+ imageHeight,
51
+ catalog,
52
+ openTicker,
53
+ openLink,
54
+ openUsername,
55
+ }: {
56
+ block: Extract<SubstackContentBlock, { type: "embed"; kind: "tweet" }>;
57
+ lineWidth: number;
58
+ imageWidth: number;
59
+ imageHeight: number;
60
+ catalog: ReturnType<typeof useInlineTickers>["catalog"];
61
+ openTicker: ReturnType<typeof useInlineTickers>["openTicker"];
62
+ openLink: (url: string) => void;
63
+ openUsername: (username: string) => void;
64
+ }) {
65
+ const username = normalizedTwitterUsername(block.username);
66
+ const contentWidth = Math.max(1, lineWidth - 3);
67
+
68
+ return (
69
+ <Box flexDirection="column" width={lineWidth} paddingX={1}>
70
+ <Box flexDirection="row" width={Math.max(1, lineWidth - 2)}>
71
+ <Text fg={colors.textDim} attributes={TextAttributes.BOLD}>Tweet</Text>
72
+ {username ? (
73
+ <Text
74
+ fg={colors.borderFocused}
75
+ onMouseDown={() => openUsername(username)}
76
+ >
77
+ {` @${username}`}
78
+ </Text>
79
+ ) : null}
80
+ {block.dateLabel ? <Text fg={colors.textDim}>{` | ${block.dateLabel}`}</Text> : null}
81
+ </Box>
82
+ <Box flexDirection="row" width={Math.max(1, lineWidth - 2)}>
83
+ <Text fg={colors.borderFocused}>| </Text>
84
+ <Box width={contentWidth}>
85
+ <TickerBadgeText
86
+ text={block.text}
87
+ lineWidth={contentWidth}
88
+ catalog={catalog}
89
+ textColor={colors.text}
90
+ openTicker={openTicker}
91
+ openLink={openLink}
92
+ openUsername={openUsername}
93
+ />
94
+ </Box>
95
+ </Box>
96
+ {block.imageUrls.length > 0 ? (
97
+ <Box flexDirection="column" paddingLeft={2}>
98
+ {block.imageUrls.slice(0, 2).map((url, index) => (
99
+ <RemoteImage
100
+ key={url}
101
+ src={url}
102
+ alt={`Tweet image ${index + 1}`}
103
+ width={Math.max(1, imageWidth - 2)}
104
+ height={Math.max(4, Math.min(imageHeight, 10))}
105
+ label={block.imageUrls.length > 1 ? `tweet image ${index + 1}` : "tweet image"}
106
+ />
107
+ ))}
108
+ </Box>
109
+ ) : null}
110
+ {block.url ? (
111
+ <Text
112
+ fg={colors.borderFocused}
113
+ onMouseDown={() => openLink(block.url!)}
114
+ >
115
+ open tweet
116
+ </Text>
117
+ ) : null}
118
+ </Box>
119
+ );
120
+ }
121
+
122
+ function ArticleBlockView({
123
+ block,
124
+ lineWidth,
125
+ imageWidth,
126
+ imageHeight,
127
+ catalog,
128
+ openTicker,
129
+ openLink,
130
+ openUsername,
131
+ }: {
132
+ block: SubstackContentBlock;
133
+ lineWidth: number;
134
+ imageWidth: number;
135
+ imageHeight: number;
136
+ catalog: ReturnType<typeof useInlineTickers>["catalog"];
137
+ openTicker: ReturnType<typeof useInlineTickers>["openTicker"];
138
+ openLink: (url: string) => void;
139
+ openUsername: (username: string) => void;
140
+ }) {
141
+ switch (block.type) {
142
+ case "heading":
143
+ return (
144
+ <Text
145
+ fg={colors.textBright}
146
+ attributes={TextAttributes.BOLD}
147
+ {...wrappedTextProps(lineWidth)}
148
+ >
149
+ {block.text}
150
+ </Text>
151
+ );
152
+ case "quote":
153
+ return (
154
+ <Box flexDirection="row" width={lineWidth}>
155
+ <Text fg={colors.warning}>| </Text>
156
+ <Box width={Math.max(1, lineWidth - 2)}>
157
+ <TickerBadgeText
158
+ text={block.text}
159
+ lineWidth={Math.max(1, lineWidth - 2)}
160
+ catalog={catalog}
161
+ textColor={colors.textDim}
162
+ openTicker={openTicker}
163
+ openLink={openLink}
164
+ openUsername={openUsername}
165
+ />
166
+ </Box>
167
+ </Box>
168
+ );
169
+ case "listItem":
170
+ return (
171
+ <Box flexDirection="row" width={lineWidth}>
172
+ <Text fg={colors.textDim}>- </Text>
173
+ <Box width={Math.max(1, lineWidth - 2)}>
174
+ <TickerBadgeText
175
+ text={block.text}
176
+ lineWidth={Math.max(1, lineWidth - 2)}
177
+ catalog={catalog}
178
+ textColor={colors.text}
179
+ openTicker={openTicker}
180
+ openLink={openLink}
181
+ openUsername={openUsername}
182
+ />
183
+ </Box>
184
+ </Box>
185
+ );
186
+ case "image":
187
+ return (
188
+ <RemoteImage
189
+ src={block.url}
190
+ alt={block.alt ?? "Substack image"}
191
+ width={imageWidth}
192
+ height={imageHeight}
193
+ label={block.alt ?? "image"}
194
+ />
195
+ );
196
+ case "embed":
197
+ if (block.kind === "tweet") {
198
+ return (
199
+ <TweetEmbedView
200
+ block={block}
201
+ lineWidth={lineWidth}
202
+ imageWidth={imageWidth}
203
+ imageHeight={imageHeight}
204
+ catalog={catalog}
205
+ openTicker={openTicker}
206
+ openLink={openLink}
207
+ openUsername={openUsername}
208
+ />
209
+ );
210
+ }
211
+ return (
212
+ <Box flexDirection="column" width={lineWidth} paddingX={1}>
213
+ <Text fg={colors.textDim} attributes={TextAttributes.BOLD}>
214
+ {block.kind === "media" ? "Media" : "Link"}
215
+ </Text>
216
+ <TickerBadgeText
217
+ text={block.text}
218
+ lineWidth={Math.max(1, lineWidth - 2)}
219
+ catalog={catalog}
220
+ textColor={colors.text}
221
+ openTicker={openTicker}
222
+ openLink={openLink}
223
+ openUsername={openUsername}
224
+ />
225
+ {block.url ? (
226
+ <Text
227
+ fg={colors.borderFocused}
228
+ onMouseDown={() => openLink(block.url!)}
229
+ {...wrappedTextProps(Math.max(1, lineWidth - 2))}
230
+ >
231
+ {block.url}
232
+ </Text>
233
+ ) : null}
234
+ </Box>
235
+ );
236
+ case "divider":
237
+ return <Text fg={colors.border}>{"-".repeat(Math.max(1, Math.min(lineWidth, 96)))}</Text>;
238
+ case "paragraph":
239
+ default:
240
+ return (
241
+ <TickerBadgeText
242
+ text={block.text}
243
+ lineWidth={lineWidth}
244
+ catalog={catalog}
245
+ textColor={colors.text}
246
+ openTicker={openTicker}
247
+ openLink={openLink}
248
+ openUsername={openUsername}
249
+ />
250
+ );
251
+ }
252
+ }
253
+
254
+ function ArticleRichContent({
255
+ blocks,
256
+ fallbackText,
257
+ fallbackImageUrls,
258
+ articleTitle,
259
+ lineWidth,
260
+ imageWidth,
261
+ imageHeight,
262
+ }: {
263
+ blocks: SubstackContentBlock[];
264
+ fallbackText: string;
265
+ fallbackImageUrls: string[];
266
+ articleTitle: string;
267
+ lineWidth: number;
268
+ imageWidth: number;
269
+ imageHeight: number;
270
+ }) {
271
+ const rendererHost = useRendererHost();
272
+ const resolvedBlocks = blocks.length > 0
273
+ ? blocks
274
+ : [
275
+ ...(fallbackText ? [{ type: "paragraph" as const, text: fallbackText }] : []),
276
+ ...fallbackImageUrls.map((url): SubstackContentBlock => ({ type: "image", url, alt: articleTitle })),
277
+ ];
278
+ const tickerText = resolvedBlocks.map(articleBlockText).filter(Boolean).join("\n");
279
+ const { catalog, openTicker } = useInlineTickers([tickerText], { liveQuotes: false });
280
+ const openLink = useCallback((url: string) => {
281
+ void rendererHost.openExternal(url);
282
+ }, [rendererHost]);
283
+ const openUsername = useCallback((username: string) => {
284
+ const normalized = normalizedTwitterUsername(username);
285
+ if (!normalized) return;
286
+ void rendererHost.openExternal(`https://x.com/${normalized}`);
287
+ }, [rendererHost]);
288
+
289
+ if (resolvedBlocks.length === 0) {
290
+ return <Text fg={colors.textDim}>No article text returned.</Text>;
291
+ }
292
+
293
+ return (
294
+ <Box flexDirection="column" width={lineWidth} gap={1}>
295
+ {resolvedBlocks.map((block, index) => (
296
+ <ArticleBlockView
297
+ key={`${block.type}:${index}:${articleBlockText(block).slice(0, 32)}`}
298
+ block={block}
299
+ lineWidth={lineWidth}
300
+ imageWidth={imageWidth}
301
+ imageHeight={imageHeight}
302
+ catalog={catalog}
303
+ openTicker={openTicker}
304
+ openLink={openLink}
305
+ openUsername={openUsername}
306
+ />
307
+ ))}
308
+ </Box>
309
+ );
310
+ }
311
+
312
+ export function ArticleDetail({
313
+ article,
314
+ detail,
315
+ width,
316
+ loading,
317
+ error,
318
+ scrollRef,
319
+ onOpenArticle,
320
+ }: {
321
+ article: SubstackArticleSummary;
322
+ detail: SubstackArticleDetail | null;
323
+ width: number;
324
+ loading: boolean;
325
+ error: string | null;
326
+ scrollRef: RefObject<ScrollBoxRenderable | null>;
327
+ onOpenArticle: () => void;
328
+ }) {
329
+ const resolved = detail ?? null;
330
+ const text = resolved?.contentText || article.previewText || article.subtitle || "";
331
+ const blocks = resolved?.contentBlocks ?? [];
332
+ const fallbackImageUrls = resolved?.imageUrls.length ? resolved.imageUrls : article.imageUrls;
333
+ const lineWidth = Math.max(1, width - 2);
334
+ const imageWidth = Math.min(lineWidth, 86);
335
+ const imageHeight = Math.max(6, Math.min(14, Math.floor(imageWidth * 0.32)));
336
+
337
+ return (
338
+ <ScrollBox ref={scrollRef} scrollY focusable={false} flexGrow={1} paddingX={1}>
339
+ <Box flexDirection="column" width={lineWidth} gap={1}>
340
+ {loading && !resolved ? <Spinner label="Loading article..." /> : null}
341
+ {error ? <Text fg={colors.negative}>{error}</Text> : null}
342
+ <ArticleRichContent
343
+ blocks={blocks}
344
+ fallbackText={text}
345
+ fallbackImageUrls={fallbackImageUrls}
346
+ articleTitle={article.title}
347
+ lineWidth={lineWidth}
348
+ imageWidth={imageWidth}
349
+ imageHeight={imageHeight}
350
+ />
351
+ {article.url ? (
352
+ <Box height={1}>
353
+ <Text fg={colors.textDim} onMouseDown={onOpenArticle}>Open source: O</Text>
354
+ </Box>
355
+ ) : null}
356
+ </Box>
357
+ </ScrollBox>
358
+ );
359
+ }
@@ -0,0 +1,129 @@
1
+ import { useCallback, type ReactNode, type RefObject } from "react";
2
+ import { Box, Text, TextAttributes, type ScrollBoxRenderable } from "../../../ui";
3
+ import {
4
+ DataTableStackView,
5
+ type DataTableCell,
6
+ type DataTableKeyEvent,
7
+ } from "../../../components";
8
+ import { colors } from "../../../theme/colors";
9
+ import {
10
+ formatPublishedAt,
11
+ formatReadTime,
12
+ } from "./table";
13
+ import type {
14
+ SubstackArticleSummary,
15
+ SubstackColumn,
16
+ SubstackPublication,
17
+ SubstackSortColumnId,
18
+ SubstackSortDirection,
19
+ } from "./types";
20
+ import type { ActiveFeedState } from "./pane-state";
21
+
22
+ export function SubstackArticleStack({
23
+ focused,
24
+ detailOpen,
25
+ selectedArticle,
26
+ selectedArticleId,
27
+ detailContent,
28
+ activePublication,
29
+ activeFeedState,
30
+ sortedRows,
31
+ sort,
32
+ columns,
33
+ tableScrollRef,
34
+ width,
35
+ height,
36
+ onBack,
37
+ onActivate,
38
+ onSelectionChange,
39
+ onRootKeyDown,
40
+ onDetailKeyDown,
41
+ onBodyScrollActivity,
42
+ onHeaderClick,
43
+ }: {
44
+ focused: boolean;
45
+ detailOpen: boolean;
46
+ selectedArticle: SubstackArticleSummary | null;
47
+ selectedArticleId: string | null;
48
+ detailContent: ReactNode;
49
+ activePublication: SubstackPublication | null;
50
+ activeFeedState: ActiveFeedState;
51
+ sortedRows: SubstackArticleSummary[];
52
+ sort: { columnId: SubstackSortColumnId; direction: SubstackSortDirection };
53
+ columns: SubstackColumn[];
54
+ tableScrollRef: RefObject<ScrollBoxRenderable | null>;
55
+ width: number;
56
+ height: number;
57
+ onBack: () => void;
58
+ onActivate: (article: SubstackArticleSummary) => void;
59
+ onSelectionChange: (id: string) => void;
60
+ onRootKeyDown: (event: DataTableKeyEvent) => boolean;
61
+ onDetailKeyDown: (event: DataTableKeyEvent) => boolean;
62
+ onBodyScrollActivity: () => void;
63
+ onHeaderClick: (columnId: string) => void;
64
+ }) {
65
+ const renderCell = useCallback((
66
+ article: SubstackArticleSummary,
67
+ column: SubstackColumn,
68
+ _index: number,
69
+ rowState: { selected: boolean },
70
+ ): DataTableCell => {
71
+ const selectedColor = rowState.selected ? colors.selectedText : undefined;
72
+ switch (column.id) {
73
+ case "published":
74
+ return { text: formatPublishedAt(article.publishedAt), color: selectedColor ?? colors.textDim };
75
+ case "publication":
76
+ return { text: article.publicationName ?? "-", color: selectedColor ?? colors.textBright };
77
+ case "title":
78
+ return {
79
+ text: article.title,
80
+ color: selectedColor ?? colors.text,
81
+ attributes: TextAttributes.BOLD,
82
+ };
83
+ case "read":
84
+ return {
85
+ text: formatReadTime(article.readMinutes),
86
+ color: selectedColor ?? colors.textDim,
87
+ };
88
+ }
89
+ }, []);
90
+
91
+ const bodyAfter = activePublication && sortedRows.length > 0 && (activeFeedState.loading || activeFeedState.loadingMore) ? (
92
+ <Box height={1} paddingX={1}>
93
+ <Text fg={colors.textDim}>{activeFeedState.loadingMore ? "Loading more..." : "Loading archive..."}</Text>
94
+ </Box>
95
+ ) : null;
96
+
97
+ return (
98
+ <DataTableStackView<SubstackArticleSummary, SubstackColumn>
99
+ focused={focused}
100
+ detailOpen={detailOpen}
101
+ onBack={onBack}
102
+ detailTitle={selectedArticle?.title}
103
+ detailContent={detailContent}
104
+ selection={{
105
+ kind: "id",
106
+ selectedId: selectedArticleId,
107
+ getId: (article) => article.id,
108
+ onChange: onSelectionChange,
109
+ }}
110
+ onActivate={onActivate}
111
+ onRootKeyDown={onRootKeyDown}
112
+ onDetailKeyDown={onDetailKeyDown}
113
+ onBodyScrollActivity={onBodyScrollActivity}
114
+ scrollRef={tableScrollRef}
115
+ bodyAfter={bodyAfter}
116
+ rootWidth={width}
117
+ rootHeight={Math.max(1, height - 1)}
118
+ columns={columns}
119
+ items={sortedRows}
120
+ sortColumnId={sort.columnId}
121
+ sortDirection={sort.direction}
122
+ onHeaderClick={onHeaderClick}
123
+ getItemKey={(article) => article.id}
124
+ renderCell={renderCell}
125
+ emptyStateTitle={activeFeedState.loading ? "Loading articles..." : activeFeedState.error ?? "No Substack posts"}
126
+ emptyStateHint={activePublication ? activePublication.name : "Authenticated reader feed"}
127
+ />
128
+ );
129
+ }