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,478 @@
1
+ import { decodeHtmlEntities } from "../../../utils/html-entities";
2
+ import type { ExtractedArticleContent, SubstackContentBlock } from "./types";
3
+ import {
4
+ asRecord,
5
+ extractAttribute,
6
+ firstString,
7
+ normalizeUrl,
8
+ parseDateIso,
9
+ rawStringValue,
10
+ stringValue,
11
+ uniqueStrings,
12
+ type JsonRecord,
13
+ } from "./utils";
14
+
15
+ const WORDS_PER_MINUTE = 220;
16
+
17
+ export function wordCount(text: string): number {
18
+ return text.match(/[\p{L}\p{N}]+(?:['.-][\p{L}\p{N}]+)*/gu)?.length ?? 0;
19
+ }
20
+
21
+ export function estimateReadingMinutes(wordsOrText: number | string | null | undefined): number {
22
+ const words = typeof wordsOrText === "number"
23
+ ? wordsOrText
24
+ : typeof wordsOrText === "string"
25
+ ? wordCount(wordsOrText)
26
+ : 0;
27
+ return Math.max(1, Math.ceil(Math.max(0, words) / WORDS_PER_MINUTE));
28
+ }
29
+
30
+ function bestSrcsetUrl(srcset: string | null, baseUrl?: string | null): string | null {
31
+ if (!srcset) return null;
32
+ const candidates = srcset
33
+ .split(",")
34
+ .map((entry) => entry.trim().split(/\s+/)[0] ?? "")
35
+ .map((entry) => normalizeUrl(decodeHtmlEntities(entry), baseUrl))
36
+ .filter((entry): entry is string => !!entry);
37
+ return candidates.at(-1) ?? null;
38
+ }
39
+
40
+ export function extractImageUrlsFromHtml(html: string, baseUrl?: string | null): string[] {
41
+ const urls: Array<string | null> = [];
42
+ for (const match of html.matchAll(/<(img|source)\b[^>]*>/gi)) {
43
+ const tag = match[0];
44
+ urls.push(
45
+ normalizeUrl(decodeHtmlEntities(extractAttribute(tag, "src") ?? ""), baseUrl),
46
+ normalizeUrl(decodeHtmlEntities(extractAttribute(tag, "data-src") ?? ""), baseUrl),
47
+ bestSrcsetUrl(decodeHtmlEntities(extractAttribute(tag, "srcset") ?? ""), baseUrl),
48
+ bestSrcsetUrl(decodeHtmlEntities(extractAttribute(tag, "data-srcset") ?? ""), baseUrl),
49
+ );
50
+ }
51
+ return uniqueStrings(urls);
52
+ }
53
+
54
+ interface ExtractedLink {
55
+ url: string;
56
+ label: string;
57
+ }
58
+
59
+ function extractLinksFromHtml(html: string, baseUrl?: string | null): ExtractedLink[] {
60
+ const links: ExtractedLink[] = [];
61
+ for (const match of html.matchAll(/<a\b[^>]*>/gi)) {
62
+ const tag = match[0];
63
+ const url = normalizeUrl(decodeHtmlEntities(extractAttribute(tag, "href") ?? ""), baseUrl);
64
+ if (!url) continue;
65
+ const closeIndex = html.indexOf("</a>", match.index + tag.length);
66
+ const label = closeIndex >= 0
67
+ ? plainFragment(html.slice(match.index + tag.length, closeIndex))
68
+ : url;
69
+ links.push({ url, label: label || url });
70
+ }
71
+ const seen = new Set<string>();
72
+ return links.filter((link) => {
73
+ if (seen.has(link.url)) return false;
74
+ seen.add(link.url);
75
+ return true;
76
+ });
77
+ }
78
+
79
+ function extractLinkUrlsFromHtml(html: string, baseUrl?: string | null): string[] {
80
+ return extractLinksFromHtml(html, baseUrl).map((link) => link.url);
81
+ }
82
+
83
+ function plainFragment(html: string): string {
84
+ return decodeHtmlEntities(html.replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim());
85
+ }
86
+
87
+ function textFragment(html: string, baseUrl?: string | null): string {
88
+ const withReadableLinks = html.replace(
89
+ /<a\b([^>]*)>([\s\S]*?)<\/a>/gi,
90
+ (_match, attrs: string, inner: string) => {
91
+ const href = normalizeUrl(decodeHtmlEntities(extractAttribute(attrs, "href") ?? ""), baseUrl);
92
+ const label = plainFragment(inner);
93
+ if (label) return label;
94
+ return href ?? "";
95
+ },
96
+ );
97
+ return plainFragment(
98
+ withReadableLinks
99
+ .replace(/<br\s*\/?>/gi, "\n")
100
+ .replace(/<\/(p|div|section|article|blockquote|h[1-6]|li|tr|ul|ol)>/gi, "\n"),
101
+ );
102
+ }
103
+
104
+ function normalizeArticleText(text: string): string {
105
+ return decodeHtmlEntities(text)
106
+ .replace(/\r/g, "")
107
+ .replace(/[ \t]+\n/g, "\n")
108
+ .replace(/\n[ \t]+/g, "\n")
109
+ .split("\n")
110
+ .map((line) => line.replace(/[ \t]+/g, " ").trim())
111
+ .join("\n")
112
+ .replace(/\n{3,}/g, "\n\n")
113
+ .trim();
114
+ }
115
+
116
+ function isSocialUrl(url: string | null | undefined): boolean {
117
+ return !!url && /\b(?:twitter\.com|x\.com|t\.co)\b/i.test(url);
118
+ }
119
+
120
+ function twitterUsernameFromUrl(url: string | null | undefined): string | null {
121
+ if (!url) return null;
122
+ const match = url.match(/\b(?:twitter\.com|x\.com)\/([A-Za-z0-9_]{1,15})\/status\//i);
123
+ return match?.[1] ?? null;
124
+ }
125
+
126
+ function socialEmbedUrl(links: ExtractedLink[]): string | null {
127
+ return links.find((link) => isSocialUrl(link.url))?.url ?? null;
128
+ }
129
+
130
+ function isSocialEmbed(attrs: string, inner: string, links: ExtractedLink[]): boolean {
131
+ return /twitter|tweet|x\.com/i.test(attrs)
132
+ || /twitter-tweet|tweet|platform\.twitter|x\.com|twitter\.com/i.test(inner)
133
+ || links.some((link) => isSocialUrl(link.url));
134
+ }
135
+
136
+ function formatTweetDateLabel(value: unknown): string | null {
137
+ const parsed = parseDateIso(value);
138
+ if (!parsed) return stringValue(value);
139
+ return new Date(parsed).toLocaleDateString("en-US", {
140
+ month: "short",
141
+ day: "numeric",
142
+ year: "numeric",
143
+ });
144
+ }
145
+
146
+ function isLikelyImageUrl(url: string | null | undefined): boolean {
147
+ if (!url) return false;
148
+ const decoded = decodeURIComponent(url);
149
+ return /\/image\/fetch\//i.test(decoded)
150
+ || /\.(?:avif|gif|jpe?g|png|svg|webp)(?:[?#]|$)/i.test(decoded);
151
+ }
152
+
153
+ function textMatchesImageLink(text: string, links: ExtractedLink[], imageUrls: string[]): boolean {
154
+ const normalizedText = normalizeArticleText(text);
155
+ if (!normalizedText) return false;
156
+ const directUrl = normalizeUrl(normalizedText);
157
+ if (isLikelyImageUrl(directUrl)) return true;
158
+
159
+ const imageLinkLabels = links
160
+ .filter((link) => isLikelyImageUrl(link.url))
161
+ .flatMap((link) => [link.label, link.url])
162
+ .map(normalizeArticleText);
163
+ if (imageLinkLabels.includes(normalizedText)) return true;
164
+
165
+ return imageUrls.some((url) => normalizedText === url || normalizedText.includes(url));
166
+ }
167
+
168
+ function buildTweetEmbedBlock(
169
+ text: string,
170
+ links: ExtractedLink[],
171
+ url: string | null,
172
+ ): SubstackContentBlock {
173
+ const sourceLink = links.find((link) => link.url === url) ?? links.find((link) => isSocialUrl(link.url));
174
+ const dateLabel = sourceLink && sourceLink.label !== sourceLink.url ? sourceLink.label : null;
175
+ let body = normalizeArticleText(text);
176
+ if (dateLabel && body.endsWith(dateLabel)) {
177
+ body = normalizeArticleText(body.slice(0, -dateLabel.length));
178
+ }
179
+
180
+ let username = twitterUsernameFromUrl(url);
181
+ let authorName: string | null = null;
182
+ const authorMatch = body.match(/\s+[\u2014-]\s*([^@\n]+?)\s*\(@([A-Za-z0-9_]{1,15})\)\s*$/);
183
+ if (authorMatch?.index != null) {
184
+ authorName = normalizeArticleText(authorMatch[1] ?? "") || null;
185
+ username = username ?? authorMatch[2] ?? null;
186
+ body = normalizeArticleText(body.slice(0, authorMatch.index));
187
+ }
188
+
189
+ return {
190
+ type: "embed",
191
+ kind: "tweet",
192
+ text: body || "Tweet",
193
+ url,
194
+ username,
195
+ authorName,
196
+ dateLabel,
197
+ imageUrls: [],
198
+ };
199
+ }
200
+
201
+ function tweetTextFromRecord(record: JsonRecord | null): string | null {
202
+ const raw = rawStringValue(record?.full_text) ?? rawStringValue(record?.text);
203
+ if (!raw) return null;
204
+ const withReadableLinks = raw.replace(
205
+ /<a\b([^>]*)>([\s\S]*?)<\/a>/gi,
206
+ (_match, attrs: string, inner: string) => {
207
+ const href = normalizeUrl(decodeHtmlEntities(extractAttribute(attrs, "href") ?? ""));
208
+ const label = plainFragment(inner);
209
+ if (label) return label;
210
+ return href ?? "";
211
+ },
212
+ );
213
+ return normalizeArticleText(decodeHtmlEntities(withReadableLinks)
214
+ .replace(/<br\s*\/?>/gi, "\n")
215
+ .replace(/<[^>]+>/g, " "));
216
+ }
217
+
218
+ function tweetImageUrlsFromRecord(record: JsonRecord | null): string[] {
219
+ const photos = Array.isArray(record?.photos) ? record.photos : [];
220
+ return uniqueStrings(photos.map((photo) => (
221
+ normalizeUrl(firstString(asRecord(photo), ["img_url", "url", "src"]))
222
+ )));
223
+ }
224
+
225
+ function tweetEmbedBlockFromAttrs(attrs: string): SubstackContentBlock | null {
226
+ const rawAttrs = extractAttribute(attrs, "data-attrs");
227
+ if (!rawAttrs) return null;
228
+ let record: JsonRecord | null = null;
229
+ try {
230
+ record = asRecord(JSON.parse(decodeHtmlEntities(rawAttrs)));
231
+ } catch {
232
+ return null;
233
+ }
234
+ if (!record) return null;
235
+
236
+ const url = normalizeUrl(firstString(record, ["url", "expanded_url"]));
237
+ const quoted = asRecord(record.quoted_tweet);
238
+ const text = [
239
+ tweetTextFromRecord(record),
240
+ tweetTextFromRecord(quoted) ? `Quoted: ${tweetTextFromRecord(quoted)}` : null,
241
+ ].filter(Boolean).join("\n\n");
242
+
243
+ return {
244
+ type: "embed",
245
+ kind: "tweet",
246
+ text: text || "Tweet",
247
+ url,
248
+ username: firstString(record, ["username"]) ?? twitterUsernameFromUrl(url),
249
+ authorName: firstString(record, ["name"]),
250
+ dateLabel: formatTweetDateLabel(record.date),
251
+ imageUrls: tweetImageUrlsFromRecord(record),
252
+ };
253
+ }
254
+
255
+ function pushTextBlock(
256
+ blocks: SubstackContentBlock[],
257
+ block: SubstackContentBlock,
258
+ ): void {
259
+ const text = "text" in block ? normalizeArticleText(block.text) : "";
260
+ if ("text" in block && !text) return;
261
+ const normalizedBlock = "text" in block ? { ...block, text } : block;
262
+ const previous = blocks.at(-1);
263
+ if (
264
+ previous
265
+ && "text" in previous
266
+ && "text" in normalizedBlock
267
+ && previous.type === normalizedBlock.type
268
+ && previous.text === normalizedBlock.text
269
+ ) {
270
+ return;
271
+ }
272
+ blocks.push(normalizedBlock);
273
+ }
274
+
275
+ function stripDuplicateLeadingTitleFromBlocks(
276
+ blocks: SubstackContentBlock[],
277
+ title: string | null | undefined,
278
+ ): SubstackContentBlock[] {
279
+ const normalizedTitle = (title ?? "").replace(/\s+/g, " ").trim().toLowerCase();
280
+ if (!normalizedTitle) return blocks;
281
+ const index = blocks.findIndex((block) => "text" in block && block.text.trim().length > 0);
282
+ if (index < 0) return blocks;
283
+ const block = blocks[index]!;
284
+ if (!("text" in block)) return blocks;
285
+ const firstText = block.text.replace(/\s+/g, " ").trim().toLowerCase();
286
+ if (firstText !== normalizedTitle) return blocks;
287
+ return [...blocks.slice(0, index), ...blocks.slice(index + 1)];
288
+ }
289
+
290
+ function articleTextFromBlocks(blocks: SubstackContentBlock[]): string {
291
+ return normalizeArticleText(blocks
292
+ .map((block) => {
293
+ switch (block.type) {
294
+ case "heading":
295
+ case "paragraph":
296
+ case "quote":
297
+ case "listItem":
298
+ return block.text;
299
+ case "embed":
300
+ return [block.text, block.url].filter(Boolean).join("\n");
301
+ default:
302
+ return "";
303
+ }
304
+ })
305
+ .filter(Boolean)
306
+ .join("\n\n"));
307
+ }
308
+
309
+ function imageBlocksFromHtml(
310
+ html: string,
311
+ baseUrl: string | null,
312
+ seenImages: Set<string>,
313
+ ): SubstackContentBlock[] {
314
+ const blocks: SubstackContentBlock[] = [];
315
+ for (const match of html.matchAll(/<img\b[^>]*>/gi)) {
316
+ const tag = match[0];
317
+ const url = normalizeUrl(decodeHtmlEntities(extractAttribute(tag, "src") ?? ""), baseUrl)
318
+ ?? normalizeUrl(decodeHtmlEntities(extractAttribute(tag, "data-src") ?? ""), baseUrl)
319
+ ?? bestSrcsetUrl(decodeHtmlEntities(extractAttribute(tag, "srcset") ?? ""), baseUrl)
320
+ ?? bestSrcsetUrl(decodeHtmlEntities(extractAttribute(tag, "data-srcset") ?? ""), baseUrl);
321
+ if (!url || seenImages.has(url)) continue;
322
+ seenImages.add(url);
323
+ blocks.push({
324
+ type: "image",
325
+ url,
326
+ alt: stringValue(extractAttribute(tag, "alt")),
327
+ });
328
+ }
329
+ return blocks;
330
+ }
331
+
332
+ export function extractArticleContentBlocks(
333
+ bodyHtml: string | null | undefined,
334
+ options: { baseUrl?: string | null; imageUrls?: string[]; title?: string | null } = {},
335
+ ): SubstackContentBlock[] {
336
+ const baseUrl = options.baseUrl ?? null;
337
+ const html = (bodyHtml ?? "")
338
+ .replace(/<script\b[\s\S]*?<\/script>/gi, " ")
339
+ .replace(/<style\b[\s\S]*?<\/style>/gi, " ")
340
+ .replace(/<noscript\b[\s\S]*?<\/noscript>/gi, " ");
341
+ const blocks: SubstackContentBlock[] = [];
342
+ const seenImages = new Set<string>();
343
+ const blockPattern = /<div\b(?=[^>]*twitter-embed)([\s\S]*?)>\s*<\/div>|<(h[1-6]|p|blockquote|li|figure|iframe)\b([^>]*)>([\s\S]*?)<\/\2>|<(img|hr)\b([^>]*)\/?>/gi;
344
+ let match: RegExpExecArray | null;
345
+
346
+ while ((match = blockPattern.exec(html)) !== null) {
347
+ const twitterAttrs = match[1] ?? "";
348
+ const pairedTag = match[2]?.toLowerCase();
349
+ const selfClosingTag = match[5]?.toLowerCase();
350
+ const tag = twitterAttrs ? "twitterEmbed" : pairedTag ?? selfClosingTag;
351
+ const attrs = twitterAttrs || match[3] || match[6] || "";
352
+ const inner = match[4] ?? "";
353
+
354
+ if (!tag) continue;
355
+
356
+ if (tag === "twitterEmbed") {
357
+ const tweetBlock = tweetEmbedBlockFromAttrs(attrs);
358
+ if (tweetBlock) pushTextBlock(blocks, tweetBlock);
359
+ continue;
360
+ }
361
+
362
+ if (tag === "img") {
363
+ for (const block of imageBlocksFromHtml(match[0], baseUrl, seenImages)) blocks.push(block);
364
+ continue;
365
+ }
366
+ if (tag === "hr") {
367
+ if (blocks.at(-1)?.type !== "divider") blocks.push({ type: "divider" });
368
+ continue;
369
+ }
370
+ if (tag === "iframe") {
371
+ const src = normalizeUrl(decodeHtmlEntities(extractAttribute(attrs, "src") ?? ""), baseUrl);
372
+ pushTextBlock(blocks, {
373
+ type: "embed",
374
+ kind: /youtube|youtu\.be|vimeo|video/i.test(src ?? "") ? "media" : "link",
375
+ text: src ? "Embedded media" : "Embedded content",
376
+ url: src,
377
+ });
378
+ continue;
379
+ }
380
+
381
+ const links = extractLinksFromHtml(inner, baseUrl);
382
+ const text = textFragment(inner, baseUrl);
383
+
384
+ if (tag === "figure") {
385
+ const socialUrl = socialEmbedUrl(links);
386
+ if (isSocialEmbed(attrs, inner, links)) {
387
+ pushTextBlock(blocks, buildTweetEmbedBlock(text || "Tweet", links, socialUrl ?? links.at(-1)?.url ?? null));
388
+ continue;
389
+ }
390
+ const imageUrls = extractImageUrlsFromHtml(inner, baseUrl);
391
+ for (const block of imageBlocksFromHtml(inner, baseUrl, seenImages)) blocks.push(block);
392
+ if (text && !textMatchesImageLink(text, links, imageUrls)) {
393
+ pushTextBlock(blocks, { type: "paragraph", text });
394
+ }
395
+ continue;
396
+ }
397
+
398
+ if (tag.startsWith("h")) {
399
+ const level = Number(tag.slice(1));
400
+ pushTextBlock(blocks, { type: "heading", level, text });
401
+ continue;
402
+ }
403
+ if (tag === "blockquote") {
404
+ const socialUrl = socialEmbedUrl(links);
405
+ pushTextBlock(blocks, isSocialEmbed(attrs, inner, links)
406
+ ? buildTweetEmbedBlock(text || "Tweet", links, socialUrl ?? links.at(-1)?.url ?? null)
407
+ : { type: "quote", text });
408
+ continue;
409
+ }
410
+ if (tag === "li") {
411
+ pushTextBlock(blocks, { type: "listItem", text });
412
+ continue;
413
+ }
414
+
415
+ if (text && textMatchesImageLink(text, links, [])) {
416
+ continue;
417
+ }
418
+ if (links.length === 1 && text && text === links[0]!.label && links[0]!.url !== text) {
419
+ pushTextBlock(blocks, { type: "embed", kind: "link", text, url: links[0]!.url });
420
+ continue;
421
+ }
422
+ pushTextBlock(blocks, { type: "paragraph", text });
423
+ }
424
+
425
+ if (blocks.length === 0) {
426
+ const fallback = normalizeArticleText(textFragment(html, baseUrl));
427
+ if (fallback) {
428
+ for (const paragraph of fallback.split(/\n{2,}/)) {
429
+ pushTextBlock(blocks, { type: "paragraph", text: paragraph });
430
+ }
431
+ }
432
+ }
433
+
434
+ for (const url of options.imageUrls ?? []) {
435
+ const normalized = normalizeUrl(url, baseUrl);
436
+ if (!normalized || seenImages.has(normalized)) continue;
437
+ seenImages.add(normalized);
438
+ blocks.push({ type: "image", url: normalized, alt: null });
439
+ }
440
+
441
+ return stripDuplicateLeadingTitleFromBlocks(blocks, options.title);
442
+ }
443
+
444
+ export function extractArticleContent(
445
+ bodyHtml: string | null | undefined,
446
+ options: { baseUrl?: string | null; imageUrls?: string[]; title?: string | null } = {},
447
+ ): ExtractedArticleContent {
448
+ const html = bodyHtml ?? "";
449
+ const baseUrl = options.baseUrl ?? null;
450
+ const imageUrls = uniqueStrings([
451
+ ...(options.imageUrls ?? []),
452
+ ...extractImageUrlsFromHtml(html, baseUrl),
453
+ ].map((url) => normalizeUrl(url, baseUrl)));
454
+ const linkUrls = extractLinkUrlsFromHtml(html, baseUrl).filter((url) => !isLikelyImageUrl(url));
455
+ const blocks = extractArticleContentBlocks(bodyHtml, options);
456
+ const text = stripDuplicateLeadingTitle(articleTextFromBlocks(blocks), options.title);
457
+ const words = wordCount(text);
458
+ return {
459
+ text,
460
+ blocks,
461
+ imageUrls,
462
+ linkUrls,
463
+ wordCount: words,
464
+ readMinutes: estimateReadingMinutes(words),
465
+ };
466
+ }
467
+
468
+ function stripDuplicateLeadingTitle(text: string, title: string | null | undefined): string {
469
+ const normalizedTitle = (title ?? "").replace(/\s+/g, " ").trim().toLowerCase();
470
+ if (!normalizedTitle || !text) return text;
471
+ const lines = text.split("\n");
472
+ const firstContentIndex = lines.findIndex((line) => line.trim().length > 0);
473
+ if (firstContentIndex < 0) return text;
474
+ const firstLine = lines[firstContentIndex]!.replace(/\s+/g, " ").trim().toLowerCase();
475
+ if (firstLine !== normalizedTitle) return text;
476
+ const nextLines = [...lines.slice(0, firstContentIndex), ...lines.slice(firstContentIndex + 1)];
477
+ return normalizeArticleText(nextLines.join("\n"));
478
+ }
@@ -0,0 +1,41 @@
1
+ import { Box } from "../../../ui";
2
+ import { Tabs } from "../../../components";
3
+ import { tabIdForPublication } from "./table";
4
+ import {
5
+ SUBSTACK_FEED_TAB_ID,
6
+ type SubstackPublication,
7
+ } from "./types";
8
+ import { tabLabel } from "./pane-state";
9
+
10
+ export function SubstackFeedTabs({
11
+ subscriptions,
12
+ activeTab,
13
+ focused,
14
+ detailOpen,
15
+ onSelect,
16
+ }: {
17
+ subscriptions: SubstackPublication[];
18
+ activeTab: string;
19
+ focused: boolean;
20
+ detailOpen: boolean;
21
+ onSelect: (tabId: string) => void;
22
+ }) {
23
+ return (
24
+ <Box height={1}>
25
+ <Tabs
26
+ tabs={[
27
+ { label: "Feed", value: SUBSTACK_FEED_TAB_ID },
28
+ ...subscriptions.map((publication) => ({
29
+ label: tabLabel(publication.name),
30
+ value: tabIdForPublication(publication),
31
+ })),
32
+ ]}
33
+ activeValue={activeTab}
34
+ onSelect={onSelect}
35
+ compact
36
+ variant="pill"
37
+ focused={focused && !detailOpen}
38
+ />
39
+ </Box>
40
+ );
41
+ }
@@ -0,0 +1,49 @@
1
+ import type { GloomPlugin } from "../../../types/plugin";
2
+ import {
3
+ attachSubstackPersistence,
4
+ resetSubstackPersistence,
5
+ } from "./api/store";
6
+ import {
7
+ SUBSTACK_PANE_ID,
8
+ SUBSTACK_PLUGIN_ID,
9
+ } from "./types";
10
+ import { SubstackPane } from "./pane";
11
+
12
+ export const substackPlugin: GloomPlugin = {
13
+ id: SUBSTACK_PLUGIN_ID,
14
+ name: "Substack",
15
+ version: "1.0.0",
16
+ description: "Authenticated Substack reader feed and subscriptions",
17
+ toggleable: true,
18
+
19
+ setup(ctx) {
20
+ attachSubstackPersistence(ctx.persistence);
21
+ },
22
+
23
+ dispose() {
24
+ resetSubstackPersistence();
25
+ },
26
+
27
+ panes: [
28
+ {
29
+ id: SUBSTACK_PANE_ID,
30
+ name: "Substack",
31
+ icon: "S",
32
+ component: SubstackPane,
33
+ defaultPosition: "right",
34
+ defaultMode: "floating",
35
+ defaultFloatingSize: { width: 104, height: 32 },
36
+ },
37
+ ],
38
+
39
+ paneTemplates: [
40
+ {
41
+ id: "substack-pane",
42
+ paneId: SUBSTACK_PANE_ID,
43
+ label: "Substack",
44
+ description: "Open the authenticated Substack reader feed.",
45
+ keywords: ["substack", "newsletter", "feed", "reader", "subscription"],
46
+ shortcut: { prefix: "SUB" },
47
+ },
48
+ ],
49
+ };
@@ -0,0 +1,126 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { Box, Text, TextAttributes, type InputRenderable } from "../../../ui";
3
+ import { Button, TextField } from "../../../components";
4
+ import { colors } from "../../../theme/colors";
5
+ import {
6
+ completeSubstackMagicLink,
7
+ completeSubstackOtpLogin,
8
+ requestSubstackMagicLink,
9
+ } from "./api/auth";
10
+ import type {
11
+ SubstackAuthState,
12
+ } from "./api/types";
13
+ import { errorMessage } from "./pane-state";
14
+
15
+ export function SubstackLoginView({
16
+ width,
17
+ height,
18
+ focused,
19
+ onLogin,
20
+ }: {
21
+ width: number;
22
+ height: number;
23
+ focused: boolean;
24
+ onLogin: (auth: SubstackAuthState) => void;
25
+ }) {
26
+ const [email, setEmail] = useState("");
27
+ const [loginToken, setLoginToken] = useState("");
28
+ const [phase, setPhase] = useState<"email" | "link">("email");
29
+ const [busy, setBusy] = useState(false);
30
+ const [error, setError] = useState<string | null>(null);
31
+ const emailRef = useRef<InputRenderable | null>(null);
32
+ const linkRef = useRef<InputRenderable | null>(null);
33
+ const panelWidth = Math.max(32, Math.min(72, width - 4));
34
+
35
+ useEffect(() => {
36
+ if (!focused) return;
37
+ const input = phase === "email" ? emailRef.current : linkRef.current;
38
+ input?.focus?.();
39
+ }, [focused, phase]);
40
+
41
+ const requestLink = useCallback(() => {
42
+ if (busy) return;
43
+ setBusy(true);
44
+ setError(null);
45
+ requestSubstackMagicLink(email)
46
+ .then(() => {
47
+ setPhase("link");
48
+ })
49
+ .catch((requestError) => setError(errorMessage(requestError)))
50
+ .finally(() => setBusy(false));
51
+ }, [busy, email]);
52
+
53
+ const completeLogin = useCallback(() => {
54
+ if (busy) return;
55
+ setBusy(true);
56
+ setError(null);
57
+ const token = loginToken.trim();
58
+ const login = /^\d{6}$/.test(token)
59
+ ? completeSubstackOtpLogin(token, email)
60
+ : completeSubstackMagicLink(token, email);
61
+ login
62
+ .then(onLogin)
63
+ .catch((loginError) => setError(errorMessage(loginError)))
64
+ .finally(() => setBusy(false));
65
+ }, [busy, email, loginToken, onLogin]);
66
+
67
+ return (
68
+ <Box width={width} height={height} justifyContent="center" alignItems="center">
69
+ <Box width={panelWidth} flexDirection="column" gap={1}>
70
+ <Text fg={colors.textBright} attributes={TextAttributes.BOLD}>Substack login</Text>
71
+ {phase === "email" ? (
72
+ <>
73
+ <TextField
74
+ label="Email"
75
+ value={email}
76
+ placeholder="you@example.com"
77
+ width={panelWidth}
78
+ inputRef={emailRef}
79
+ focused={focused}
80
+ onChange={setEmail}
81
+ onSubmit={requestLink}
82
+ />
83
+ <Button
84
+ label={busy ? "Sending..." : "Send magic link"}
85
+ variant="primary"
86
+ disabled={busy || !email.trim()}
87
+ onPress={requestLink}
88
+ />
89
+ </>
90
+ ) : (
91
+ <>
92
+ <Text fg={colors.textDim}>Paste the 6-digit code or full URL from Substack's email.</Text>
93
+ <TextField
94
+ label="Code or magic link"
95
+ value={loginToken}
96
+ placeholder="123456 or https://substack.com/..."
97
+ width={panelWidth}
98
+ inputRef={linkRef}
99
+ focused={focused}
100
+ onChange={setLoginToken}
101
+ onSubmit={completeLogin}
102
+ />
103
+ <Box flexDirection="row" gap={1}>
104
+ <Button
105
+ label={busy ? "Logging in..." : "Log in"}
106
+ variant="primary"
107
+ disabled={busy || !loginToken.trim()}
108
+ onPress={completeLogin}
109
+ />
110
+ <Button
111
+ label="Change email"
112
+ variant="ghost"
113
+ disabled={busy}
114
+ onPress={() => {
115
+ setPhase("email");
116
+ setLoginToken("");
117
+ }}
118
+ />
119
+ </Box>
120
+ </>
121
+ )}
122
+ {error ? <Text fg={colors.negative}>{error}</Text> : null}
123
+ </Box>
124
+ </Box>
125
+ );
126
+ }