gloomberb 0.17.2 → 0.17.4

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 (200) hide show
  1. package/package.json +2 -1
  2. package/src/api-client/chat.ts +28 -0
  3. package/src/api-client/errors.ts +11 -1
  4. package/src/api-client/futures-curve.ts +16 -0
  5. package/src/api-client/index.ts +22 -9
  6. package/src/api-client/normalizers.ts +21 -0
  7. package/src/api-client/socket.ts +27 -0
  8. package/src/api-client/types.ts +3 -1
  9. package/src/app/runtime/startup.ts +4 -0
  10. package/src/cli/commands/market.ts +60 -9
  11. package/src/cli/commands/search.ts +43 -2
  12. package/src/cli/commands/ticker.ts +100 -21
  13. package/src/cli/desktop-pane-shot.ts +34 -9
  14. package/src/cli/fx-pair.ts +17 -0
  15. package/src/cli/glossary.ts +134 -6
  16. package/src/cli/history-rows.ts +16 -4
  17. package/src/cli/index.ts +44 -12
  18. package/src/cli/known-gaps.ts +21 -2
  19. package/src/cli/listing-arg.ts +19 -4
  20. package/src/cli/listing-suggestion.ts +33 -2
  21. package/src/cli/pane-functions/dom.ts +39 -6
  22. package/src/cli/pane-functions/freshness.ts +48 -5
  23. package/src/cli/pane-functions/function-help.ts +1 -1
  24. package/src/cli/pane-functions/headless.ts +85 -19
  25. package/src/cli/pane-functions/index.ts +60 -4
  26. package/src/cli/pane-functions/market-session.ts +69 -6
  27. package/src/cli/pane-functions/resolver.ts +4 -2
  28. package/src/cli/pane-functions/screenshot.ts +14 -1
  29. package/src/cli/scroll-truncation.ts +5 -1
  30. package/src/cli/symbol-args.ts +41 -3
  31. package/src/cli/symbol-list.ts +19 -7
  32. package/src/cli/unknown-command.ts +70 -0
  33. package/src/components/chart/native/surface/visibility.ts +37 -7
  34. package/src/components/index.ts +1 -1
  35. package/src/components/layout/header.tsx +348 -102
  36. package/src/components/markdown-text.tsx +40 -66
  37. package/src/components/market-board/index.tsx +12 -6
  38. package/src/components/metric-treemap/heat-surface.tsx +76 -14
  39. package/src/components/metric-treemap/heat-tiles.ts +5 -3
  40. package/src/components/metric-treemap/index.tsx +1 -1
  41. package/src/components/reader/detail-reader.tsx +28 -6
  42. package/src/components/ticker/badge/index.tsx +2 -0
  43. package/src/components/ui/button.tsx +24 -9
  44. package/src/components/ui/display.tsx +6 -1
  45. package/src/components/ui/external-link.tsx +10 -2
  46. package/src/components/ui/index.ts +1 -1
  47. package/src/components/ui/status.tsx +148 -5
  48. package/src/data/memory-resource-store.ts +1 -1
  49. package/src/data/resource-store.ts +13 -1
  50. package/src/docs/snapshot.json +13 -13
  51. package/src/i18n/es.ts +1 -0
  52. package/src/i18n/ja.ts +1 -0
  53. package/src/i18n/ko.ts +1 -0
  54. package/src/i18n/zh-cn.ts +1 -0
  55. package/src/i18n/zh-tw.ts +1 -0
  56. package/src/layout-marketplace/gallery-desktop.tsx +2 -2
  57. package/src/market-data/futures-board.ts +44 -1
  58. package/src/market-data/futures-contract.ts +10 -0
  59. package/src/market-data/market/format.ts +7 -2
  60. package/src/market-data/market/freshness.ts +25 -1
  61. package/src/market-data/quotes/regular-session-valuation.ts +32 -1
  62. package/src/market-data/quotes/resolution.ts +2 -0
  63. package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
  64. package/src/plugins/builtin/account-management/pane.tsx +17 -13
  65. package/src/plugins/builtin/alerts/cli.ts +72 -11
  66. package/src/plugins/builtin/attention/headless.ts +19 -10
  67. package/src/plugins/builtin/attention/model.ts +22 -0
  68. package/src/plugins/builtin/awards/headless.ts +5 -1
  69. package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
  70. package/src/plugins/builtin/catalysts/headless.ts +5 -1
  71. package/src/plugins/builtin/changelog/index.tsx +11 -8
  72. package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
  73. package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
  74. package/src/plugins/builtin/chat/attachments/model.ts +5 -3
  75. package/src/plugins/builtin/chat/content/footer.tsx +6 -0
  76. package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
  77. package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
  78. package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
  79. package/src/plugins/builtin/chat/controller/connections.ts +18 -0
  80. package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
  81. package/src/plugins/builtin/chat/controller/index.ts +49 -21
  82. package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
  83. package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
  84. package/src/plugins/builtin/chat/layout.ts +91 -31
  85. package/src/plugins/builtin/chat/markdown.ts +94 -0
  86. package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
  87. package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
  88. package/src/plugins/builtin/chat/message/render-state.ts +3 -0
  89. package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
  90. package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
  91. package/src/plugins/builtin/company-attention/headless.ts +8 -5
  92. package/src/plugins/builtin/company-attention/model.ts +53 -2
  93. package/src/plugins/builtin/company-attention/pane.tsx +1 -1
  94. package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
  95. package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
  96. package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
  97. package/src/plugins/builtin/cot/client.ts +12 -4
  98. package/src/plugins/builtin/cot/model.ts +20 -0
  99. package/src/plugins/builtin/cot/pane.tsx +2 -2
  100. package/src/plugins/builtin/crypto-board/model.ts +7 -3
  101. package/src/plugins/builtin/custom-view/pane.tsx +2 -2
  102. package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
  103. package/src/plugins/builtin/debug/index.tsx +15 -15
  104. package/src/plugins/builtin/dividend-yield/client.ts +8 -1
  105. package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
  106. package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
  107. package/src/plugins/builtin/docs/index.tsx +5 -3
  108. package/src/plugins/builtin/docs/pane.tsx +19 -2
  109. package/src/plugins/builtin/docs/targets.ts +5 -3
  110. package/src/plugins/builtin/earnings-calls/data.ts +22 -0
  111. package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
  112. package/src/plugins/builtin/equity-screener/headless.ts +4 -3
  113. package/src/plugins/builtin/equity-screener/live.ts +27 -1
  114. package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
  115. package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
  116. package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
  117. package/src/plugins/builtin/futures/index.tsx +33 -6
  118. package/src/plugins/builtin/futures/model.ts +69 -5
  119. package/src/plugins/builtin/futures/series.ts +3 -3
  120. package/src/plugins/builtin/futures/table.tsx +31 -11
  121. package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
  122. package/src/plugins/builtin/holders/pane.tsx +6 -1
  123. package/src/plugins/builtin/jobs/pane.tsx +12 -9
  124. package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
  125. package/src/plugins/builtin/mna/client.ts +12 -2
  126. package/src/plugins/builtin/mna/detail.tsx +2 -2
  127. package/src/plugins/builtin/mna/pane.tsx +2 -2
  128. package/src/plugins/builtin/news/headless.ts +4 -2
  129. package/src/plugins/builtin/notes/owner.tsx +5 -4
  130. package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
  131. package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
  132. package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
  133. package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
  134. package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
  135. package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
  136. package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
  137. package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
  138. package/src/plugins/builtin/research/event-model.ts +14 -0
  139. package/src/plugins/builtin/research/headless-schema.ts +31 -1
  140. package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
  141. package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
  142. package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
  143. package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
  144. package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
  145. package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
  146. package/src/plugins/builtin/research-search/data.ts +11 -0
  147. package/src/plugins/builtin/research-search/pane.tsx +13 -2
  148. package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
  149. package/src/plugins/builtin/seasonality/client.ts +23 -0
  150. package/src/plugins/builtin/seasonality/pane.tsx +4 -2
  151. package/src/plugins/builtin/sectors/index.tsx +7 -3
  152. package/src/plugins/builtin/shared/report-freshness.ts +6 -2
  153. package/src/plugins/builtin/short-interest/headless.ts +13 -3
  154. package/src/plugins/builtin/short-interest/index.tsx +1 -1
  155. package/src/plugins/builtin/short-interest/model.ts +62 -3
  156. package/src/plugins/builtin/short-interest/pane.tsx +2 -2
  157. package/src/plugins/builtin/short-interest/schedule.ts +107 -0
  158. package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
  159. package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
  160. package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
  161. package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
  162. package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
  163. package/src/plugins/builtin/themes/headless.ts +1 -1
  164. package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
  165. package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
  166. package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
  167. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
  168. package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
  169. package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
  170. package/src/plugins/builtin/volatility/chart-model.ts +1 -1
  171. package/src/plugins/builtin/volatility/charts.tsx +1 -1
  172. package/src/renderers/dom/build-assets.ts +17 -4
  173. package/src/renderers/dom/desktop/controls.tsx +2 -0
  174. package/src/renderers/dom/styles.css +6 -0
  175. package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
  176. package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
  177. package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
  178. package/src/sources/gloomberb-cloud/normalizers.ts +13 -4
  179. package/src/sources/provider-router/batches.ts +2 -1
  180. package/src/sources/provider-router/cache.ts +3 -0
  181. package/src/sync/baseline.ts +19 -14
  182. package/src/sync/controller.ts +140 -40
  183. package/src/sync/core-contributors.ts +256 -32
  184. package/src/sync/types.ts +44 -0
  185. package/src/time-series/resolve.ts +24 -5
  186. package/src/types/data-provider.ts +8 -0
  187. package/src/types/financials.ts +40 -4
  188. package/src/types/headless.ts +43 -0
  189. package/src/types/plugin.ts +7 -0
  190. package/src/ui/resize-settle.ts +128 -0
  191. package/src/utils/calendar-date.ts +56 -0
  192. package/src/utils/decimal-comma.ts +31 -0
  193. package/src/utils/declared-dividends.ts +39 -0
  194. package/src/utils/forward-dividend.ts +34 -11
  195. package/src/utils/fundamentals.ts +13 -2
  196. package/src/utils/futures-generic.ts +25 -4
  197. package/src/utils/markdown.ts +180 -0
  198. package/src/utils/market-capitalization.ts +22 -0
  199. package/src/utils/plugin-proxy-hosts.json +5 -0
  200. package/src/utils/utc-time.ts +14 -3
