gloomberb 0.8.2 → 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 (85) hide show
  1. package/README.md +9 -7
  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/app/global-shortcuts.test.tsx +54 -4
  5. package/src/app/global-shortcuts.ts +11 -5
  6. package/src/components/command-bar/action-runtime.ts +1 -0
  7. package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
  8. package/src/components/command-bar/layout-items/current-layout.ts +33 -0
  9. package/src/components/command-bar/list/view.tsx +3 -1
  10. package/src/components/command-bar/pane-templates/items.ts +4 -1
  11. package/src/components/command-bar/panel/body.tsx +1 -0
  12. package/src/components/command-bar/routes/root/results.ts +0 -30
  13. package/src/components/command-bar/routes/root/runtime.ts +2 -20
  14. package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
  15. package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
  16. package/src/components/command-bar/routing/launch-request.ts +14 -7
  17. package/src/components/command-bar/routing/list-state.ts +1 -3
  18. package/src/components/command-bar/surface/index.test.tsx +76 -4
  19. package/src/components/command-bar/surface/index.tsx +0 -2
  20. package/src/components/command-bar/surface/pane.test.tsx +1 -0
  21. package/src/components/command-bar/surface/test-harness.tsx +1 -0
  22. package/src/components/command-bar/view-model.ts +3 -3
  23. package/src/components/layout/shell/index.test.tsx +120 -2
  24. package/src/components/layout/shell/index.tsx +2 -0
  25. package/src/components/layout/shell/menu.ts +1 -7
  26. package/src/components/layout/shell/pane/actions.ts +8 -0
  27. package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
  28. package/src/components/layout/shell/shortcuts.ts +14 -6
  29. package/src/components/layout/shell/window-mode/index.ts +8 -3
  30. package/src/components/layout/window-edit/presentation.ts +4 -4
  31. package/src/components/onboarding/onboarding-steps.tsx +3 -2
  32. package/src/components/ticker/list-table-view.tsx +6 -0
  33. package/src/components/ui/remote-image.tsx +1 -14
  34. package/src/components/ui/tabs.tsx +95 -80
  35. package/src/components/ui/ui.test.tsx +54 -0
  36. package/src/core/state/app/types.ts +12 -4
  37. package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
  38. package/src/plugins/builtin/chat/content/index.tsx +5 -4
  39. package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
  40. package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
  41. package/src/plugins/builtin/help/components.tsx +1 -1
  42. package/src/plugins/builtin/help/index.test.tsx +105 -30
  43. package/src/plugins/builtin/help/index.tsx +324 -168
  44. package/src/plugins/builtin/help/shortcut-model.ts +5 -6
  45. package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
  46. package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
  47. package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
  48. package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
  49. package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
  50. package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
  51. package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
  52. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
  53. package/src/plugins/builtin/substack/api/auth.ts +185 -0
  54. package/src/plugins/builtin/substack/api/cache.ts +78 -0
  55. package/src/plugins/builtin/substack/api/loaders.ts +146 -0
  56. package/src/plugins/builtin/substack/api/store.ts +186 -0
  57. package/src/plugins/builtin/substack/api/types.ts +40 -0
  58. package/src/plugins/builtin/substack/api.test.ts +298 -0
  59. package/src/plugins/builtin/substack/article-detail.tsx +359 -0
  60. package/src/plugins/builtin/substack/article-stack.tsx +129 -0
  61. package/src/plugins/builtin/substack/content.ts +478 -0
  62. package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
  63. package/src/plugins/builtin/substack/index.tsx +49 -0
  64. package/src/plugins/builtin/substack/login-view.tsx +126 -0
  65. package/src/plugins/builtin/substack/model.test.ts +155 -0
  66. package/src/plugins/builtin/substack/normalize.ts +343 -0
  67. package/src/plugins/builtin/substack/pane-footer.ts +71 -0
  68. package/src/plugins/builtin/substack/pane-state.ts +170 -0
  69. package/src/plugins/builtin/substack/pane.tsx +500 -0
  70. package/src/plugins/builtin/substack/table.ts +111 -0
  71. package/src/plugins/builtin/substack/types.ts +71 -0
  72. package/src/plugins/builtin/substack/utils.ts +131 -0
  73. package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
  74. package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
  75. package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
  76. package/src/plugins/catalog-ui.ts +2 -0
  77. package/src/plugins/pane-manager/layout-state.ts +11 -0
  78. package/src/plugins/pane-manager.ts +1 -1
  79. package/src/react/use-throttled-commit-value.ts +116 -0
  80. package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
  81. package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
  82. package/src/renderers/electrobun/view/http-fetch.ts +10 -3
  83. package/src/state/app/context/index.test.tsx +16 -0
  84. package/src/tickers/search/index.ts +1 -0
  85. package/src/version.ts +1 -1
