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,170 @@
1
+ import { formatTimeAgo } from "../../../utils/format";
2
+ import {
3
+ getCachedSubstackArticleDetail,
4
+ getCachedSubstackHome,
5
+ getCachedSubstackPublicationFeed,
6
+ } from "./api/cache";
7
+ import type {
8
+ SubstackHomeData,
9
+ SubstackPublicationFeedPage,
10
+ } from "./api/types";
11
+ import { articleMatchesPublication } from "./normalize";
12
+ import type {
13
+ SubstackArticleDetail,
14
+ SubstackArticleSummary,
15
+ SubstackPublication,
16
+ } from "./types";
17
+
18
+ export interface LoadState<T> {
19
+ data: T | null;
20
+ loading: boolean;
21
+ error: string | null;
22
+ fetchedAt: number | null;
23
+ stale: boolean;
24
+ }
25
+
26
+ export type PublicationFeedState = LoadState<SubstackPublicationFeedPage> & {
27
+ loadingMore: boolean;
28
+ };
29
+
30
+ export type DetailState = LoadState<SubstackArticleDetail>;
31
+
32
+ export type ActiveFeedState = LoadState<SubstackArticleSummary[]> & {
33
+ loadingMore?: boolean;
34
+ hasMore?: boolean;
35
+ nextOffset?: number | null;
36
+ };
37
+
38
+ export function emptyLoadState<T>(): LoadState<T> {
39
+ return {
40
+ data: null,
41
+ loading: false,
42
+ error: null,
43
+ fetchedAt: null,
44
+ stale: false,
45
+ };
46
+ }
47
+
48
+ export function emptyPublicationFeedState(): PublicationFeedState {
49
+ return {
50
+ ...emptyLoadState<SubstackPublicationFeedPage>(),
51
+ loadingMore: false,
52
+ };
53
+ }
54
+
55
+ export function mergePublicationFeedPages(
56
+ current: SubstackPublicationFeedPage | null,
57
+ next: SubstackPublicationFeedPage,
58
+ ): SubstackPublicationFeedPage {
59
+ if (!current) return next;
60
+ const articlesById = new Map<string, SubstackArticleSummary>();
61
+ for (const article of current.items) articlesById.set(article.id, article);
62
+ for (const article of next.items) articlesById.set(article.id, article);
63
+ return {
64
+ items: [...articlesById.values()],
65
+ nextOffset: next.nextOffset,
66
+ hasMore: next.hasMore,
67
+ };
68
+ }
69
+
70
+ export function homeLoadStateFromCache(): LoadState<SubstackHomeData> {
71
+ const cached = getCachedSubstackHome();
72
+ if (!cached) return emptyLoadState<SubstackHomeData>();
73
+ return {
74
+ data: cached,
75
+ loading: false,
76
+ error: null,
77
+ fetchedAt: cached.fetchedAt,
78
+ stale: cached.stale,
79
+ };
80
+ }
81
+
82
+ export function publicationLoadStateFromCache(publication: SubstackPublication): PublicationFeedState | null {
83
+ const cached = getCachedSubstackPublicationFeed(publication);
84
+ if (!cached) return null;
85
+ return {
86
+ data: cached.data,
87
+ loading: false,
88
+ loadingMore: false,
89
+ error: null,
90
+ fetchedAt: cached.fetchedAt,
91
+ stale: cached.stale,
92
+ };
93
+ }
94
+
95
+ export function detailLoadStateFromCache(article: SubstackArticleSummary): DetailState | null {
96
+ const cached = getCachedSubstackArticleDetail(article);
97
+ if (!cached) return null;
98
+ return {
99
+ data: cached.data,
100
+ loading: false,
101
+ error: null,
102
+ fetchedAt: cached.fetchedAt,
103
+ stale: cached.stale,
104
+ };
105
+ }
106
+
107
+ export function errorMessage(error: unknown): string {
108
+ return error instanceof Error ? error.message : String(error);
109
+ }
110
+
111
+ export function tabLabel(label: string): string {
112
+ const compact = label.replace(/\s+/g, " ").trim();
113
+ return compact.length <= 24 ? compact : `${compact.slice(0, 21)}...`;
114
+ }
115
+
116
+ export function cacheStatusLabel(fetchedAt: number | null, stale: boolean): string | null {
117
+ if (!fetchedAt) return null;
118
+ return `${stale ? "stale " : "updated "}${formatTimeAgo(new Date(fetchedAt))}`;
119
+ }
120
+
121
+ export function activeFeedStateFromSources({
122
+ activePublication,
123
+ activePublicationTabId,
124
+ publicationFeeds,
125
+ home,
126
+ }: {
127
+ activePublication: SubstackPublication | null;
128
+ activePublicationTabId: string | null;
129
+ publicationFeeds: Record<string, PublicationFeedState>;
130
+ home: LoadState<SubstackHomeData>;
131
+ }): ActiveFeedState {
132
+ if (!activePublication || !activePublicationTabId) {
133
+ return {
134
+ data: home.data?.feed ?? null,
135
+ loading: home.loading,
136
+ error: home.error,
137
+ fetchedAt: home.fetchedAt,
138
+ stale: home.stale,
139
+ loadingMore: false,
140
+ hasMore: false,
141
+ nextOffset: null,
142
+ };
143
+ }
144
+
145
+ const publicationEntry = publicationFeeds[activePublicationTabId];
146
+ if (publicationEntry?.data) {
147
+ return {
148
+ data: publicationEntry.data.items,
149
+ loading: publicationEntry.loading,
150
+ loadingMore: publicationEntry.loadingMore,
151
+ hasMore: publicationEntry.data.hasMore,
152
+ nextOffset: publicationEntry.data.nextOffset,
153
+ error: publicationEntry.error,
154
+ fetchedAt: publicationEntry.fetchedAt,
155
+ stale: publicationEntry.stale,
156
+ };
157
+ }
158
+
159
+ const publicationFallbackRows = home.data?.feed.filter((article) => articleMatchesPublication(article, activePublication)) ?? [];
160
+ return {
161
+ data: publicationFallbackRows.length > 0 ? publicationFallbackRows : null,
162
+ loading: publicationEntry?.loading ?? false,
163
+ loadingMore: publicationEntry?.loadingMore ?? false,
164
+ hasMore: false,
165
+ nextOffset: null,
166
+ error: publicationEntry?.error ?? null,
167
+ fetchedAt: publicationEntry?.fetchedAt ?? home.fetchedAt,
168
+ stale: publicationEntry?.stale ?? home.stale,
169
+ };
170
+ }
@@ -0,0 +1,500 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
2
+ import { Box, useRendererHost, type ScrollBoxRenderable } from "../../../ui";
3
+ import {
4
+ EmptyState,
5
+ Spinner,
6
+ type DataTableKeyEvent,
7
+ } from "../../../components";
8
+ import type { PaneProps } from "../../../types/plugin";
9
+ import { isPlainKey } from "../../../utils/keyboard";
10
+ import { useDebouncedPluginPaneState, usePluginPaneState } from "../../runtime";
11
+ import {
12
+ clearSubstackAuth,
13
+ getStoredSubstackAuth,
14
+ } from "./api/store";
15
+ import {
16
+ loadSubstackArticleDetail,
17
+ loadSubstackHome,
18
+ loadSubstackPublicationFeed,
19
+ } from "./api/loaders";
20
+ import {
21
+ SubstackAuthError,
22
+ type SubstackAuthState,
23
+ type SubstackCachedData,
24
+ type SubstackHomeData,
25
+ type SubstackPublicationFeedPage,
26
+ } from "./api/types";
27
+ import { ArticleDetail } from "./article-detail";
28
+ import { SubstackArticleStack } from "./article-stack";
29
+ import { SubstackFeedTabs } from "./feed-tabs";
30
+ import { SubstackLoginView } from "./login-view";
31
+ import {
32
+ buildSubstackColumns,
33
+ isSubstackSortColumnId,
34
+ nextSubstackSort,
35
+ publicationFromTabId,
36
+ sortedSubstackArticles,
37
+ tabIdForPublication,
38
+ } from "./table";
39
+ import {
40
+ SUBSTACK_FEED_TAB_ID,
41
+ type SubstackArticleDetail,
42
+ type SubstackArticleSummary,
43
+ type SubstackPublication,
44
+ type SubstackSortColumnId,
45
+ type SubstackSortDirection,
46
+ } from "./types";
47
+ import { useSubstackPaneFooter } from "./pane-footer";
48
+ import {
49
+ activeFeedStateFromSources,
50
+ detailLoadStateFromCache,
51
+ emptyLoadState,
52
+ emptyPublicationFeedState,
53
+ errorMessage,
54
+ homeLoadStateFromCache,
55
+ mergePublicationFeedPages,
56
+ publicationLoadStateFromCache,
57
+ type DetailState,
58
+ type LoadState,
59
+ type PublicationFeedState,
60
+ } from "./pane-state";
61
+
62
+ const PUBLICATION_LOAD_MORE_THRESHOLD_ROWS = 8;
63
+
64
+ export function SubstackPane({ focused, width, height }: PaneProps) {
65
+ const rendererHost = useRendererHost();
66
+ const [auth, setAuth] = useState<SubstackAuthState | null>(() => getStoredSubstackAuth());
67
+ const [home, setHome] = useState<LoadState<SubstackHomeData>>(homeLoadStateFromCache);
68
+ const [publicationFeeds, setPublicationFeeds] = useState<Record<string, PublicationFeedState>>({});
69
+ const [details, setDetails] = useState<Record<string, DetailState>>({});
70
+ const [activeTab, setActiveTab] = usePluginPaneState<string>("activeTab", SUBSTACK_FEED_TAB_ID);
71
+ const [selectedArticleId, setSelectedArticleId] = useDebouncedPluginPaneState<string | null>("selectedArticleId", null);
72
+ const [detailOpen, setDetailOpen] = usePluginPaneState<boolean>("detailOpen", false);
73
+ const [sort, setSort] = useState<{ columnId: SubstackSortColumnId; direction: SubstackSortDirection }>({
74
+ columnId: "published",
75
+ direction: "desc",
76
+ });
77
+ const homeFetchGenRef = useRef(0);
78
+ const publicationFetchGenRef = useRef<Record<string, number>>({});
79
+ const detailFetchGenRef = useRef(0);
80
+ const tableScrollRef = useRef<ScrollBoxRenderable | null>(null);
81
+ const detailScrollRef = useRef<ScrollBoxRenderable | null>(null);
82
+
83
+ const handleAuthFailure = useCallback((error: unknown) => {
84
+ if (!(error instanceof SubstackAuthError)) return false;
85
+ clearSubstackAuth();
86
+ setAuth(null);
87
+ setDetailOpen(false);
88
+ setHome((current) => ({
89
+ ...current,
90
+ loading: false,
91
+ error: error.message,
92
+ }));
93
+ return true;
94
+ }, []);
95
+
96
+ const loadHome = useCallback((force = false) => {
97
+ if (!auth) return;
98
+ homeFetchGenRef.current += 1;
99
+ const gen = homeFetchGenRef.current;
100
+ setHome((current) => ({
101
+ ...current,
102
+ loading: !current.data || force,
103
+ error: null,
104
+ }));
105
+ loadSubstackHome(force)
106
+ .then((data) => {
107
+ if (homeFetchGenRef.current !== gen) return;
108
+ setHome({
109
+ data,
110
+ loading: false,
111
+ error: null,
112
+ fetchedAt: data.fetchedAt,
113
+ stale: data.stale,
114
+ });
115
+ })
116
+ .catch((loadError) => {
117
+ if (homeFetchGenRef.current !== gen) return;
118
+ if (handleAuthFailure(loadError)) return;
119
+ setHome((current) => ({
120
+ ...current,
121
+ loading: false,
122
+ error: errorMessage(loadError),
123
+ }));
124
+ });
125
+ }, [auth, handleAuthFailure]);
126
+
127
+ useEffect(() => {
128
+ if (!auth) return;
129
+ loadHome(false);
130
+ }, [auth, loadHome]);
131
+
132
+ const subscriptions = home.data?.subscriptions ?? [];
133
+ const activePublication = useMemo(
134
+ () => publicationFromTabId(activeTab, subscriptions),
135
+ [activeTab, subscriptions],
136
+ );
137
+ const activePublicationTabId = activePublication ? tabIdForPublication(activePublication) : null;
138
+
139
+ useEffect(() => {
140
+ if (!home.data || activeTab === SUBSTACK_FEED_TAB_ID) return;
141
+ if (!publicationFromTabId(activeTab, subscriptions)) {
142
+ setActiveTab(SUBSTACK_FEED_TAB_ID);
143
+ setDetailOpen(false);
144
+ }
145
+ }, [activeTab, home.data, setActiveTab, setDetailOpen, subscriptions]);
146
+
147
+ const loadPublication = useCallback((publication: SubstackPublication, force = false, offset = 0) => {
148
+ const tabId = tabIdForPublication(publication);
149
+ const isLoadMore = offset > 0 && !force;
150
+ const gen = (publicationFetchGenRef.current[tabId] ?? 0) + 1;
151
+ publicationFetchGenRef.current[tabId] = gen;
152
+ setPublicationFeeds((current) => ({
153
+ ...current,
154
+ [tabId]: {
155
+ ...(current[tabId] ?? emptyPublicationFeedState()),
156
+ loading: !isLoadMore,
157
+ loadingMore: isLoadMore,
158
+ error: null,
159
+ },
160
+ }));
161
+ loadSubstackPublicationFeed(publication, force, offset)
162
+ .then((entry: SubstackCachedData<SubstackPublicationFeedPage>) => {
163
+ if (publicationFetchGenRef.current[tabId] !== gen) return;
164
+ setPublicationFeeds((current) => ({
165
+ ...current,
166
+ [tabId]: (() => {
167
+ const previous = current[tabId] ?? emptyPublicationFeedState();
168
+ return {
169
+ data: isLoadMore ? mergePublicationFeedPages(previous.data, entry.data) : entry.data,
170
+ loading: false,
171
+ loadingMore: false,
172
+ error: null,
173
+ fetchedAt: entry.fetchedAt,
174
+ stale: entry.stale,
175
+ };
176
+ })(),
177
+ }));
178
+ })
179
+ .catch((loadError) => {
180
+ if (publicationFetchGenRef.current[tabId] !== gen) return;
181
+ if (handleAuthFailure(loadError)) return;
182
+ setPublicationFeeds((current) => ({
183
+ ...current,
184
+ [tabId]: {
185
+ ...(current[tabId] ?? emptyPublicationFeedState()),
186
+ loading: false,
187
+ loadingMore: false,
188
+ error: errorMessage(loadError),
189
+ },
190
+ }));
191
+ });
192
+ }, [handleAuthFailure]);
193
+
194
+ useEffect(() => {
195
+ if (!activePublication || !activePublicationTabId) return;
196
+ const entry = publicationFeeds[activePublicationTabId];
197
+ if (entry?.data || entry?.loading || entry?.loadingMore || entry?.error) return;
198
+ const cached = publicationLoadStateFromCache(activePublication);
199
+ if (cached) {
200
+ setPublicationFeeds((current) => ({
201
+ ...current,
202
+ [activePublicationTabId]: cached,
203
+ }));
204
+ return;
205
+ }
206
+ loadPublication(activePublication, false);
207
+ }, [activePublication, activePublicationTabId, loadPublication, publicationFeeds]);
208
+
209
+ const activeFeedState = activeFeedStateFromSources({
210
+ activePublication,
211
+ activePublicationTabId,
212
+ publicationFeeds,
213
+ home,
214
+ });
215
+
216
+ const loadMoreActivePublicationRows = useCallback(() => {
217
+ if (!activePublication || !activePublicationTabId || detailOpen) return;
218
+ const entry = publicationFeeds[activePublicationTabId];
219
+ const page = entry?.data;
220
+ if (!page || entry.loading || entry.loadingMore || entry.error || !page.hasMore || page.nextOffset == null) return;
221
+ const scrollBox = tableScrollRef.current;
222
+ if (!scrollBox?.viewport) return;
223
+ const visibleBottom = scrollBox.scrollTop + scrollBox.viewport.height;
224
+ const remainingRows = page.items.length - visibleBottom;
225
+ if (remainingRows > PUBLICATION_LOAD_MORE_THRESHOLD_ROWS) return;
226
+ loadPublication(activePublication, false, page.nextOffset);
227
+ }, [activePublication, activePublicationTabId, detailOpen, loadPublication, publicationFeeds]);
228
+
229
+ const sortedRows = useMemo(() => (
230
+ sortedSubstackArticles(activeFeedState.data ?? [], sort)
231
+ ), [activeFeedState.data, sort]);
232
+
233
+ useEffect(() => {
234
+ loadMoreActivePublicationRows();
235
+ }, [loadMoreActivePublicationRows, sortedRows.length]);
236
+
237
+ const selectedIndex = selectedArticleId
238
+ ? sortedRows.findIndex((article) => article.id === selectedArticleId)
239
+ : -1;
240
+ const selectedArticle = sortedRows[selectedIndex >= 0 ? selectedIndex : 0] ?? null;
241
+
242
+ useEffect(() => {
243
+ if (sortedRows.length === 0) {
244
+ if (!home.loading && !activeFeedState.loading && !activeFeedState.loadingMore) {
245
+ if (selectedArticleId !== null) setSelectedArticleId(null);
246
+ setDetailOpen(false);
247
+ }
248
+ return;
249
+ }
250
+ if (!selectedArticleId || selectedIndex < 0) {
251
+ setSelectedArticleId(sortedRows[0]!.id);
252
+ }
253
+ }, [
254
+ activeFeedState.loading,
255
+ activeFeedState.loadingMore,
256
+ home.loading,
257
+ selectedArticleId,
258
+ selectedIndex,
259
+ setDetailOpen,
260
+ setSelectedArticleId,
261
+ sortedRows,
262
+ ]);
263
+
264
+ const loadSelectedDetail = useCallback((article: SubstackArticleSummary, force = false) => {
265
+ detailFetchGenRef.current += 1;
266
+ const gen = detailFetchGenRef.current;
267
+ setDetails((current) => ({
268
+ ...current,
269
+ [article.id]: {
270
+ ...(current[article.id] ?? emptyLoadState<SubstackArticleDetail>()),
271
+ loading: true,
272
+ error: null,
273
+ },
274
+ }));
275
+ loadSubstackArticleDetail(article, force)
276
+ .then((entry) => {
277
+ if (detailFetchGenRef.current !== gen) return;
278
+ setDetails((current) => ({
279
+ ...current,
280
+ [article.id]: {
281
+ data: entry.data,
282
+ loading: false,
283
+ error: null,
284
+ fetchedAt: entry.fetchedAt,
285
+ stale: entry.stale,
286
+ },
287
+ }));
288
+ })
289
+ .catch((loadError) => {
290
+ if (detailFetchGenRef.current !== gen) return;
291
+ if (handleAuthFailure(loadError)) return;
292
+ setDetails((current) => ({
293
+ ...current,
294
+ [article.id]: {
295
+ ...(current[article.id] ?? emptyLoadState<SubstackArticleDetail>()),
296
+ loading: false,
297
+ error: errorMessage(loadError),
298
+ },
299
+ }));
300
+ });
301
+ }, [handleAuthFailure]);
302
+
303
+ useEffect(() => {
304
+ if (!detailOpen || !selectedArticle) return;
305
+ const existing = details[selectedArticle.id];
306
+ if (existing?.data || existing?.loading) return;
307
+ const cached = detailLoadStateFromCache(selectedArticle);
308
+ if (cached) {
309
+ setDetails((current) => ({
310
+ ...current,
311
+ [selectedArticle.id]: cached,
312
+ }));
313
+ if (!cached.stale) return;
314
+ }
315
+ loadSelectedDetail(selectedArticle, false);
316
+ }, [detailOpen, details, loadSelectedDetail, selectedArticle]);
317
+
318
+ useEffect(() => {
319
+ if (!detailOpen) return;
320
+ if (detailScrollRef.current) detailScrollRef.current.scrollTop = 0;
321
+ }, [detailOpen, selectedArticle?.id]);
322
+
323
+ const selectTab = useCallback((tabId: string) => {
324
+ setActiveTab(tabId);
325
+ setDetailOpen(false);
326
+ }, [setActiveTab, setDetailOpen]);
327
+
328
+ const refreshActive = useCallback(() => {
329
+ if (activePublication) {
330
+ loadPublication(activePublication, true);
331
+ return;
332
+ }
333
+ loadHome(true);
334
+ }, [activePublication, loadHome, loadPublication]);
335
+
336
+ const openSelectedArticle = useCallback(() => {
337
+ if (!selectedArticle?.url) return;
338
+ void rendererHost.openExternal(selectedArticle.url);
339
+ }, [rendererHost, selectedArticle]);
340
+
341
+ const handleLogin = useCallback((nextAuth: SubstackAuthState) => {
342
+ setAuth(nextAuth);
343
+ setActiveTab(SUBSTACK_FEED_TAB_ID);
344
+ }, [setActiveTab]);
345
+
346
+ const handleHeaderClick = useCallback((columnId: string) => {
347
+ if (!isSubstackSortColumnId(columnId)) return;
348
+ setSort((current) => nextSubstackSort(current, columnId));
349
+ }, []);
350
+
351
+ const scrollDetailBy = useCallback((delta: number) => {
352
+ const scrollBox = detailScrollRef.current;
353
+ if (!scrollBox?.viewport) return;
354
+ const maxScrollTop = Math.max(0, scrollBox.scrollHeight - scrollBox.viewport.height);
355
+ scrollBox.scrollTop = Math.max(0, Math.min(maxScrollTop, scrollBox.scrollTop + delta));
356
+ }, []);
357
+
358
+ const handleRootKeyDown = useCallback((event: DataTableKeyEvent) => {
359
+ if (isPlainKey(event, "r")) {
360
+ event.preventDefault?.();
361
+ event.stopPropagation?.();
362
+ refreshActive();
363
+ return true;
364
+ }
365
+ if (isPlainKey(event, "o")) {
366
+ event.preventDefault?.();
367
+ event.stopPropagation?.();
368
+ openSelectedArticle();
369
+ return true;
370
+ }
371
+ return false;
372
+ }, [openSelectedArticle, refreshActive]);
373
+
374
+ const handleDetailKeyDown = useCallback((event: DataTableKeyEvent) => {
375
+ if (isPlainKey(event, "j", "down")) {
376
+ event.preventDefault?.();
377
+ event.stopPropagation?.();
378
+ scrollDetailBy(1);
379
+ return true;
380
+ }
381
+ if (isPlainKey(event, "k", "up")) {
382
+ event.preventDefault?.();
383
+ event.stopPropagation?.();
384
+ scrollDetailBy(-1);
385
+ return true;
386
+ }
387
+ if (isPlainKey(event, "o")) {
388
+ event.preventDefault?.();
389
+ event.stopPropagation?.();
390
+ openSelectedArticle();
391
+ return true;
392
+ }
393
+ if (isPlainKey(event, "r") && selectedArticle) {
394
+ event.preventDefault?.();
395
+ event.stopPropagation?.();
396
+ loadSelectedDetail(selectedArticle, true);
397
+ return true;
398
+ }
399
+ return false;
400
+ }, [loadSelectedDetail, openSelectedArticle, scrollDetailBy, selectedArticle]);
401
+
402
+ const includePublication = !activePublication;
403
+ const columns = useMemo(() => buildSubstackColumns(width, includePublication), [includePublication, width]);
404
+ const activeDetail = selectedArticle ? details[selectedArticle.id] ?? emptyLoadState<SubstackArticleDetail>() : emptyLoadState<SubstackArticleDetail>();
405
+ useSubstackPaneFooter({
406
+ auth,
407
+ detailOpen,
408
+ activeFeedState,
409
+ activeDetail,
410
+ selectedArticle,
411
+ refreshActive,
412
+ openSelectedArticle,
413
+ });
414
+
415
+ if (!auth) {
416
+ return (
417
+ <SubstackLoginView
418
+ width={width}
419
+ height={height}
420
+ focused={focused}
421
+ onLogin={handleLogin}
422
+ />
423
+ );
424
+ }
425
+
426
+ const tabs = (
427
+ <SubstackFeedTabs
428
+ subscriptions={subscriptions}
429
+ activeTab={activeTab}
430
+ focused={focused}
431
+ detailOpen={detailOpen}
432
+ onSelect={selectTab}
433
+ />
434
+ );
435
+
436
+ if (home.loading && !home.data) {
437
+ return (
438
+ <Box flexDirection="column" width={width} height={height}>
439
+ {tabs}
440
+ <Box flexGrow={1} justifyContent="center" alignItems="center">
441
+ <Spinner label="Loading Substack..." />
442
+ </Box>
443
+ </Box>
444
+ );
445
+ }
446
+
447
+ if (home.error && !home.data) {
448
+ return (
449
+ <Box flexDirection="column" width={width} height={height}>
450
+ {tabs}
451
+ <Box padding={1}>
452
+ <EmptyState title="Substack unavailable." message={home.error} hint="Press r to retry." />
453
+ </Box>
454
+ </Box>
455
+ );
456
+ }
457
+
458
+ const detailContent = selectedArticle ? (
459
+ <ArticleDetail
460
+ article={selectedArticle}
461
+ detail={activeDetail.data}
462
+ width={width}
463
+ loading={activeDetail.loading}
464
+ error={activeDetail.error}
465
+ scrollRef={detailScrollRef}
466
+ onOpenArticle={openSelectedArticle}
467
+ />
468
+ ) : null;
469
+
470
+ return (
471
+ <Box flexDirection="column" width={width} height={height}>
472
+ {tabs}
473
+ <SubstackArticleStack
474
+ focused={focused}
475
+ detailOpen={detailOpen}
476
+ onBack={() => setDetailOpen(false)}
477
+ selectedArticle={selectedArticle}
478
+ detailContent={detailContent}
479
+ selectedArticleId={selectedArticleId}
480
+ onActivate={(article) => {
481
+ setSelectedArticleId(article.id, { immediate: true });
482
+ setDetailOpen(true);
483
+ }}
484
+ onSelectionChange={(id) => setSelectedArticleId(id)}
485
+ onRootKeyDown={handleRootKeyDown}
486
+ onDetailKeyDown={handleDetailKeyDown}
487
+ onBodyScrollActivity={loadMoreActivePublicationRows}
488
+ tableScrollRef={tableScrollRef}
489
+ width={width}
490
+ height={height}
491
+ columns={columns}
492
+ sortedRows={sortedRows}
493
+ activePublication={activePublication}
494
+ activeFeedState={activeFeedState}
495
+ sort={sort}
496
+ onHeaderClick={handleHeaderClick}
497
+ />
498
+ </Box>
499
+ );
500
+ }