gloomberb 0.17.4 → 0.17.5

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 (119) hide show
  1. package/package.json +1 -1
  2. package/patches/electrobun@1.18.1.patch +80 -0
  3. package/src/api-client/data.ts +4 -1
  4. package/src/api-client/research-activity.ts +10 -3
  5. package/src/app/pane-runtime/ticker-open-runtime.ts +2 -1
  6. package/src/app/runtime/update.ts +50 -3
  7. package/src/app.tsx +2 -0
  8. package/src/ask-gloom/pane-context.ts +11 -1
  9. package/src/cli/commands/overview.ts +2 -2
  10. package/src/cli/commands/system.ts +19 -1
  11. package/src/cli/glossary.ts +34 -4
  12. package/src/cli/index.ts +6 -0
  13. package/src/cli/pane-functions/function-help.ts +1 -1
  14. package/src/cli/pane-functions/market-session.ts +18 -3
  15. package/src/components/chart/composite/format.ts +7 -0
  16. package/src/components/chart/composite/legend.tsx +8 -11
  17. package/src/components/command-bar/commands/direct/index.ts +47 -0
  18. package/src/components/command-bar/commands/registry.ts +8 -0
  19. package/src/components/command-bar/panel/prompt-binding.ts +8 -0
  20. package/src/components/command-bar/routes/root/command-items.ts +16 -1
  21. package/src/components/command-bar/surface/index.tsx +2 -1
  22. package/src/components/command-bar/workflow/ops.ts +4 -3
  23. package/src/components/form-modal/confirm.tsx +1 -0
  24. package/src/components/form-modal/request.ts +2 -0
  25. package/src/components/layout/detached-pane-shell.tsx +6 -1
  26. package/src/components/layout/pane/sidebar.tsx +24 -11
  27. package/src/components/layout/shell/index.tsx +5 -1
  28. package/src/components/layout/shell/menu.ts +8 -0
  29. package/src/components/layout/shell/pane/capture.ts +29 -12
  30. package/src/components/ui/confirm-dialog.tsx +30 -4
  31. package/src/components/ui/fields.tsx +5 -0
  32. package/src/data/config/store/normalize.ts +8 -0
  33. package/src/i18n/es.ts +2 -0
  34. package/src/i18n/ja.ts +2 -0
  35. package/src/i18n/ko.ts +2 -0
  36. package/src/i18n/zh-cn.ts +2 -0
  37. package/src/i18n/zh-tw.ts +2 -0
  38. package/src/market-data/coordinator/events.ts +5 -1
  39. package/src/market-data/coordinator/index.ts +4 -0
  40. package/src/market-data/coordinator/quotes.ts +4 -0
  41. package/src/market-data/hooks.tsx +168 -29
  42. package/src/plugins/builtin/broker-manager/detail.tsx +2 -2
  43. package/src/plugins/builtin/broker-manager/model.ts +2 -2
  44. package/src/plugins/builtin/chart-composer/headless.ts +2 -1
  45. package/src/plugins/builtin/chart-composer/pane.tsx +20 -2
  46. package/src/plugins/builtin/chat/content/footer.tsx +5 -2
  47. package/src/plugins/builtin/chat/controller/index.ts +20 -0
  48. package/src/plugins/builtin/chat/controller/messages.ts +14 -2
  49. package/src/plugins/builtin/chat/controller/state.ts +15 -0
  50. package/src/plugins/builtin/chat/controller/unread-people.ts +240 -0
  51. package/src/plugins/builtin/chat/controller/view.ts +2 -0
  52. package/src/plugins/builtin/chat/index.tsx +1 -1
  53. package/src/plugins/builtin/chat/share/flow.tsx +18 -9
  54. package/src/plugins/builtin/chat/unread-badge.ts +38 -7
  55. package/src/plugins/builtin/cloud/askg/pane-attachment.ts +2 -3
  56. package/src/plugins/builtin/futures-curve/headless.ts +13 -6
  57. package/src/plugins/builtin/futures-curve/index.tsx +3 -3
  58. package/src/plugins/builtin/futures-curve/model.ts +64 -4
  59. package/src/plugins/builtin/futures-curve/pane.tsx +11 -4
  60. package/src/plugins/builtin/kelly-sizer/headless.ts +1 -1
  61. package/src/plugins/builtin/kelly-sizer/history.ts +5 -3
  62. package/src/plugins/builtin/kelly-sizer/pane.tsx +1 -1
  63. package/src/plugins/builtin/macro-day/release-table.ts +3 -3
  64. package/src/plugins/builtin/mna/model.ts +2 -1
  65. package/src/plugins/builtin/options-positioning/model.ts +14 -0
  66. package/src/plugins/builtin/options-positioning/pane.tsx +17 -10
  67. package/src/plugins/builtin/perps/client.ts +77 -6
  68. package/src/plugins/builtin/perps/headless.ts +25 -14
  69. package/src/plugins/builtin/perps/index.tsx +1 -1
  70. package/src/plugins/builtin/perps/model.ts +32 -0
  71. package/src/plugins/builtin/perps/pane.tsx +5 -4
  72. package/src/plugins/builtin/rate-path/model.ts +1 -1
  73. package/src/plugins/builtin/rate-path/pane.tsx +2 -1
  74. package/src/plugins/builtin/research/event-sources.ts +1 -1
  75. package/src/plugins/builtin/research/relative-valuation-model.ts +6 -0
  76. package/src/plugins/builtin/research/relative-valuation-pane.tsx +3 -3
  77. package/src/plugins/builtin/sectors/client.ts +46 -19
  78. package/src/plugins/builtin/sectors/sector-model.ts +22 -10
  79. package/src/plugins/builtin/thirteenf/api.ts +60 -43
  80. package/src/plugins/builtin/thirteenf/data.ts +76 -35
  81. package/src/plugins/builtin/thirteenf/headless.ts +2 -0
  82. package/src/plugins/builtin/thirteenf/pane.tsx +10 -2
  83. package/src/plugins/builtin/yield-curve/curves.ts +15 -1
  84. package/src/plugins/builtin/yield-curve/headless.ts +17 -7
  85. package/src/plugins/builtin/yield-curve/history.ts +2 -0
  86. package/src/plugins/builtin/yield-curve/index.tsx +23 -18
  87. package/src/plugins/builtin/yield-curve/series.ts +19 -9
  88. package/src/plugins/builtin/yield-curve/treasury-data.ts +2 -0
  89. package/src/plugins/builtin/yield-curve/world.ts +4 -0
  90. package/src/plugins/registry/host-actions.ts +3 -0
  91. package/src/renderers/browser/app-services.ts +7 -1
  92. package/src/renderers/browser/config-host.ts +55 -2
  93. package/src/renderers/browser/first-screen.ts +193 -0
  94. package/src/renderers/browser/main.tsx +59 -7
  95. package/src/renderers/dom/desktop/controls.tsx +2 -0
  96. package/src/renderers/electrobun/bun/application-menu/index.ts +1 -0
  97. package/src/renderers/electrobun/bun/desktop/backend-requests.ts +3 -0
  98. package/src/renderers/electrobun/bun/desktop/update.ts +115 -12
  99. package/src/renderers/electrobun/shared/protocol.ts +3 -2
  100. package/src/renderers/electrobun/view/update-host.ts +2 -2
  101. package/src/shares/bug-report-dialog.tsx +162 -0
  102. package/src/shares/bug-report-flow.tsx +84 -0
  103. package/src/shares/bug-report.ts +42 -0
  104. package/src/shares/capture-flow.ts +19 -0
  105. package/src/shares/capture-preview.tsx +129 -0
  106. package/src/shares/chat.ts +10 -1
  107. package/src/shares/dialog.tsx +16 -91
  108. package/src/shares/flow.tsx +12 -16
  109. package/src/shares/personal-pane.ts +35 -1
  110. package/src/telemetry/usage-counts.ts +16 -0
  111. package/src/tickers/open-target.ts +61 -11
  112. package/src/time-series/resolve.ts +21 -6
  113. package/src/time-series/spec.ts +8 -0
  114. package/src/types/config.ts +14 -0
  115. package/src/updater/channel.ts +51 -0
  116. package/src/updater/github-releases.ts +85 -16
  117. package/src/updater/index.ts +125 -16
  118. package/src/utils/datetime-format.ts +7 -1
  119. package/src/utils/semver.ts +47 -2