@@ -0,0 +1,281 @@
1
+ import { useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
2
+ import type { ColumnConfig } from "../../../../types/config";
3
+ import type { AnalystResearchData, CorporateActionsData } from "../../../../types/financials";
4
+ import type { DataProvider, EarningsEvent } from "../../../../types/data-provider";
5
+ import type { TickerRecord } from "../../../../types/ticker";
6
+ import { useAssetData } from "../../../runtime";
7
+ import { loadEarningsCalendar } from "../../earnings/data/cache";
8
+
9
+ const SUPPLEMENTAL_BATCH_LIMIT = 6;
10
+ const ANALYST_COLUMN_IDS = new Set(["target", "target_pct", "rating"]);
11
+ const CORPORATE_COLUMN_IDS = new Set(["ex_div", "next_earn"]);
12
+ const EARNINGS_COLUMN_IDS = new Set(["next_earn"]);
13
+
14
+ interface SupplementalTarget {
15
+ symbol: string;
16
+ exchange: string;
17
+ }
18
+
19
+ export interface PortfolioSupplementalData {
20
+ analystResearch: Map<string, AnalystResearchData | null>;
21
+ corporateActions: Map<string, CorporateActionsData | null>;
22
+ earningsEvents: Map<string, EarningsEvent | null>;
23
+ version: number;
24
+ }
25
+
26
+ type SupplementalState = PortfolioSupplementalData;
27
+
28
+ function targetKey(targets: SupplementalTarget[]): string {
29
+ return targets.map((target) => `${target.symbol}:${target.exchange}`).join("|");
30
+ }
31
+
32
+ function targetsFromTickers(tickers: TickerRecord[]): SupplementalTarget[] {
33
+ return tickers.map((ticker) => ({
34
+ symbol: ticker.metadata.ticker,
35
+ exchange: ticker.metadata.exchange ?? "",
36
+ }));
37
+ }
38
+
39
+ function needsAnyColumn(columns: ColumnConfig[], ids: Set<string>): boolean {
40
+ return columns.some((column) => ids.has(column.id));
41
+ }
42
+
43
+ function pickMissingBatch<T>(
44
+ targets: SupplementalTarget[],
45
+ values: Map<string, T | null>,
46
+ inFlight: Set<string>,
47
+ ): SupplementalTarget[] {
48
+ const batch: SupplementalTarget[] = [];
49
+ for (const target of targets) {
50
+ if (values.has(target.symbol) || inFlight.has(target.symbol)) continue;
51
+ batch.push(target);
52
+ if (batch.length >= SUPPLEMENTAL_BATCH_LIMIT) break;
53
+ }
54
+ return batch;
55
+ }
56
+
57
+ function markMissing<T>(
58
+ targets: SupplementalTarget[],
59
+ values: Map<string, T | null>,
60
+ ): Map<string, T | null> | null {
61
+ let changed = false;
62
+ const next = new Map(values);
63
+ for (const target of targets) {
64
+ if (next.has(target.symbol)) continue;
65
+ next.set(target.symbol, null);
66
+ changed = true;
67
+ }
68
+ return changed ? next : null;
69
+ }
70
+
71
+ function setAnalystValues(
72
+ current: SupplementalState,
73
+ entries: Array<readonly [string, AnalystResearchData | null]>,
74
+ ): SupplementalState {
75
+ const analystResearch = new Map(current.analystResearch);
76
+ for (const [symbol, data] of entries) {
77
+ analystResearch.set(symbol, data);
78
+ }
79
+ return { ...current, analystResearch, version: current.version + 1 };
80
+ }
81
+
82
+ function setCorporateValues(
83
+ current: SupplementalState,
84
+ entries: Array<readonly [string, CorporateActionsData | null]>,
85
+ ): SupplementalState {
86
+ const corporateActions = new Map(current.corporateActions);
87
+ for (const [symbol, data] of entries) {
88
+ corporateActions.set(symbol, data);
89
+ }
90
+ return { ...current, corporateActions, version: current.version + 1 };
91
+ }
92
+
93
+ function setEarningsValues(
94
+ current: SupplementalState,
95
+ symbols: string[],
96
+ events: EarningsEvent[],
97
+ ): SupplementalState {
98
+ const eventsBySymbol = new Map<string, EarningsEvent>();
99
+ const now = Date.now();
100
+ for (const event of events) {
101
+ const symbol = event.symbol.trim().toUpperCase();
102
+ if (!symbol || event.earningsDate.getTime() < now) continue;
103
+ const currentEvent = eventsBySymbol.get(symbol);
104
+ if (!currentEvent || event.earningsDate < currentEvent.earningsDate) {
105
+ eventsBySymbol.set(symbol, event);
106
+ }
107
+ }
108
+
109
+ const earningsEvents = new Map(current.earningsEvents);
110
+ for (const symbol of symbols) {
111
+ earningsEvents.set(symbol, eventsBySymbol.get(symbol) ?? null);
112
+ }
113
+ return { ...current, earningsEvents, version: current.version + 1 };
114
+ }
115
+
116
+ function useAnalystSupplemental(
117
+ provider: DataProvider | null | undefined,
118
+ targets: SupplementalTarget[],
119
+ enabled: boolean,
120
+ state: SupplementalState,
121
+ setState: Dispatch<SetStateAction<SupplementalState>>,
122
+ ) {
123
+ const inFlightRef = useRef(new Set<string>());
124
+ const targetsKey = targetKey(targets);
125
+
126
+ useEffect(() => {
127
+ if (!enabled || targets.length === 0) return;
128
+ if (!provider?.getAnalystResearch) {
129
+ setState((current) => {
130
+ const missing = markMissing(targets, current.analystResearch);
131
+ return missing ? { ...current, analystResearch: missing, version: current.version + 1 } : current;
132
+ });
133
+ return;
134
+ }
135
+
136
+ const batch = pickMissingBatch(targets, state.analystResearch, inFlightRef.current);
137
+ if (batch.length === 0) return;
138
+ for (const target of batch) inFlightRef.current.add(target.symbol);
139
+
140
+ let cancelled = false;
141
+ Promise.all(batch.map(async (target) => {
142
+ try {
143
+ const data = await provider.getAnalystResearch!(target.symbol, target.exchange);
144
+ return [target.symbol, data] as const;
145
+ } catch {
146
+ return [target.symbol, null] as const;
147
+ } finally {
148
+ inFlightRef.current.delete(target.symbol);
149
+ }
150
+ })).then((entries) => {
151
+ if (cancelled) return;
152
+ setState((current) => setAnalystValues(current, entries));
153
+ }).catch(() => {});
154
+
155
+ return () => {
156
+ cancelled = true;
157
+ };
158
+ }, [enabled, provider, setState, state.analystResearch, targetsKey]);
159
+ }
160
+
161
+ function useCorporateSupplemental(
162
+ provider: DataProvider | null | undefined,
163
+ targets: SupplementalTarget[],
164
+ enabled: boolean,
165
+ state: SupplementalState,
166
+ setState: Dispatch<SetStateAction<SupplementalState>>,
167
+ ) {
168
+ const inFlightRef = useRef(new Set<string>());
169
+ const targetsKey = targetKey(targets);
170
+
171
+ useEffect(() => {
172
+ if (!enabled || targets.length === 0) return;
173
+ if (!provider?.getCorporateActions) {
174
+ setState((current) => {
175
+ const missing = markMissing(targets, current.corporateActions);
176
+ return missing ? { ...current, corporateActions: missing, version: current.version + 1 } : current;
177
+ });
178
+ return;
179
+ }
180
+
181
+ const batch = pickMissingBatch(targets, state.corporateActions, inFlightRef.current);
182
+ if (batch.length === 0) return;
183
+ for (const target of batch) inFlightRef.current.add(target.symbol);
184
+
185
+ let cancelled = false;
186
+ Promise.all(batch.map(async (target) => {
187
+ try {
188
+ const data = await provider.getCorporateActions!(target.symbol, target.exchange);
189
+ return [target.symbol, data] as const;
190
+ } catch {
191
+ return [target.symbol, null] as const;
192
+ } finally {
193
+ inFlightRef.current.delete(target.symbol);
194
+ }
195
+ })).then((entries) => {
196
+ if (cancelled) return;
197
+ setState((current) => setCorporateValues(current, entries));
198
+ }).catch(() => {});
199
+
200
+ return () => {
201
+ cancelled = true;
202
+ };
203
+ }, [enabled, provider, setState, state.corporateActions, targetsKey]);
204
+ }
205
+
206
+ function useEarningsSupplemental(
207
+ provider: DataProvider | null | undefined,
208
+ targets: SupplementalTarget[],
209
+ enabled: boolean,
210
+ state: SupplementalState,
211
+ setState: Dispatch<SetStateAction<SupplementalState>>,
212
+ ) {
213
+ const inFlightRef = useRef(false);
214
+ const targetsKey = targetKey(targets);
215
+
216
+ useEffect(() => {
217
+ if (!enabled || targets.length === 0) return;
218
+ if (!provider?.getEarningsCalendar) {
219
+ setState((current) => {
220
+ const missing = markMissing(targets, current.earningsEvents);
221
+ return missing ? { ...current, earningsEvents: missing, version: current.version + 1 } : current;
222
+ });
223
+ return;
224
+ }
225
+
226
+ const missingSymbols = targets
227
+ .map((target) => target.symbol)
228
+ .filter((symbol) => !state.earningsEvents.has(symbol));
229
+ if (missingSymbols.length === 0 || inFlightRef.current) return;
230
+
231
+ inFlightRef.current = true;
232
+ let cancelled = false;
233
+ loadEarningsCalendar(provider, missingSymbols)
234
+ .then((events) => {
235
+ if (cancelled) return;
236
+ setState((current) => setEarningsValues(current, missingSymbols, events));
237
+ })
238
+ .catch(() => {
239
+ if (cancelled) return;
240
+ setState((current) => ({
241
+ ...current,
242
+ earningsEvents: markMissing(
243
+ missingSymbols.map((symbol) => ({ symbol, exchange: "" })),
244
+ current.earningsEvents,
245
+ ) ?? current.earningsEvents,
246
+ version: current.version + 1,
247
+ }));
248
+ })
249
+ .finally(() => {
250
+ inFlightRef.current = false;
251
+ });
252
+
253
+ return () => {
254
+ cancelled = true;
255
+ };
256
+ }, [enabled, provider, setState, state.earningsEvents, targetsKey]);
257
+ }
258
+
259
+ export function usePortfolioSupplementalData(
260
+ tickers: TickerRecord[],
261
+ columns: ColumnConfig[],
262
+ appActive: boolean,
263
+ ): PortfolioSupplementalData {
264
+ const provider = useAssetData();
265
+ const targets = useMemo(() => targetsFromTickers(tickers), [tickers]);
266
+ const [state, setState] = useState<SupplementalState>(() => ({
267
+ analystResearch: new Map(),
268
+ corporateActions: new Map(),
269
+ earningsEvents: new Map(),
270
+ version: 0,
271
+ }));
272
+ const needsAnalyst = appActive && needsAnyColumn(columns, ANALYST_COLUMN_IDS);
273
+ const needsCorporate = appActive && needsAnyColumn(columns, CORPORATE_COLUMN_IDS);
274
+ const needsEarnings = appActive && needsAnyColumn(columns, EARNINGS_COLUMN_IDS);
275
+
276
+ useAnalystSupplemental(provider, targets, needsAnalyst, state, setState);
277
+ useCorporateSupplemental(provider, targets, needsCorporate, state, setState);
278
+ useEarningsSupplemental(provider, targets, needsEarnings, state, setState);
279
+
280
+ return state;
281
+ }
@@ -21,30 +21,57 @@ export interface CollectionEntry {
21
21
  const PORTFOLIO_COLUMN_DEFS: ColumnConfig[] = [
22
22
  ...DEFAULT_COLUMNS,
23
23
  { id: PRICE_SPARKLINE_COLUMN_ID, label: PRICE_SPARKLINE_PERIOD_LABEL, width: 6, align: "left" },
24
+ { id: "name", label: "NAME", width: 16, align: "left" },
25
+ { id: "asset_type", label: "TYPE", width: 5, align: "left" },
26
+ { id: "exchange", label: "EXCH", width: 8, align: "left" },
27
+ { id: "currency", label: "CCY", width: 4, align: "left" },
28
+ { id: "sector", label: "SECTOR", width: 14, align: "left" },
29
+ { id: "industry", label: "INDUSTRY", width: 16, align: "left" },
30
+ { id: "tags", label: "TAGS", width: 14, align: "left" },
31
+ { id: "volume", label: "VOL", width: 8, align: "right", format: "compact" },
32
+ { id: "dollar_volume", label: "$VOL", width: 9, align: "right", format: "compact" },
33
+ { id: "range_52w", label: "52W%", width: 7, align: "right", format: "percent" },
24
34
  { id: "bid", label: "BID", width: 10, align: "right", format: "currency" },
25
35
  { id: "ask", label: "ASK", width: 10, align: "right", format: "currency" },
26
36
  { id: "spread", label: "SPREAD", width: 10, align: "right", format: "currency" },
37
+ { id: "spread_pct", label: "SPR%", width: 7, align: "right", format: "percent" },
38
+ { id: "bid_ask_size", label: "B/A SZ", width: 9, align: "right", format: "compact" },
27
39
  { id: "change", label: "CHG", width: 9, align: "right", format: "currency" },
28
40
  { id: "ext_hours", label: "EXT%", width: 8, align: "right", format: "percent" },
29
41
  { id: "dividend_yield", label: "DIV%", width: 7, align: "right", format: "percent" },
42
+ { id: "target", label: "TARGET", width: 10, align: "right", format: "currency" },
43
+ { id: "target_pct", label: "TARGET%", width: 8, align: "right", format: "percent" },
44
+ { id: "rating", label: "RATING", width: 7, align: "right", format: "number" },
45
+ { id: "ex_div", label: "EX-DIV", width: 7, align: "right" },
46
+ { id: "next_earn", label: "ERN", width: 7, align: "right" },
47
+ { id: "side", label: "SIDE", width: 5, align: "left" },
30
48
  { id: "shares", label: "SHARES", width: 9, align: "right", format: "number" },
31
49
  { id: "avg_cost", label: "AVG COST", width: 10, align: "right", format: "currency" },
32
50
  { id: "cost_basis", label: "COST", width: 10, align: "right", format: "compact" },
33
51
  { id: "mkt_value", label: "MKT VAL", width: 10, align: "right", format: "compact" },
52
+ { id: "weight", label: "WEIGHT", width: 8, align: "right", format: "percent" },
34
53
  { id: "day_pnl", label: "DAY", width: 10, align: "right", format: "compact" },
35
54
  { id: "pnl", label: "P&L", width: 10, align: "right", format: "compact" },
36
55
  { id: "pnl_pct", label: "P&L%", width: 8, align: "right", format: "percent" },
56
+ { id: "mark_delta", label: "MARK%", width: 8, align: "right", format: "percent" },
57
+ { id: "acq_date", label: "ACQ", width: 7, align: "right" },
58
+ { id: "held", label: "HELD", width: 6, align: "right" },
37
59
  ];
38
60
 
39
61
  const PORTFOLIO_COLUMNS_BY_ID = new Map(PORTFOLIO_COLUMN_DEFS.map((column) => [column.id, column]));
40
62
  const PORTFOLIO_ONLY_COLUMN_IDS = new Set([
63
+ "side",
41
64
  "shares",
42
65
  "avg_cost",
43
66
  "cost_basis",
44
67
  "mkt_value",
68
+ "weight",
45
69
  "day_pnl",
46
70
  "pnl",
47
71
  "pnl_pct",
72
+ "mark_delta",
73
+ "acq_date",
74
+ "held",
48
75
  ]);
49
76
  const COLLECTION_SCOPE_OPTIONS: PaneSettingOption[] = [
50
77
  {
@@ -32,7 +32,7 @@ function buildCellVersion(
32
32
  financials: TickerFinancials | undefined,
33
33
  context: ColumnContext,
34
34
  ): string {
35
- const latencyNow = column.id === "latency" ? context.now : 0;
35
+ const timeSensitiveNow = column.id === "latency" || column.id === "held" ? context.now : 0;
36
36
  return [
37
37
  column.id,
38
38
  objectVersion(ticker),
@@ -40,7 +40,9 @@ function buildCellVersion(
40
40
  objectVersion(context.exchangeRates),
41
41
  context.activeTab ?? "",
42
42
  context.baseCurrency,
43
- latencyNow,
43
+ context.portfolioTotalMarketValue ?? 0,
44
+ context.supplementalVersion ?? 0,
45
+ timeSensitiveNow,
44
46
  ].join("|");
45
47
  }
46
48
 
@@ -1,4 +1,4 @@
1
- import { useCallback, useEffect, useRef, useState } from "react";
1
+ import { useThrottledCommitValue } from "../../../react/use-throttled-commit-value";
2
2
 
3
3
  const FOLLOW_CURSOR_THROTTLE_MS = 150;
4
4
 
@@ -7,75 +7,17 @@ export function useThrottledCursorSymbol(
7
7
  setCommittedCursorSymbol: (symbol: string | null) => void,
8
8
  throttleMs = FOLLOW_CURSOR_THROTTLE_MS,
9
9
  ) {
10
- const [cursorSymbol, setCursorSymbolState] = useState<string | null>(committedCursorSymbol);
11
- const commitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
12
- const hasPendingCommitRef = useRef(false);
13
- const pendingCursorSymbolRef = useRef<string | null>(committedCursorSymbol);
14
- const appliedCursorSymbolRef = useRef<string | null>(committedCursorSymbol);
15
-
16
- const clearPendingCursorCommit = useCallback(() => {
17
- if (commitTimerRef.current) {
18
- clearTimeout(commitTimerRef.current);
19
- commitTimerRef.current = null;
20
- }
21
- hasPendingCommitRef.current = false;
22
- }, []);
23
-
24
- const commitCursorSymbol = useCallback((nextCursorSymbol?: string | null) => {
25
- const targetCursorSymbol = nextCursorSymbol ?? pendingCursorSymbolRef.current;
26
- clearPendingCursorCommit();
27
- pendingCursorSymbolRef.current = targetCursorSymbol;
28
-
29
- if (Object.is(appliedCursorSymbolRef.current, targetCursorSymbol)) {
30
- return;
31
- }
32
-
33
- appliedCursorSymbolRef.current = targetCursorSymbol;
34
- setCommittedCursorSymbol(targetCursorSymbol);
35
- }, [clearPendingCursorCommit, setCommittedCursorSymbol]);
36
-
37
- const scheduleCursorSymbol = useCallback((nextCursorSymbol: string | null, options?: { immediate?: boolean }) => {
38
- setCursorSymbolState((current) => (Object.is(current, nextCursorSymbol) ? current : nextCursorSymbol));
39
- pendingCursorSymbolRef.current = nextCursorSymbol;
40
-
41
- if (options?.immediate) {
42
- commitCursorSymbol(nextCursorSymbol);
43
- return;
44
- }
45
-
46
- clearPendingCursorCommit();
47
-
48
- if (Object.is(appliedCursorSymbolRef.current, nextCursorSymbol)) {
49
- return;
50
- }
51
-
52
- hasPendingCommitRef.current = true;
53
- commitTimerRef.current = setTimeout(() => {
54
- commitCursorSymbol();
55
- }, throttleMs);
56
- }, [clearPendingCursorCommit, commitCursorSymbol, throttleMs]);
57
-
58
- useEffect(() => {
59
- if (hasPendingCommitRef.current && Object.is(committedCursorSymbol, pendingCursorSymbolRef.current)) {
60
- clearPendingCursorCommit();
61
- }
62
-
63
- if (!hasPendingCommitRef.current) {
64
- appliedCursorSymbolRef.current = committedCursorSymbol;
65
- setCursorSymbolState((current) => (Object.is(current, committedCursorSymbol) ? current : committedCursorSymbol));
66
- }
67
- }, [clearPendingCursorCommit, committedCursorSymbol]);
68
-
69
- useEffect(() => () => {
70
- if (commitTimerRef.current) {
71
- clearTimeout(commitTimerRef.current);
72
- }
73
- }, []);
10
+ const {
11
+ value: cursorSymbol,
12
+ setValue,
13
+ flushValue,
14
+ cancelPendingValue,
15
+ } = useThrottledCommitValue(committedCursorSymbol, setCommittedCursorSymbol, throttleMs);
74
16
 
75
17
  return {
76
18
  cursorSymbol,
77
- setCursorSymbol: scheduleCursorSymbol,
78
- flushCursorSymbol: commitCursorSymbol,
79
- cancelPendingCursorSymbol: clearPendingCursorCommit,
19
+ setCursorSymbol: setValue,
20
+ flushCursorSymbol: flushValue,
21
+ cancelPendingCursorSymbol: cancelPendingValue,
80
22
  };
81
23
  }
@@ -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
+ }