@@ -0,0 +1,70 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { ChatMessage } from "../../../../api-client";
3
+
4
+ const BODY_SELECTOR = "[data-gloom-role=chat-message-body]";
5
+ const MESSAGE_ID_ATTRIBUTE = "data-gloom-chat-message-id";
6
+
7
+ /** Whether the selection takes in every character the body draws, first to last. */
8
+ function coversBody(range: Range, body: Element): boolean {
9
+ const walker = body.ownerDocument.createTreeWalker(body, 4 /* NodeFilter.SHOW_TEXT */);
10
+ let first: [Node, number] | null = null;
11
+ let last: [Node, number] | null = null;
12
+ for (let node = walker.nextNode(); node; node = walker.nextNode()) {
13
+ const text = node.textContent ?? "";
14
+ const start = text.search(/\S/);
15
+ if (start < 0) continue;
16
+ first ??= [node, start];
17
+ last = [node, text.trimEnd().length];
18
+ }
19
+ if (!first || !last) return false;
20
+ try {
21
+ return range.comparePoint(first[0], first[1]) === 0 && range.comparePoint(last[0], last[1]) === 0;
22
+ } catch {
23
+ return false;
24
+ }
25
+ }
26
+
27
+ /**
28
+ * What a copy of the selection puts on the clipboard when it takes in whole
29
+ * messages of this list: each as it was written, marks and all, one per line.
30
+ * Null when it takes in part of a message, which copies as drawn, or anything
31
+ * outside this list's messages.
32
+ */
33
+ function wholeMessagesText(doc: Document, messages: readonly ChatMessage[]): string | null {
34
+ const selection = doc.getSelection();
35
+ if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
36
+ const range = selection.getRangeAt(0);
37
+ const bodies = Array.from(doc.querySelectorAll(BODY_SELECTOR)).filter((body) => range.intersectsNode(body));
38
+ if (bodies.length === 0) return null;
39
+ const parts: string[] = [];
40
+ for (const body of bodies) {
41
+ const id = body.closest(`[${MESSAGE_ID_ATTRIBUTE}]`)?.getAttribute(MESSAGE_ID_ATTRIBUTE);
42
+ const message = id ? messages.find((candidate) => candidate.id === id) : undefined;
43
+ if (!message || !coversBody(range, body)) return null;
44
+ parts.push(message.content);
45
+ }
46
+ return parts.join("\n");
47
+ }
48
+
49
+ /**
50
+ * Desktop and web: copying whole messages copies what their authors wrote,
51
+ * markdown and share links included, not the text as drawn. Copying part of a
52
+ * message copies what it shows.
53
+ */
54
+ export function useWholeMessageCopy(enabled: boolean, messages: readonly ChatMessage[]): void {
55
+ const messagesRef = useRef(messages);
56
+ messagesRef.current = messages;
57
+ useEffect(() => {
58
+ const doc = (globalThis as { document?: Document }).document;
59
+ if (!enabled || !doc) return;
60
+ const onCopy = (event: ClipboardEvent) => {
61
+ if (event.defaultPrevented || !event.clipboardData) return;
62
+ const text = wholeMessagesText(doc, messagesRef.current);
63
+ if (text === null) return;
64
+ event.clipboardData.setData("text/plain", text);
65
+ event.preventDefault();
66
+ };
67
+ doc.addEventListener("copy", onCopy);
68
+ return () => doc.removeEventListener("copy", onCopy);
69
+ }, [enabled]);
70
+ }
@@ -21,6 +21,21 @@ export function getSafetyRefreshChannelIds(channelStates: Iterable<[string, Chan
21
21
  return active.length > 0 ? active : [DEFAULT_CHAT_CHANNEL_ID];
22
22
  }
23
23
 
24
+ /**
25
+ * The channels read live, each marked to read its latest page in full on its
26
+ * next refresh: after the socket was down, what changed on messages already
27
+ * held (reactions, edits, removals) does not come with newer messages.
28
+ */
29
+ export function markConnectedChannelsForBackfill(channelStates: Iterable<[string, ChannelRuntimeState]>): string[] {
30
+ const channelIds: string[] = [];
31
+ for (const [channelId, channel] of channelStates) {
32
+ if (!channel.wsConnected) continue;
33
+ channel.backfilled = false;
34
+ channelIds.push(channelId);
35
+ }
36
+ return channelIds;
37
+ }
38
+
24
39
  export function getOpenConnectionChannelIds(channelStates: Iterable<[string, ChannelRuntimeState]>): Set<string> {
25
40
  const channelIds = new Set<string>([DEFAULT_CHAT_CHANNEL_ID]);
26
41
  for (const [channelId, channel] of channelStates) {
@@ -76,6 +91,9 @@ export function ensureChatChannelConnection({
76
91
  if (channel.wsConnected) return;
77
92
  channel.wsConnected = true;
78
93
 
94
+ // Reactions, edits and removals on messages already held while the channel
95
+ // was not read live come only with a full page, not with newer messages.
96
+ channel.backfilled = false;
79
97
  void refreshMessages().catch(() => {});
80
98
 
81
99
  channel.wsConnection = connectChannel(
@@ -35,8 +35,11 @@ export async function fetchLatestChannelMessages(
35
35
  limit: MESSAGE_PAGE_SIZE,
36
36
  after: backfillGaps ? undefined : channel.lastCursor ?? undefined,
37
37
  });
38
- channel.backfilled = true;
39
- if (backfillGaps) channel.backfilledAs = readerId;
38
+ // A full page asked for while this read was out still happens next time.
39
+ if (backfillGaps) {
40
+ channel.backfilled = true;
41
+ channel.backfilledAs = readerId;
42
+ }
40
43
  if ((!hasIncrementalCursor || backfillGaps) && messages.length < MESSAGE_PAGE_SIZE) {
41
44
  channel.reachedOldestMessage = true;
42
45
  }
@@ -11,6 +11,7 @@ import {
11
11
  type ChatChannel,
12
12
  type ChatChannelState,
13
13
  type ChatMessage,
14
+ type ChatNotification,
14
15
  type ChatReaction,
15
16
  type ChatReactionsUpdate,
16
17
  type PersistedAuthUser,
@@ -56,10 +57,9 @@ import {
56
57
  ensureChatChannelConnection,
57
58
  getOpenConnectionChannelIds,
58
59
  getSafetyRefreshChannelIds as getChannelIdsForSafetyRefresh,
60
+ markConnectedChannelsForBackfill,
59
61
  } from "./connections";
60
- import {
61
- handleChatNotification as handleChatNotificationEvent,
62
- } from "./notifications";
62
+ import { ChatNotificationAlerts } from "./notifications";
63
63
  import {
64
64
  CHAT_MESSAGE_EDIT_WINDOW_LABEL,
65
65
  DISCORD_MESSAGE_EDIT_NOTICE,
@@ -116,7 +116,14 @@ export class ChatController {
116
116
  );
117
117
  private notifyFn: (notification: AppNotificationRequest) => AppNotificationDelivery | void = () => {};
118
118
  private openMessageFn: ((channelId: string, messageId: string) => void) | undefined;
119
- private notifiedMessageIds = new Set<string>();
119
+ private readonly alerts = new ChatNotificationAlerts({
120
+ getAppActive: () => this.appActive,
121
+ ensureChannelState: (channelId) => this.ensureChannelState(channelId),
122
+ mergeMessages: (channelId, messages, options) => this.mergeMessages(channelId, messages, options),
123
+ getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
124
+ getNotify: () => this.notifyFn,
125
+ getOpenMessage: () => this.openMessageFn,
126
+ });
120
127
 
121
128
  private readonly storage = new ChatControllerStorage({
122
129
  emit: (channelId) => this.emit(channelId),
@@ -156,6 +163,11 @@ export class ChatController {
156
163
  getUser: () => this.session.user,
157
164
  refreshSession: () => this.refreshSession(),
158
165
  handleNotification: (notification) => this.handleChatNotification(notification),
166
+ handleChannelState: (state) => {
167
+ this.applyChannelState(state);
168
+ this.emit(state.channelId);
169
+ this.emit();
170
+ },
159
171
  handleReactions: (update) => this.applyReactionsUpdate(update),
160
172
  setOnlineCount: (onlineCount) => {
161
173
  this.channelCatalog.setOnlineCount(onlineCount);
@@ -163,6 +175,7 @@ export class ChatController {
163
175
  emit: () => this.emit(),
164
176
  getSafetyRefreshChannelIds: () => getChannelIdsForSafetyRefresh(this.storage.channelStates),
165
177
  runSafetyRefresh: (channelId) => this.messageLoading.runMessagesRefresh(channelId, { showLoading: false }),
178
+ resyncChannels: () => this.resyncConnectedChannels(),
166
179
  });
167
180
 
168
181
  attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
@@ -170,6 +183,14 @@ export class ChatController {
170
183
  this.hydrate();
171
184
  }
172
185
 
186
+ /**
187
+ * A popped-out window has its own chat state but leaves background banners
188
+ * to the main window, so one message raises one banner per app.
189
+ */
190
+ setRaisesBackgroundAlerts(raises: boolean): void {
191
+ this.alerts.raisesBackgroundAlerts = raises;
192
+ }
193
+
173
194
  setNotifier(
174
195
  notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void,
175
196
  openMessage?: (channelId: string, messageId: string) => void,
@@ -466,7 +487,7 @@ export class ChatController {
466
487
  },
467
488
  closeAllConnections: () => this.closeAllConnections(),
468
489
  emit: () => this.emit(),
469
- stopRealtime: () => this.realtime.stopAll(),
490
+ stopRealtime: () => this.stopRealtime(),
470
491
  });
471
492
  }
472
493
 
@@ -502,7 +523,7 @@ export class ChatController {
502
523
  persistChannelState: (channelId) => this.storage.persistChannelState(channelId),
503
524
  persistSession: () => this.storage.persistSession(this.session.sessionToken, this.session.user),
504
525
  shouldClearSession: clearSession,
505
- stopRealtime: () => this.realtime.stopAll(),
526
+ stopRealtime: () => this.stopRealtime(),
506
527
  });
507
528
  }
508
529
 
@@ -514,7 +535,7 @@ export class ChatController {
514
535
  });
515
536
  disposeChatControllerRuntime({
516
537
  channelStates: this.storage.channelStates,
517
- clearNotifications: () => this.notifiedMessageIds.clear(),
538
+ clearNotifications: () => this.alerts.clear(),
518
539
  clearView: () => this.view.clear(),
519
540
  closeAllConnections: () => this.closeAllConnections(),
520
541
  flushDraftSync: (channelId) => this.storage.flushDraftSync(channelId),
@@ -522,7 +543,7 @@ export class ChatController {
522
543
  this.notifyFn = () => {};
523
544
  this.openMessageFn = undefined;
524
545
  },
525
- stopRealtime: () => this.realtime.stopAll(),
546
+ stopRealtime: () => this.stopRealtime(),
526
547
  });
527
548
  }
528
549
 
@@ -778,6 +799,12 @@ export class ChatController {
778
799
  closeAllChannelConnections(this.storage.channelStates.values());
779
800
  }
780
801
 
802
+ /** Live updates stop, and so do alerts still waiting to go up: nobody signed in reads them. */
803
+ private stopRealtime(): void {
804
+ this.realtime.stopAll();
805
+ this.alerts.clear();
806
+ }
807
+
781
808
  private applySignedOutSession(): void {
782
809
  applySignedOutChatControllerSession({
783
810
  channelStates: this.storage.channelStates.values(),
@@ -785,10 +812,21 @@ export class ChatController {
785
812
  emit: () => this.emit(),
786
813
  persistSession: (sessionToken, user) => this.storage.persistSession(sessionToken, user),
787
814
  session: this.session,
788
- stopRealtime: () => this.realtime.stopAll(),
815
+ stopRealtime: () => this.stopRealtime(),
789
816
  });
790
817
  }
791
818
 
819
+ /** Each channel read live reads its latest page in full, after any read already out. */
820
+ private resyncConnectedChannels(): void {
821
+ for (const channelId of markConnectedChannelsForBackfill(this.storage.channelStates)) {
822
+ const inFlight = this.ensureChannelState(channelId).refreshMessagesPromise ?? Promise.resolve();
823
+ void inFlight
824
+ .catch(() => {})
825
+ .then(() => this.messageLoading.runMessagesRefresh(channelId, { showLoading: false }))
826
+ .catch(() => {});
827
+ }
828
+ }
829
+
792
830
  private ensureOpenChannelConnections(): void {
793
831
  const channelIds = getOpenConnectionChannelIds(this.storage.channelStates);
794
832
  closeInactiveChannelConnections(this.storage.channelStates, channelIds);
@@ -823,20 +861,10 @@ export class ChatController {
823
861
  }
824
862
 
825
863
  private handleChatNotification(
826
- notification: Parameters<typeof handleChatNotificationEvent>[0]["notification"],
864
+ notification: ChatNotification,
827
865
  options: { countUnread?: boolean } = {},
828
866
  ): void {
829
- handleChatNotificationEvent({
830
- notification,
831
- options,
832
- appActive: this.appActive,
833
- ensureChannelState: (channelId) => this.ensureChannelState(channelId),
834
- mergeMessages: (channelId, messages, mergeOptions) => this.mergeMessages(channelId, messages, mergeOptions),
835
- getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
836
- notifiedMessageIds: this.notifiedMessageIds,
837
- notify: this.notifyFn,
838
- openMessage: this.openMessageFn,
839
- });
867
+ this.alerts.handle(notification, options);
840
868
  }
841
869
 
842
870
  private getUnreadMentionCount(channelId: string): number {
@@ -11,83 +11,143 @@ import {
11
11
  formatReplyToast,
12
12
  } from "./utils";
13
13
 
14
- function wasNotificationDelivered(delivery: AppNotificationDelivery | void): boolean {
15
- return !!delivery && (delivery.toastVisible || delivery.desktopRequested);
16
- }
14
+ /**
15
+ * How long an app in the background holds a chat banner. The reader may be
16
+ * reading the message at that moment on another device or in a popped-out
17
+ * window, which tells the server within a second; the banner goes up only if
18
+ * the server still has the message unseen.
19
+ */
20
+ const BACKGROUND_ALERT_DELAY_MS = 2_000;
17
21
 
18
- function notifyChatServerMessage({
19
- notification,
20
- channel,
21
- notifiedMessageIds,
22
- notify,
23
- openMessage,
24
- }: {
25
- notification: ChatNotification;
26
- channel: ChatChannel | undefined;
27
- notifiedMessageIds: Set<string>;
28
- notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void;
29
- openMessage?: (channelId: string, messageId: string) => void;
30
- }): boolean {
31
- if (notifiedMessageIds.has(notification.messageId)) return true;
32
- const channelTitle = formatChatPaneTitle(channel, notification.channelId);
33
- const body = notification.type === "reply"
34
- ? formatReplyToast(notification.message)
35
- : notification.type === "mention"
36
- ? formatMentionToast(notification.message)
37
- : formatChannelToast(notification.message, channel?.kind === "direct");
38
- const delivered = wasNotificationDelivered(notify({
39
- title: channelTitle,
40
- body,
41
- type: "info",
42
- desktop: "when-inactive",
43
- ...(openMessage ? {
44
- action: {
45
- label: "Open",
46
- onClick: () => openMessage(notification.channelId, notification.messageId),
47
- },
48
- } : {}),
49
- }));
50
- if (delivered) {
51
- notifiedMessageIds.add(notification.messageId);
52
- }
53
- return delivered;
54
- }
22
+ type Notify = (notification: AppNotificationRequest) => AppNotificationDelivery | void;
55
23
 
56
- export function handleChatNotification({
57
- notification,
58
- options = {},
59
- ensureChannelState,
60
- mergeMessages,
61
- appActive,
62
- getChannel,
63
- notifiedMessageIds,
64
- notify,
65
- openMessage,
66
- }: {
67
- notification: ChatNotification;
68
- options?: { countUnread?: boolean };
24
+ interface ChatNotificationAlertsOptions {
25
+ getAppActive: () => boolean;
69
26
  ensureChannelState: (channelId: string) => ChannelRuntimeState;
70
27
  mergeMessages: (channelId: string, messages: ChatMessage[], options?: MergeMessagesOptions) => void;
71
- appActive: boolean;
72
28
  getChannel: (channelId: string) => ChatChannel | undefined;
73
- notifiedMessageIds: Set<string>;
74
- notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void;
75
- openMessage?: (channelId: string, messageId: string) => void;
76
- }): void {
77
- mergeMessages(notification.channelId, [notification.message], { countUnread: options.countUnread });
78
- const channel = ensureChannelState(notification.channelId);
79
- const activelyViewed = appActive && channel.focusedViewCount > 0;
80
- const delivered = activelyViewed || notifyChatServerMessage({
81
- notification,
82
- channel: getChannel(notification.channelId),
83
- notifiedMessageIds,
84
- notify,
85
- openMessage,
86
- });
87
- if (activelyViewed) {
88
- notifiedMessageIds.add(notification.messageId);
29
+ getNotify: () => Notify;
30
+ getOpenMessage: () => ((channelId: string, messageId: string) => void) | undefined;
31
+ }
32
+
33
+ /** Whether this window holds the message as read: its read marker is at or after it. */
34
+ function isReadHere(channel: ChannelRuntimeState, messageId: string): boolean {
35
+ const viewedId = channel.lastViewedMessageId;
36
+ if (!viewedId) return false;
37
+ if (viewedId === messageId) return true;
38
+ const viewedIndex = channel.messages.findIndex((message) => message.id === viewedId);
39
+ if (viewedIndex < 0) return false;
40
+ const messageIndex = channel.messages.findIndex((message) => message.id === messageId);
41
+ return messageIndex >= 0 && messageIndex <= viewedIndex;
42
+ }
43
+
44
+ /**
45
+ * Tells the server how a notification reached the reader. `seen`: where they
46
+ * were looking (the open channel, a toast in the foreground), which also
47
+ * stops the phone push waiting on it. Without it, only a banner on a
48
+ * computer nobody may be at, which still stops the email and the reminder on
49
+ * the next reconnect.
50
+ */
51
+ function acknowledge(notification: ChatNotification, seen: boolean): void {
52
+ void apiClient.markChatNotificationsDelivered([notification.id], { seen }).catch(() => {});
53
+ }
54
+
55
+ /**
56
+ * Decides, once per message, whether a chat notification becomes a toast or
57
+ * a desktop banner. A message already read here or on another device stays
58
+ * quiet, whether it arrives live or again on reconnect.
59
+ */
60
+ export class ChatNotificationAlerts {
61
+ private readonly notifiedMessageIds = new Set<string>();
62
+ private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
63
+ private generation = 0;
64
+ /** False in a popped-out window: the main window raises the background banners. */
65
+ raisesBackgroundAlerts = true;
66
+ backgroundDelayMs = BACKGROUND_ALERT_DELAY_MS;
67
+
68
+ constructor(private readonly options: ChatNotificationAlertsOptions) {}
69
+
70
+ handle(notification: ChatNotification, options: { countUnread?: boolean } = {}): void {
71
+ this.options.mergeMessages(notification.channelId, [notification.message], { countUnread: options.countUnread });
72
+ if (this.notifiedMessageIds.has(notification.messageId)) {
73
+ // Shown here already; this copy came back on a reconnect.
74
+ acknowledge(notification, false);
75
+ return;
76
+ }
77
+ if (this.waiting.has(notification.id) || this.settledHere(notification)) return;
78
+
79
+ if (this.options.getAppActive()) {
80
+ this.present(notification);
81
+ return;
82
+ }
83
+ if (!this.raisesBackgroundAlerts) return;
84
+ const generation = this.generation;
85
+ this.waiting.set(notification.id, setTimeout(() => {
86
+ this.waiting.delete(notification.id);
87
+ void this.presentIfUnseen(notification, generation);
88
+ }, this.backgroundDelayMs));
89
89
  }
90
- if (delivered) {
91
- void apiClient.markChatNotificationsDelivered([notification.id]).catch(() => {});
90
+
91
+ /** Drops what is waiting and what was shown, as on sign-out. */
92
+ clear(): void {
93
+ this.generation += 1;
94
+ for (const timer of this.waiting.values()) clearTimeout(timer);
95
+ this.waiting.clear();
96
+ this.notifiedMessageIds.clear();
97
+ }
98
+
99
+ /** Read here, or open in front of the reader: nothing to raise, and the server hears it was seen. */
100
+ private settledHere(notification: ChatNotification): boolean {
101
+ const channel = this.options.ensureChannelState(notification.channelId);
102
+ const viewing = this.options.getAppActive() && channel.focusedViewCount > 0;
103
+ if (!viewing && !isReadHere(channel, notification.messageId)) return false;
104
+ this.notifiedMessageIds.add(notification.messageId);
105
+ acknowledge(notification, true);
106
+ return true;
107
+ }
108
+
109
+ private async presentIfUnseen(notification: ChatNotification, generation: number): Promise<void> {
110
+ let unseen = true;
111
+ try {
112
+ const ids = await apiClient.getUnseenChatNotificationIds([notification.id]);
113
+ unseen = ids.includes(notification.id);
114
+ } catch {
115
+ // A server that cannot say (an older one, a dropped request) gets the banner, as before.
116
+ }
117
+ if (generation !== this.generation || this.notifiedMessageIds.has(notification.messageId)) return;
118
+ if (!unseen) {
119
+ // Read, or shown in front of the reader, on another device or window.
120
+ this.notifiedMessageIds.add(notification.messageId);
121
+ return;
122
+ }
123
+ if (this.settledHere(notification)) return;
124
+ this.present(notification);
125
+ }
126
+
127
+ private present(notification: ChatNotification): void {
128
+ const channel = this.options.getChannel(notification.channelId);
129
+ const body = notification.type === "reply"
130
+ ? formatReplyToast(notification.message)
131
+ : notification.type === "mention"
132
+ ? formatMentionToast(notification.message)
133
+ : formatChannelToast(notification.message, channel?.kind === "direct");
134
+ const openMessage = this.options.getOpenMessage();
135
+ const delivery = this.options.getNotify()({
136
+ title: formatChatPaneTitle(channel, notification.channelId),
137
+ body,
138
+ type: "info",
139
+ desktop: "when-inactive",
140
+ ...(openMessage ? {
141
+ action: {
142
+ label: "Open",
143
+ onClick: () => openMessage(notification.channelId, notification.messageId),
144
+ },
145
+ } : {}),
146
+ });
147
+ const toastVisible = !!delivery && delivery.toastVisible;
148
+ // Nothing reached the reader: it stays pending for the email and the next reconnect.
149
+ if (!toastVisible && !(delivery && delivery.desktopRequested)) return;
150
+ this.notifiedMessageIds.add(notification.messageId);
151
+ acknowledge(notification, toastVisible);
92
152
  }
93
153
  }
@@ -1,4 +1,9 @@
1
- import { apiClient, type ChatNotification, type ChatReactionsUpdate } from "../../../../api-client";
1
+ import {
2
+ apiClient,
3
+ type ChatChannelState,
4
+ type ChatNotification,
5
+ type ChatReactionsUpdate,
6
+ } from "../../../../api-client";
2
7
  import {
3
8
  SAFETY_REFRESH_MS,
4
9
  SESSION_RETRY_MS,
@@ -11,20 +16,25 @@ interface ChatControllerRealtimeOptions {
11
16
  getUser: () => { emailVerified?: boolean } | null;
12
17
  refreshSession: () => Promise<void>;
13
18
  handleNotification: (notification: ChatNotification) => void;
19
+ handleChannelState: (state: ChatChannelState) => void;
14
20
  handleReactions: (update: ChatReactionsUpdate) => void;
15
21
  setOnlineCount: (onlineCount: number) => void;
16
22
  emit: () => void;
17
23
  getSafetyRefreshChannelIds: () => string[];
18
24
  runSafetyRefresh: (channelId: string) => Promise<void>;
25
+ /** The socket came back after a drop: the channels read live read their latest page in full. */
26
+ resyncChannels: () => void;
19
27
  }
20
28
 
21
29
  export class ChatControllerRealtime {
22
30
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
23
31
  private sessionRetryTimer: ReturnType<typeof setTimeout> | null = null;
24
32
  private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
33
+ private socketConnectionUnsubscribe: (() => void) | null = null;
25
34
  private chatNotificationUnsubscribe: (() => void) | null = null;
26
35
  private chatPresenceUnsubscribe: (() => void) | null = null;
27
36
  private chatReactionsUnsubscribe: (() => void) | null = null;
37
+ private channelStateUnsubscribe: (() => void) | null = null;
28
38
 
29
39
  constructor(private readonly options: ChatControllerRealtimeOptions) {}
30
40
 
@@ -72,21 +82,47 @@ export class ChatControllerRealtime {
72
82
  this.options.handleReactions(update);
73
83
  });
74
84
  }
85
+ if (!this.channelStateUnsubscribe) {
86
+ // Read or muted on another device or window: the unread count, the
87
+ // Dock badge and alerts still waiting follow without a refocus.
88
+ this.channelStateUnsubscribe = apiClient.subscribeChatChannelState((state) => {
89
+ this.options.handleChannelState(state);
90
+ });
91
+ }
75
92
  if (!this.chatPresenceUnsubscribe) {
76
93
  this.chatPresenceUnsubscribe = apiClient.subscribeChatPresence((onlineCount) => {
77
94
  this.options.setOnlineCount(onlineCount);
78
95
  this.options.emit();
79
96
  });
80
97
  }
98
+ if (!this.socketConnectionUnsubscribe) {
99
+ // Frames sent while the socket was down are gone, and the catch-up
100
+ // only reads newer messages, so a reaction, edit or removal made
101
+ // meanwhile is read with a full page once it is back.
102
+ let dropped = false;
103
+ this.socketConnectionUnsubscribe = apiClient.subscribeSocketConnection((event) => {
104
+ if (event === "closed") {
105
+ dropped = true;
106
+ return;
107
+ }
108
+ if (!dropped) return;
109
+ dropped = false;
110
+ this.options.resyncChannels();
111
+ });
112
+ }
81
113
  }
82
114
 
83
115
  stopRealtimeSubscriptions(): void {
116
+ this.socketConnectionUnsubscribe?.();
117
+ this.socketConnectionUnsubscribe = null;
84
118
  this.chatNotificationUnsubscribe?.();
85
119
  this.chatNotificationUnsubscribe = null;
86
120
  this.chatPresenceUnsubscribe?.();
87
121
  this.chatPresenceUnsubscribe = null;
88
122
  this.chatReactionsUnsubscribe?.();
89
123
  this.chatReactionsUnsubscribe = null;
124
+ this.channelStateUnsubscribe?.();
125
+ this.channelStateUnsubscribe = null;
90
126
  }
91
127
 
92
128
  stopVerificationPolling(): void {