@@ -135,6 +135,125 @@ function buildTickerFinancialsKeys(tickers: TickerRecord[], options: TickerInstr
135
135
  return keys;
136
136
  }
137
137
 
138
+ /**
139
+ * A new object so memo dependencies move, over the map that already holds
140
+ * every unchanged entry. Copying that map would visit every instrument.
141
+ */
142
+ class RetainedMapView<K, V> extends Map<K, V> {
143
+ constructor(private readonly backing: Map<K, V>) {
144
+ super();
145
+ }
146
+
147
+ override get size(): number {
148
+ return this.backing.size;
149
+ }
150
+
151
+ override get(key: K): V | undefined {
152
+ return this.backing.get(key);
153
+ }
154
+
155
+ override has(key: K): boolean {
156
+ return this.backing.has(key);
157
+ }
158
+
159
+ override forEach(callback: (value: V, key: K, map: Map<K, V>) => void, thisArg?: unknown): void {
160
+ this.backing.forEach((value, key) => {
161
+ callback.call(thisArg, value, key, this);
162
+ });
163
+ }
164
+
165
+ override keys(): MapIterator<K> {
166
+ return this.backing.keys();
167
+ }
168
+
169
+ override values(): MapIterator<V> {
170
+ return this.backing.values();
171
+ }
172
+
173
+ override entries(): MapIterator<[K, V]> {
174
+ return this.backing.entries();
175
+ }
176
+
177
+ override [Symbol.iterator](): MapIterator<[K, V]> {
178
+ return this.backing[Symbol.iterator]();
179
+ }
180
+ }
181
+
182
+ interface PatchedMapSlot<K, V> {
183
+ key: K;
184
+ versionKeys: readonly string[];
185
+ read: () => V | null | undefined;
186
+ }
187
+
188
+ interface PatchedMapState<K, V> {
189
+ signature: string;
190
+ coordinator: SharedCoordinator | null;
191
+ slots: Array<PatchedMapSlot<K, V>>;
192
+ versions: number[];
193
+ backing: Map<K, V>;
194
+ view: Map<K, V>;
195
+ }
196
+
197
+ function slotVersion(
198
+ slot: PatchedMapSlot<unknown, unknown>,
199
+ versionOf: ((key: string) => number) | null,
200
+ aggregate: number,
201
+ ): number {
202
+ if (!versionOf) return aggregate;
203
+ let version = 0;
204
+ for (const key of slot.versionKeys) version += versionOf(key);
205
+ return version;
206
+ }
207
+
208
+ function usePatchedMap<K, V>(
209
+ signature: string,
210
+ coordinator: SharedCoordinator | null,
211
+ aggregateVersion: number,
212
+ versionOf: ((key: string) => number) | null,
213
+ createSlots: () => Array<PatchedMapSlot<K, V>>,
214
+ ): Map<K, V> {
215
+ const stateRef = useRef<PatchedMapState<K, V> | null>(null);
216
+ let state = stateRef.current;
217
+ if (!state || state.signature !== signature || state.coordinator !== coordinator) {
218
+ const slots = createSlots();
219
+ const backing = new Map<K, V>();
220
+ const versions: number[] = [];
221
+ for (const slot of slots) {
222
+ const value = slot.read();
223
+ if (value != null) backing.set(slot.key, value);
224
+ versions.push(slotVersion(slot, versionOf, aggregateVersion));
225
+ }
226
+ const view = new RetainedMapView(backing);
227
+ state = { signature, coordinator, slots, versions, backing, view };
228
+ stateRef.current = state;
229
+ return view;
230
+ }
231
+
232
+ let changed = false;
233
+ for (let index = 0; index < state.slots.length; index += 1) {
234
+ const slot = state.slots[index]!;
235
+ const version = slotVersion(slot, versionOf, aggregateVersion);
236
+ if (version === state.versions[index]) continue;
237
+ state.versions[index] = version;
238
+ const value = slot.read();
239
+ if (value == null) {
240
+ if (state.backing.delete(slot.key)) changed = true;
241
+ } else if (state.backing.get(slot.key) !== value) {
242
+ state.backing.set(slot.key, value);
243
+ changed = true;
244
+ }
245
+ }
246
+ if (!changed) return state.view;
247
+ const view = new RetainedMapView(state.backing);
248
+ state.view = view;
249
+ return view;
250
+ }
251
+
252
+ function versionOfCoordinator(coordinator: SharedCoordinator | null): ((key: string) => number) | null {
253
+ if (!coordinator || typeof coordinator.getKeyVersion !== "function") return null;
254
+ return (key) => coordinator.getKeyVersion(key);
255
+ }
256
+
138
257
  export function useTickerFinancialsMap(
139
258
  tickers: TickerRecord[],
140
259
  options: TickerInstrumentOptions = {},
@@ -145,19 +264,30 @@ export function useTickerFinancialsMap(
145
264
  const subscriptionKeys = useMemo(() => buildTickerFinancialsKeys(tickers, options), [optionsKey, tickerKey]);
146
265
  const keysVersion = useCoordinatorKeysVersion(subscriptionKeys);
147
266
 
148
- return useMemo(() => {
149
- if (!coordinator) return new Map<string, TickerFinancials>();
150
- const result = new Map<string, TickerFinancials>();
151
- for (const ticker of tickers) {
152
- const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
153
- if (!instrument) continue;
154
- const financials = coordinator.getTickerFinancialsSync(instrument);
155
- if (financials) {
156
- result.set(ticker.metadata.ticker, financials);
267
+ return usePatchedMap(
268
+ `${tickerKey}\0${optionsKey}`,
269
+ coordinator,
270
+ keysVersion,
271
+ versionOfCoordinator(coordinator),
272
+ () => {
273
+ if (!coordinator) return [];
274
+ const slots: Array<PatchedMapSlot<string, TickerFinancials>> = [];
275
+ for (const ticker of tickers) {
276
+ const instrument = instrumentFromTicker(ticker, ticker.metadata.ticker, options);
277
+ if (!instrument) continue;
278
+ slots.push({
279
+ key: ticker.metadata.ticker,
280
+ versionKeys: [
281
+ buildSnapshotKey(instrument),
282
+ buildQuoteKey(instrument),
283
+ buildChartKey(createBaselineChartRequest(instrument)),
284
+ ],
285
+ read: () => coordinator.getTickerFinancialsSync(instrument),
286
+ });
157
287
  }
158
- }
159
- return result;
160
- }, [coordinator, keysVersion, optionsKey, tickerKey, subscriptionKeys]);
288
+ return slots;
289
+ },
290
+ );
161
291
  }
162
292
 
163
293
  export function useQuoteEntry(symbol: string | null | undefined, ticker: TickerRecord | null | undefined): QueryEntry<Quote> | null {
@@ -197,15 +327,23 @@ export function useQuoteEntries(
197
327
  const keysVersion = useCoordinatorKeysVersion(keys);
198
328
  const coordinator = getSharedMarketDataCoordinator();
199
329
 
200
- return useMemo(() => {
201
- if (!coordinator) return new Map<string, QueryEntry<Quote>>();
202
- return new Map(
203
- instruments.map((instrument) => [
204
- buildQuoteKey(instrument),
205
- coordinator.getQuoteEntry(instrument),
206
- ]),
207
- );
208
- }, [coordinator, instrumentKey, keysVersion]);
330
+ return usePatchedMap(
331
+ instrumentKey,
332
+ coordinator,
333
+ keysVersion,
334
+ versionOfCoordinator(coordinator),
335
+ () => {
336
+ if (!coordinator) return [];
337
+ return instruments.map((instrument) => {
338
+ const key = buildQuoteKey(instrument);
339
+ return {
340
+ key,
341
+ versionKeys: [key],
342
+ read: () => coordinator.getQuoteEntry(instrument),
343
+ };
344
+ });
345
+ },
346
+ );
209
347
  }
210
348
 
211
349
  export function useChartQueries(
@@ -219,7 +357,8 @@ export function useChartQueries(
219
357
  const keysVersion = useCoordinatorKeysVersion(keys);
220
358
  const coordinator = getSharedMarketDataCoordinator();
221
359
  const appActive = useAppVisible();
222
- const wasActiveRef = useRef(appActive);
360
+ const paneVisible = usePaneVisible();
361
+ const wasPaneVisibleRef = useRef(paneVisible);
223
362
  const entries = useMemo(() => (
224
363
  coordinator
225
364
  ? requests.map((request) => [buildChartKey(request), coordinator.getChartEntry(request)] as const)
@@ -229,15 +368,15 @@ export function useChartQueries(
229
368
  useEffect(() => {
230
369
  const coordinator = getSharedMarketDataCoordinator();
231
370
  if (!coordinator) {
232
- wasActiveRef.current = appActive;
371
+ wasPaneVisibleRef.current = paneVisible;
233
372
  return;
234
373
  }
235
374
  if (!appActive) {
236
- wasActiveRef.current = false;
375
+ wasPaneVisibleRef.current = false;
237
376
  return;
238
377
  }
239
- const forceRefresh = refreshIntervalMs > 0 && !wasActiveRef.current;
240
- wasActiveRef.current = true;
378
+ const forceRefresh = refreshIntervalMs > 0 && paneVisible && !wasPaneVisibleRef.current;
379
+ wasPaneVisibleRef.current = paneVisible;
241
380
  const loadRequests = () => {
242
381
  loadChartRequests(coordinator, requests, { forceRefresh });
243
382
  };
@@ -247,17 +386,17 @@ export function useChartQueries(
247
386
  }
248
387
  const timeout = setTimeout(loadRequests, debounceMs);
249
388
  return () => clearTimeout(timeout);
250
- }, [appActive, debounceMs, refreshIntervalMs, requestKey]);
389
+ }, [appActive, debounceMs, paneVisible, refreshIntervalMs, requestKey]);
251
390
 
252
391
  useEffect(() => {
253
392
  const coordinator = getSharedMarketDataCoordinator();
254
- if (!coordinator || !appActive || refreshIntervalMs <= 0 || requests.length === 0) return;
393
+ if (!coordinator || !paneVisible || refreshIntervalMs <= 0 || requests.length === 0) return;
255
394
 
256
395
  const interval = setInterval(() => {
257
396
  loadChartRequests(coordinator, requests, { forceRefresh: true });
258
397
  }, refreshIntervalMs);
259
398
  return () => clearInterval(interval);
260
- }, [appActive, refreshIntervalMs, requestKey]);
399
+ }, [paneVisible, refreshIntervalMs, requestKey]);
261
400
 
262
401
  return useMemo(() => new Map(entries), [entries]);
263
402
  }
@@ -91,8 +91,8 @@ function BrokerConfigFieldEditor({
91
91
  function accountDetail(account: BrokerAccount): string {
92
92
  const parts = [
93
93
  account.accountId,
94
- account.netLiquidation != null ? tf("{value} net liq", { value: formatCurrency(account.netLiquidation, account.currency || "USD") }) : null,
95
- account.buyingPower != null ? tf("{value} buying power", { value: formatCurrency(account.buyingPower, account.currency || "USD") }) : null,
94
+ account.netLiquidation != null ? tf("{value} net liq", { value: formatCurrency(account.netLiquidation, account.currency || "") }) : null,
95
+ account.buyingPower != null ? tf("{value} buying power", { value: formatCurrency(account.buyingPower, account.currency || "") }) : null,
96
96
  ];
97
97
  return parts.filter(Boolean).join(" · ");
98
98
  }
@@ -46,10 +46,10 @@ function formatBrokerMode(value: unknown): string {
46
46
 
47
47
  function summarizeBrokerAccounts(accounts: BrokerAccount[]): string {
48
48
  if (accounts.length === 0) return "0 acct";
49
- const currencies = new Set(accounts.map((account) => account.currency || "USD"));
49
+ const currencies = new Set(accounts.map((account) => account.currency || ""));
50
50
  const canSum = currencies.size === 1 && accounts.every((account) => typeof account.netLiquidation === "number");
51
51
  if (!canSum) return `${accounts.length} acct${accounts.length === 1 ? "" : "s"}`;
52
- const currency = currencies.values().next().value ?? "USD";
52
+ const currency = currencies.values().next().value ?? "";
53
53
  const total = accounts.reduce((sum, account) => sum + (account.netLiquidation ?? 0), 0);
54
54
  return formatCurrency(total, currency);
55
55
  }
@@ -27,6 +27,7 @@ import { parseChartSpec } from "./chart-spec";
27
27
  import { paneSchemas } from "./headless-schema";
28
28
  import { barHistoryFreshness, REPORTED_DATA } from "../shared/report-freshness";
29
29
  import { inferSeriesFrequency } from "../../../series/csv";
30
+ import { isDatasetChart } from "../../../time-series/spec";
30
31
 
31
32
  export interface ChartPaneModel extends HeadlessSeriesResult {
32
33
  /** The renderer consumes the full model; generic reports project series and metadata. */
@@ -285,7 +286,7 @@ function seriesAttribution(series: readonly HeadlessSeries[]): Pick<HeadlessPane
285
286
  function chartFreshness(series: readonly HeadlessSeries[], spec: ChartSpec): HeadlessPaneFreshness {
286
287
  const order = ["1wk", "1mo"];
287
288
  const resolutions = series.map((entry) => (entry as HeadlessSeries & { historyResolution?: string }).historyResolution);
288
- const dataset = spec.series.length > 0 && spec.series.every(({ source }) => source.kind === "capability");
289
+ const dataset = isDatasetChart(spec);
289
290
  if (!series.length || resolutions.some((resolution) => !resolution)) {
290
291
  return { ...REPORTED_DATA, basis: "reported and published data", ...(dataset ? { dataset } : {}), ...economicChartDates(series, spec), ...seriesAttribution(series) };
291
292
  }
@@ -18,6 +18,7 @@ import {
18
18
  type MultiSelectRowAction,
19
19
  } from "../../../components/ui";
20
20
  import { CompositeChart } from "../../../components/chart/composite";
21
+ import { formatCompositeDatasetDate } from "../../../components/chart/composite/format";
21
22
  import type { CompositeChartLevels, CompositeLevelEdit } from "../../../components/chart/composite/levels";
22
23
  import type { PaneProps, TickerResearchTabProps } from "../../../types/plugin";
23
24
  import type { ChartResolution, TimeRange } from "../../../components/chart/core/types";
@@ -46,6 +47,7 @@ import {
46
47
  import { colors } from "../../../theme/colors";
47
48
  import { isUsListingExchange, parsePublicTickerKey, publicTickerKey } from "../../../utils/exchanges";
48
49
  import { isMarketFieldId } from "../../../time-series/field-catalog";
50
+ import { isDatasetChart } from "../../../time-series/spec";
49
51
  import { CHART_COMPOSER_PANE_ID } from "../../../types/config";
50
52
  import { useRemoteUiNode } from "../../../remote/semantic-tree";
51
53
  import { SeriesEditorDialog } from "./editor";
@@ -934,12 +936,28 @@ function ChartComposerSurface({
934
936
  }, [anchoredVwapOn]);
935
937
  const anchorable = anchoredVwapOn && !pickingAnchor;
936
938
  const footerAnchor = useCallback(() => setPickingAnchor(true), []);
939
+ // A dataset (CHOKE's transits) panned or zoomed back from its newest
940
+ // observation says how far its data goes, as its legend dates a value.
941
+ const newestBeyondView = useMemo(() => {
942
+ const end = viewport?.end.getTime();
943
+ if (!activeRuntimeViewport || !isDatasetChart(spec) || end === undefined || !Number.isFinite(end)) return null;
944
+ let newest = Number.NEGATIVE_INFINITY;
945
+ for (const entry of plottedSeries) {
946
+ for (const point of entry.points) {
947
+ const time = point.date.getTime();
948
+ if (point.value != null && Number.isFinite(time) && time > newest) newest = time;
949
+ }
950
+ }
951
+ return newest > end ? formatCompositeDatasetDate(new Date(newest), end) : null;
952
+ }, [activeRuntimeViewport, plottedSeries, spec, viewport]);
937
953
  usePaneFooter(footerId, () => ({
938
954
  info: resolution.loading
939
955
  ? [{ id: "loading", parts: [{ text: "loading", tone: "muted" as const }] }]
940
956
  : pickingAnchor
941
957
  ? [{ id: "anchor", parts: [{ text: "pick a bar to anchor VWAP", tone: "muted" as const }] }]
942
- : [],
958
+ : newestBeyondView
959
+ ? [{ id: "newest", asOf: true, parts: [{ text: `newest ${newestBeyondView}`, tone: "muted" as const }] }]
960
+ : [],
943
961
  hints: [
944
962
  { id: "series", key: "s", label: "eries", onPress: footerSeries },
945
963
  { id: "indicators", key: "i", label: "ndicators", onPress: openIndicators, disabled: indicatorsDisabled },
@@ -947,7 +965,7 @@ function ChartComposerSurface({
947
965
  { id: "resolution", key: "t", label: "imeframe", onPress: footerResolution },
948
966
  ...(anchorable ? [{ id: "vwap-anchor", key: "v", label: "wap anchor", title: "Anchor VWAP", onPress: footerAnchor }] : []),
949
967
  ],
950
- }), [resolution.loading, pickingAnchor, footerSeries, openIndicators, indicatorsDisabled, openFormulas, formulasDisabled, footerResolution, anchorable, footerAnchor]);
968
+ }), [resolution.loading, pickingAnchor, newestBeyondView, footerSeries, openIndicators, indicatorsDisabled, openFormulas, formulasDisabled, footerResolution, anchorable, footerAnchor]);
951
969
 
952
970
  // A fixed window (a GIP session) highlights no range, so the bar names it.
953
971
  const dateWindowLabel = useMemo(() => {
@@ -16,6 +16,8 @@ import { canReactToMessage } from "../reactions";
16
16
  * The chat pane's keys, shown in its footer and listed in its pane menu. The
17
17
  * hints are the bindings for everything but Enter and `i`, which the chat's
18
18
  * own handler owns because they depend on the composer and the selection.
19
+ * With no message selected both open the composer, as a click on it does, so
20
+ * the footer leaves that out and only the pane menu lists Compose.
19
21
  */
20
22
  export function useChatFooter({
21
23
  composing,
@@ -240,8 +242,6 @@ export function useChatFooter({
240
242
  if (openTargets.length > 0) hints.push({ id: "open", key: "o", label: "pen", title: openTargetTitle, onPress: () => { void latest.current.openSelectedTargets(); } });
241
243
  // Every author but a Discord ghost has a card, if only their name and a way to write to them.
242
244
  if (author) hints.push({ id: "profile", key: "p", label: "rofile", title: "Show Profile", onPress: () => latest.current.toggleAuthorProfile() });
243
- } else if (!composing && canSend) {
244
- hints.push({ id: "compose", key: "i", label: " compose", title: "Compose", onPress: () => queueMicrotask(() => latest.current.focusComposer()) });
245
245
  }
246
246
  if (canSend && notificationChannelId) {
247
247
  const channelId = notificationChannelId;
@@ -261,6 +261,9 @@ export function useChatFooter({
261
261
  }
262
262
 
263
263
  const menu: ContextMenuItem[] = [];
264
+ if (!composing && !selectedMessage && canSend) {
265
+ menu.push({ id: "compose", label: "Compose", accelerator: "Enter", onSelect: () => queueMicrotask(() => latest.current.focusComposer()) });
266
+ }
264
267
  // No key of its own: a stray press would start a conversation. The pane
265
268
  // menu (.) and a right-click on the name reach it.
266
269
  if (authorMessageLabel) {
@@ -68,6 +68,7 @@ import {
68
68
  } from "../edit-window";
69
69
  import { ChatControllerRealtime } from "./realtime";
70
70
  import { ChatControllerChannels } from "./channels";
71
+ import { ChatControllerUnreadPeople } from "./unread-people";
71
72
  import { ChatControllerView } from "./view";
72
73
  import { ChatControllerMessageLoading } from "./message-loading";
73
74
  import { ChatControllerStorage } from "./storage";
@@ -149,6 +150,13 @@ export class ChatController {
149
150
  getListenerSnapshot: (channelId) => this.getSnapshot(channelId),
150
151
  getVisibleMessages: (channelId) => this.getVisibleMessages(channelId),
151
152
  getUnreadMentionCount: (channelId) => this.getUnreadMentionCount(channelId),
153
+ getUnreadPeople: () => this.unreadPeople.snapshot(),
154
+ });
155
+ private readonly unreadPeople = new ChatControllerUnreadPeople({
156
+ getChannels: () => this.channelCatalog.getChannels(),
157
+ getChannelStates: () => this.storage.channelStates.entries(),
158
+ getUser: () => this.session.user,
159
+ onChange: () => this.emit(),
152
160
  });
153
161
  private readonly messageLoading = new ChatControllerMessageLoading({
154
162
  ensureChannelState: (channelId) => this.ensureChannelState(channelId),
@@ -247,6 +255,16 @@ export class ChatController {
247
255
  return this.view.getSnapshot(channelId);
248
256
  }
249
257
 
258
+ /**
259
+ * Keeps who sent each channel's unread messages (`unreadPeople` in the
260
+ * snapshot) current until the returned stop is called, reading what the
261
+ * device does not hold. Nothing is read, and the snapshot leaves it empty,
262
+ * unless something tracks it: only the app badge needs it.
263
+ */
264
+ trackUnreadPeople(): () => void {
265
+ return this.unreadPeople.track();
266
+ }
267
+
250
268
  /** Channels with unread messages, from the counts and messages already held; no request. */
251
269
  listUnreadInbox() {
252
270
  return listUnreadInboxItems({
@@ -786,6 +804,7 @@ export class ChatController {
786
804
 
787
805
  private emit(channelId?: string): void {
788
806
  this.view.emit(channelId);
807
+ this.unreadPeople.sync();
789
808
  }
790
809
 
791
810
  private applyChannelState(state: ChatChannelState): void {
@@ -803,6 +822,7 @@ export class ChatController {
803
822
  private stopRealtime(): void {
804
823
  this.realtime.stopAll();
805
824
  this.alerts.clear();
825
+ this.unreadPeople.clear();
806
826
  }
807
827
 
808
828
  private applySignedOutSession(): void {
@@ -39,9 +39,21 @@ export function isSameMessageRevision(current: ChatMessage, incoming: ChatMessag
39
39
  && attachmentKey(current) === attachmentKey(incoming);
40
40
  }
41
41
 
42
+ // Both lists are replaced on edits, reactions, pagination and send transitions.
43
+ // A draft save, upload progress or presence update changes neither. Keep the
44
+ // projection's identity then: subscribers must not redraw the entire transcript.
45
+ const visibleMessages = new WeakMap<ChannelRuntimeState, {
46
+ messages: ChatMessage[];
47
+ pending: ChatMessage[];
48
+ visible: ChatMessage[];
49
+ }>();
50
+
42
51
  export function getVisibleMessages(channel: ChannelRuntimeState): ChatMessage[] {
43
- return [...channel.messages, ...channel.pendingMessages]
44
- .sort(compareMessages);
52
+ const cached = visibleMessages.get(channel);
53
+ if (cached?.messages === channel.messages && cached.pending === channel.pendingMessages) return cached.visible;
54
+ const visible = [...channel.messages, ...channel.pendingMessages].sort(compareMessages);
55
+ visibleMessages.set(channel, { messages: channel.messages, pending: channel.pendingMessages, visible });
56
+ return visible;
45
57
  }
46
58
 
47
59
  function mergeStoredMessages(channel: ChannelRuntimeState, messages: ChatMessage[]): ChatMessage[] {
@@ -44,6 +44,14 @@ export interface PersistedTranscript {
44
44
  messages: ChatMessage[];
45
45
  }
46
46
 
47
+ /** Who the unread messages of one channel are from, by account id. */
48
+ export interface ChannelUnreadPeople {
49
+ /** Everyone who sent an unread message there. */
50
+ senders: string[];
51
+ /** The ones among them with an unread message that mentions you. */
52
+ mentioners: string[];
53
+ }
54
+
47
55
  export interface ChatControllerSnapshot {
48
56
  channelId: string;
49
57
  channels: ChatChannel[];
@@ -63,6 +71,13 @@ export interface ChatControllerSnapshot {
63
71
  draftAttachments: ChatDraftAttachment[];
64
72
  replyToId: string | null;
65
73
  unreadMentionCount: number;
74
+ /**
75
+ * Who sent the unread messages of each channel with any, by channel id: what
76
+ * the app badge counts people from. Empty unless the badge asked for it
77
+ * ({@link ChatController.trackUnreadPeople}). A channel whose unread messages
78
+ * are not on this device yet lists only the ones that are, until they load.
79
+ */
80
+ unreadPeople: Record<string, ChannelUnreadPeople>;
66
81
  }
67
82
 
68
83
  type ChatConnection = {