gloomberb 0.7.1 → 0.7.3

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 (131) hide show
  1. package/README.md +79 -132
  2. package/package.json +1 -1
  3. package/src/app.tsx +73 -67
  4. package/src/components/chart/chart-data.ts +7 -2
  5. package/src/components/chart/chart-pane-settings.ts +7 -4
  6. package/src/components/chart/chart-renderer.ts +49 -15
  7. package/src/components/chart/chart-types.ts +2 -2
  8. package/src/components/chart/chart.test.ts +27 -8
  9. package/src/components/chart/comparison-stock-chart.tsx +84 -121
  10. package/src/components/chart/native/chart-rasterizer.ts +43 -17
  11. package/src/components/chart/price-axis-labels.tsx +25 -9
  12. package/src/components/chart/stock-chart-renderer-switch.test.tsx +1 -1
  13. package/src/components/chart/stock-chart.tsx +59 -41
  14. package/src/components/command-bar/command-bar.test.tsx +446 -120
  15. package/src/components/command-bar/command-bar.tsx +528 -383
  16. package/src/components/command-bar/command-registry.ts +16 -6
  17. package/src/components/command-bar/root-shortcuts.ts +4 -5
  18. package/src/components/command-bar/view-model.test.ts +1 -0
  19. package/src/components/command-bar/view-model.ts +2 -1
  20. package/src/components/command-bar/workflow-ops.ts +11 -32
  21. package/src/components/data-table-view.test.tsx +24 -1
  22. package/src/components/data-table-view.tsx +3 -2
  23. package/src/components/feed-data-table-stack-view.tsx +3 -2
  24. package/src/components/layout/detached-pane-shell.tsx +2 -2
  25. package/src/components/layout/floating-pane.tsx +9 -2
  26. package/src/components/layout/pane-footer.tsx +1 -1
  27. package/src/components/layout/pane-header.tsx +4 -2
  28. package/src/components/layout/pane-sizing.ts +12 -0
  29. package/src/components/layout/pane.tsx +24 -2
  30. package/src/components/layout/shell.tsx +24 -10
  31. package/src/components/layout/status-bar.test.tsx +4 -1
  32. package/src/components/layout/status-bar.tsx +4 -24
  33. package/src/components/markdown-text.tsx +53 -8
  34. package/src/components/onboarding/onboarding-wizard.tsx +7 -6
  35. package/src/components/pane-settings-dialog.tsx +9 -6
  36. package/src/components/pane-template-wizard.tsx +3 -2
  37. package/src/components/price-selector-dialog.tsx +3 -2
  38. package/src/components/price-sparkline-view.tsx +463 -0
  39. package/src/components/price-sparkline.ts +46 -0
  40. package/src/components/table-view-shared.tsx +47 -7
  41. package/src/components/ticker-list-table-view.tsx +3 -2
  42. package/src/components/ticker-list-table.tsx +11 -51
  43. package/src/components/toggle-list.tsx +3 -3
  44. package/src/components/ui/choice-dialog.tsx +3 -2
  45. package/src/components/ui/multi-select-dialog.tsx +3 -2
  46. package/src/components/ui/native-select.tsx +3 -3
  47. package/src/core/state/app-state.ts +210 -80
  48. package/src/data/config-store-node.ts +113 -25
  49. package/src/data/config-store.test.ts +65 -3
  50. package/src/data/plugin-state-store.ts +39 -13
  51. package/src/data/sqlite-cache.test.ts +13 -0
  52. package/src/market-data/coordinator.ts +8 -1
  53. package/src/news/aggregator.test.ts +41 -0
  54. package/src/news/aggregator.ts +61 -9
  55. package/src/plugins/builtin/ai/ask-ai-detail-tab.tsx +25 -6
  56. package/src/plugins/builtin/ai/settings.ts +3 -0
  57. package/src/plugins/builtin/broker-manager/index.tsx +4 -3
  58. package/src/plugins/builtin/chat-controller.test.ts +398 -17
  59. package/src/plugins/builtin/chat-controller.ts +258 -44
  60. package/src/plugins/builtin/chat.test.tsx +257 -6
  61. package/src/plugins/builtin/chat.tsx +225 -60
  62. package/src/plugins/builtin/comparison-chart.test.tsx +117 -3
  63. package/src/plugins/builtin/correlation/colors.test.ts +39 -0
  64. package/src/plugins/builtin/correlation/colors.ts +87 -0
  65. package/src/plugins/builtin/correlation/index.test.tsx +151 -0
  66. package/src/plugins/builtin/correlation/index.tsx +92 -21
  67. package/src/plugins/builtin/debug.tsx +4 -3
  68. package/src/plugins/builtin/econ/index.tsx +3 -2
  69. package/src/plugins/builtin/help.test.tsx +2 -2
  70. package/src/plugins/builtin/holders/index.tsx +25 -13
  71. package/src/plugins/builtin/layout-manager.tsx +1 -7
  72. package/src/plugins/builtin/news-wire/index.tsx +1 -1
  73. package/src/plugins/builtin/news-wire/news-detail-view.tsx +3 -2
  74. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +4 -1
  75. package/src/plugins/builtin/news-wire/persisted-articles.ts +7 -1
  76. package/src/plugins/builtin/options.tsx +9 -17
  77. package/src/plugins/builtin/portfolio-list/cli/commands.ts +6 -10
  78. package/src/plugins/builtin/portfolio-list/metrics.ts +11 -0
  79. package/src/plugins/builtin/portfolio-list/mutations.ts +17 -0
  80. package/src/plugins/builtin/portfolio-list/pane.tsx +117 -18
  81. package/src/plugins/builtin/portfolio-list/quick-add.tsx +422 -0
  82. package/src/plugins/builtin/portfolio-list/settings.ts +20 -12
  83. package/src/plugins/builtin/portfolio-list/table.tsx +3 -3
  84. package/src/plugins/builtin/portfolio-list.test.tsx +282 -2
  85. package/src/plugins/builtin/quote-monitor.test.tsx +158 -25
  86. package/src/plugins/builtin/sectors/index.tsx +252 -41
  87. package/src/plugins/builtin/sectors/sector-data.ts +41 -1
  88. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +3 -2
  89. package/src/plugins/builtin/ticker-detail/index.tsx +30 -9
  90. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +25 -15
  91. package/src/plugins/builtin/ticker-detail/pane.tsx +0 -6
  92. package/src/plugins/builtin/ticker-detail/quote-monitor.tsx +442 -63
  93. package/src/plugins/builtin/ticker-detail/settings.ts +84 -3
  94. package/src/plugins/builtin/ticker-detail.test.tsx +2 -2
  95. package/src/plugins/ibkr/trading-pane.tsx +3 -2
  96. package/src/plugins/plugin-runtime.tsx +2 -2
  97. package/src/plugins/prediction-markets/settings.ts +4 -0
  98. package/src/plugins/registry.test.ts +55 -0
  99. package/src/plugins/registry.ts +11 -4
  100. package/src/plugins/ticker-navigation.test.ts +35 -5
  101. package/src/plugins/ticker-navigation.ts +16 -22
  102. package/src/renderers/electrobun/bun/application-menu-click.test.ts +9 -0
  103. package/src/renderers/electrobun/bun/application-menu-click.ts +7 -4
  104. package/src/renderers/electrobun/bun/application-menu.ts +6 -1
  105. package/src/renderers/electrobun/bun/desktop-workspace.ts +47 -29
  106. package/src/renderers/electrobun/bun/index.ts +52 -13
  107. package/src/renderers/electrobun/view/app-services.ts +51 -9
  108. package/src/renderers/electrobun/view/backend-rpc.ts +5 -8
  109. package/src/renderers/electrobun/view/desktop-controls.tsx +4 -4
  110. package/src/renderers/electrobun/view/main.tsx +0 -16
  111. package/src/renderers/electrobun/view/styles.css +23 -21
  112. package/src/renderers/electrobun/view/ui-host.tsx +67 -11
  113. package/src/renderers/opentui/host.tsx +2 -1
  114. package/src/renderers/opentui/start.tsx +1 -1
  115. package/src/renderers/opentui/test-utils.tsx +6 -5
  116. package/src/state/app-bootstrap.ts +3 -3
  117. package/src/state/app-context.test.ts +113 -0
  118. package/src/state/app-context.tsx +8 -4
  119. package/src/theme/colors.ts +13 -14
  120. package/src/types/config.ts +9 -4
  121. package/src/types/plugin.ts +8 -1
  122. package/src/ui/context-menu.tsx +4 -4
  123. package/src/ui/host.tsx +8 -2
  124. package/src/ui/index.tsx +1 -0
  125. package/src/utils/api-client.test.ts +128 -0
  126. package/src/utils/api-client.ts +112 -7
  127. package/src/utils/fuzzy-search.ts +12 -1
  128. package/src/utils/keyboard.ts +31 -0
  129. package/src/utils/ticker-search.test.ts +2 -2
  130. package/src/utils/ticker-search.ts +48 -1
  131. package/src/version.ts +1 -1
@@ -1,5 +1,12 @@
1
1
  import type { AppNotificationRequest, PluginPersistence, PluginResumeState } from "../../types/plugin";
2
- import { apiClient, type ChatChannel, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
2
+ import {
3
+ apiClient,
4
+ type ChatChannel,
5
+ type ChatChannelState,
6
+ type ChatMessage,
7
+ type ChatNotification,
8
+ type PersistedAuthUser,
9
+ } from "../../utils/api-client";
3
10
  import { debugLog } from "../../utils/debug-log";
4
11
  import { toTimestampMillis } from "../../utils/timestamp";
5
12
 
@@ -32,6 +39,7 @@ interface PersistedSessionState {
32
39
 
33
40
  interface PersistedChannelState {
34
41
  draft: string;
42
+ draftClientMessageId?: string | null;
35
43
  replyToId: string | null;
36
44
  lastCursor: string | null;
37
45
  lastViewedMessageId?: string | null;
@@ -44,11 +52,13 @@ interface PersistedTranscript {
44
52
  export interface ChatControllerSnapshot {
45
53
  channelId: string;
46
54
  channels: ChatChannel[];
55
+ channelStates: ChatChannelState[];
47
56
  channelsLoading: boolean;
48
57
  loading: boolean;
49
58
  loadingOlderMessages: boolean;
50
59
  hasOlderMessages: boolean;
51
60
  hasSavedSession: boolean;
61
+ onlineCount: number;
52
62
  user: { id: string; username: string; emailVerified: boolean } | null;
53
63
  messages: ChatMessage[];
54
64
  draft: string;
@@ -56,7 +66,8 @@ export interface ChatControllerSnapshot {
56
66
  unreadMentionCount: number;
57
67
  }
58
68
 
59
- type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
69
+ type ChatConnection = { send: (content: string, replyToId?: string, clientMessageId?: string) => Promise<ChatMessage>; close: () => void };
70
+ type MergeMessagesOptions = { countUnread?: boolean };
60
71
 
61
72
  interface ChannelRuntimeState {
62
73
  hydrated: boolean;
@@ -68,9 +79,12 @@ interface ChannelRuntimeState {
68
79
  messages: ChatMessage[];
69
80
  pendingMessages: ChatMessage[];
70
81
  draft: string;
82
+ draftClientMessageId: string | null;
71
83
  replyToId: string | null;
72
84
  lastCursor: string | null;
73
85
  lastViewedMessageId: string | null;
86
+ notificationsEnabled: boolean;
87
+ unreadCount: number;
74
88
  wsConnection: ChatConnection | null;
75
89
  wsConnected: boolean;
76
90
  draftSyncTimer: ReturnType<typeof setTimeout> | null;
@@ -102,9 +116,12 @@ function createEmptyChannelState(): ChannelRuntimeState {
102
116
  messages: [],
103
117
  pendingMessages: [],
104
118
  draft: "",
119
+ draftClientMessageId: null,
105
120
  replyToId: null,
106
121
  lastCursor: null,
107
122
  lastViewedMessageId: null,
123
+ notificationsEnabled: false,
124
+ unreadCount: 0,
108
125
  wsConnection: null,
109
126
  wsConnected: false,
110
127
  draftSyncTimer: null,
@@ -152,6 +169,23 @@ function formatMentionToast(message: ChatMessage): string {
152
169
  return `@${author} mentioned you: ${snippet}`;
153
170
  }
154
171
 
172
+ function formatReplyToast(message: ChatMessage): string {
173
+ const author = message.user.username || "Someone";
174
+ const normalized = message.content.replace(/\s+/g, " ").trim();
175
+ if (!normalized) {
176
+ return `@${author} replied to you.`;
177
+ }
178
+ const snippet = normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
179
+ return `@${author} replied to you: ${snippet}`;
180
+ }
181
+
182
+ function formatChannelToast(channelId: string, message: ChatMessage): string {
183
+ const author = message.user.username || "Someone";
184
+ const normalized = message.content.replace(/\s+/g, " ").trim();
185
+ const snippet = normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
186
+ return snippet ? `#${channelId} @${author}: ${snippet}` : `#${channelId} @${author} sent a message.`;
187
+ }
188
+
155
189
  function isLegacyTimestampCursor(cursor: string | null): boolean {
156
190
  return !!cursor && ISO_TIMESTAMP_CURSOR.test(cursor);
157
191
  }
@@ -171,6 +205,11 @@ function resolveHydratedCursor(messages: ChatMessage[], persistedCursor: string
171
205
  return persistedCursor === transcriptCursor ? persistedCursor : transcriptCursor;
172
206
  }
173
207
 
208
+ function createClientMessageId(): string {
209
+ const randomUUID = globalThis.crypto?.randomUUID?.();
210
+ return randomUUID ?? `local:${Date.now().toString(36)}:${Math.random().toString(36).slice(2)}`;
211
+ }
212
+
174
213
  function compareMessages(a: ChatMessage, b: ChatMessage): number {
175
214
  // Preserve server/insertion order inside same-timestamp batches so the bottom-most
176
215
  // rendered message stays the one selected first from the composer.
@@ -182,9 +221,11 @@ export class ChatController {
182
221
  private resume: PluginResumeState | null = null;
183
222
  private appActive = true;
184
223
  private hydrated = false;
224
+ private sessionToken: string | null = null;
185
225
  private sessionChecked = false;
186
226
  private user: { id: string; username: string; emailVerified: boolean } | null = null;
187
227
  private channels: ChatChannel[] = [];
228
+ private onlineCount = 0;
188
229
  private channelsLoading = false;
189
230
  private channelsPromise: Promise<void> | null = null;
190
231
  private channelStates = new Map<string, ChannelRuntimeState>();
@@ -193,6 +234,9 @@ export class ChatController {
193
234
  private pendingMessageSeq = 0;
194
235
  private notifyFn: (notification: AppNotificationRequest) => void = () => {};
195
236
  private listeners = new Set<ChannelListenerEntry>();
237
+ private chatNotificationUnsubscribe: (() => void) | null = null;
238
+ private chatPresenceUnsubscribe: (() => void) | null = null;
239
+ private notifiedMessageIds = new Set<string>();
196
240
 
197
241
  private get wsConnection(): ChatConnection | null {
198
242
  return this.ensureChannelState(DEFAULT_CHAT_CHANNEL_ID).wsConnection;
@@ -217,7 +261,8 @@ export class ChatController {
217
261
  }) ?? this.persistence.getState<PersistedSessionState>(SESSION_STATE_KEY, {
218
262
  schemaVersion: SESSION_SCHEMA_VERSION,
219
263
  });
220
- apiClient.setSessionToken(session?.sessionToken ?? null);
264
+ this.sessionToken = session?.sessionToken ?? null;
265
+ apiClient.setSessionToken(this.sessionToken);
221
266
  // WebSocket tokens are short-lived connection credentials. Reusing a persisted
222
267
  // one can trap reconnects on an expired token even while the session cookie is valid.
223
268
  apiClient.setWebSocketToken(null);
@@ -259,11 +304,13 @@ export class ChatController {
259
304
  return {
260
305
  channelId: normalizedChannelId,
261
306
  channels: this.channels,
307
+ channelStates: this.getChannelStateSnapshots(),
262
308
  channelsLoading: this.channelsLoading,
263
309
  loading: !this.sessionChecked || channel.messagesLoading,
264
310
  loadingOlderMessages: channel.olderMessagesLoading,
265
311
  hasOlderMessages: channel.messages.length > 0 && !channel.reachedOldestMessage,
266
- hasSavedSession: !!apiClient.getSessionToken(),
312
+ hasSavedSession: !!this.sessionToken,
313
+ onlineCount: this.onlineCount,
267
314
  user: this.user,
268
315
  messages: this.getVisibleMessages(normalizedChannelId),
269
316
  draft: channel.draft,
@@ -276,6 +323,22 @@ export class ChatController {
276
323
  return this.channels;
277
324
  }
278
325
 
326
+ private getChannelStateSnapshots(): ChatChannelState[] {
327
+ const channelIds = new Set<string>([
328
+ ...this.channels.map((channel) => channel.id),
329
+ ...this.channelStates.keys(),
330
+ ]);
331
+ return [...channelIds].map((channelId) => {
332
+ const channel = this.ensureChannelState(channelId);
333
+ return {
334
+ channelId,
335
+ notificationsEnabled: channel.notificationsEnabled,
336
+ lastReadMessageId: channel.lastViewedMessageId,
337
+ unreadCount: channel.unreadCount,
338
+ };
339
+ });
340
+ }
341
+
279
342
  async refreshChannels(): Promise<void> {
280
343
  if (this.channelsPromise) return this.channelsPromise;
281
344
  this.channelsLoading = true;
@@ -298,6 +361,33 @@ export class ChatController {
298
361
  return request;
299
362
  }
300
363
 
364
+ async refreshPresence(): Promise<void> {
365
+ const presence = await apiClient.getChatPresence();
366
+ this.onlineCount = presence.onlineCount;
367
+ this.emit();
368
+ }
369
+
370
+ async refreshChatState(): Promise<void> {
371
+ if (!this.user?.emailVerified || !this.sessionToken) {
372
+ await this.refreshPresence();
373
+ return;
374
+ }
375
+ const state = await apiClient.getChatState();
376
+ this.channels = normalizeChannels(state.channels);
377
+ this.onlineCount = state.onlineCount;
378
+ for (const entry of state.channelStates) {
379
+ const channel = this.ensureChannelState(entry.channelId);
380
+ channel.notificationsEnabled = entry.notificationsEnabled;
381
+ channel.unreadCount = entry.unreadCount;
382
+ channel.lastViewedMessageId = entry.lastReadMessageId ?? channel.lastViewedMessageId;
383
+ }
384
+ for (const notification of state.notifications) {
385
+ this.handleChatNotification(notification, { countUnread: false });
386
+ }
387
+ this.ensureOpenChannelConnections();
388
+ this.emit();
389
+ }
390
+
301
391
  async resolveRequiredChannelId(channelId: string): Promise<string> {
302
392
  const normalizedChannelId = normalizeChannelId(channelId);
303
393
  if (this.isKnownChannelId(normalizedChannelId)) {
@@ -350,6 +440,9 @@ export class ChatController {
350
440
  schemaVersion: CHANNEL_SCHEMA_VERSION,
351
441
  });
352
442
  channel.draft = persistedChannel?.draft ?? "";
443
+ channel.draftClientMessageId = channel.draft.trim()
444
+ ? persistedChannel?.draftClientMessageId ?? createClientMessageId()
445
+ : null;
353
446
  channel.replyToId = persistedChannel?.replyToId ?? null;
354
447
  const persistedCursor = persistedChannel?.lastCursor ?? null;
355
448
  const persistedViewedMessageId = persistedChannel?.lastViewedMessageId ?? null;
@@ -361,22 +454,26 @@ export class ChatController {
361
454
  });
362
455
  channel.messages = transcript?.value.messages ?? [];
363
456
  channel.lastCursor = resolveHydratedCursor(channel.messages, persistedCursor);
364
- channel.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
457
+ channel.lastViewedMessageId = this.user?.id && this.sessionToken
365
458
  ? persistedViewedMessageId ?? getLatestMessageId(channel.messages)
366
459
  : null;
367
460
  }
368
461
 
369
462
  async refreshSession(): Promise<void> {
370
463
  const token = apiClient.getSessionToken();
464
+ this.sessionToken = token;
371
465
  if (!token) {
372
466
  this.stopVerificationPolling();
373
467
  this.stopSafetyRefresh();
468
+ this.stopRealtimeSubscriptions();
374
469
  this.closeAllConnections();
375
470
  this.user = null;
376
471
  this.sessionChecked = true;
377
472
  for (const channel of this.channelStates.values()) {
378
473
  channel.pendingMessages = [];
379
474
  channel.lastViewedMessageId = null;
475
+ channel.unreadCount = 0;
476
+ channel.notificationsEnabled = false;
380
477
  }
381
478
  this.persistSession();
382
479
  this.emit();
@@ -387,13 +484,17 @@ export class ChatController {
387
484
  if (!session) {
388
485
  this.stopVerificationPolling();
389
486
  this.stopSafetyRefresh();
487
+ this.stopRealtimeSubscriptions();
390
488
  this.closeAllConnections();
391
489
  apiClient.setSessionToken(null);
490
+ this.sessionToken = null;
392
491
  this.user = null;
393
492
  this.sessionChecked = true;
394
493
  for (const channel of this.channelStates.values()) {
395
494
  channel.pendingMessages = [];
396
495
  channel.lastViewedMessageId = null;
496
+ channel.unreadCount = 0;
497
+ channel.notificationsEnabled = false;
397
498
  }
398
499
  this.persistSession();
399
500
  this.emit();
@@ -402,6 +503,7 @@ export class ChatController {
402
503
 
403
504
  const previousIdentity = `${this.user?.id ?? ""}:${normalizeUsername(this.user?.username) ?? ""}`;
404
505
  const nextUser = { id: session.id, username: session.username ?? session.name, emailVerified: !!session.emailVerified };
506
+ this.sessionToken = apiClient.getSessionToken();
405
507
  this.user = nextUser;
406
508
  const nextIdentity = `${nextUser.id}:${normalizeUsername(nextUser.username) ?? ""}`;
407
509
  if (previousIdentity && previousIdentity !== nextIdentity) {
@@ -416,12 +518,15 @@ export class ChatController {
416
518
 
417
519
  if (nextUser?.emailVerified) {
418
520
  this.stopVerificationPolling();
521
+ this.ensureRealtimeSubscriptions();
522
+ await this.refreshChatState().catch(() => {});
419
523
  this.ensureOpenChannelConnections();
420
524
  return;
421
525
  }
422
526
 
423
527
  this.syncVerificationPolling();
424
528
  this.stopSafetyRefresh();
529
+ this.stopRealtimeSubscriptions();
425
530
  this.closeAllConnections();
426
531
  }
427
532
 
@@ -494,7 +599,13 @@ export class ChatController {
494
599
  const normalizedChannelId = normalizeChannelId(channelId);
495
600
  const channel = this.ensureChannelState(normalizedChannelId);
496
601
  if (draft === channel.draft) return;
602
+ const previousDraft = channel.draft;
497
603
  channel.draft = draft;
604
+ if (!draft.trim()) {
605
+ channel.draftClientMessageId = null;
606
+ } else if (!previousDraft.trim() || !channel.draftClientMessageId) {
607
+ channel.draftClientMessageId = createClientMessageId();
608
+ }
498
609
  this.scheduleDraftSync(normalizedChannelId);
499
610
  }
500
611
 
@@ -510,6 +621,33 @@ export class ChatController {
510
621
  this.emit(normalizedChannelId);
511
622
  }
512
623
 
624
+ setChannelNotificationsEnabled(channelId: string, enabled: boolean): void {
625
+ const normalizedChannelId = normalizeChannelId(channelId);
626
+ const channel = this.ensureChannelState(normalizedChannelId);
627
+ if (channel.notificationsEnabled === enabled) return;
628
+
629
+ const previous = channel.notificationsEnabled;
630
+ channel.notificationsEnabled = enabled;
631
+ this.emit();
632
+ this.ensureOpenChannelConnections();
633
+
634
+ void apiClient.updateChatChannelState(normalizedChannelId, {
635
+ notificationsEnabled: enabled,
636
+ }).then((nextState) => {
637
+ this.applyChannelState(nextState);
638
+ this.ensureOpenChannelConnections();
639
+ this.emit();
640
+ }).catch((error) => {
641
+ channel.notificationsEnabled = previous;
642
+ this.ensureOpenChannelConnections();
643
+ this.emit();
644
+ this.notifyFn({
645
+ body: error instanceof Error ? error.message : "Failed to update channel notifications.",
646
+ type: "error",
647
+ });
648
+ });
649
+ }
650
+
513
651
  attachView(): () => void {
514
652
  return this.attachChannelView(DEFAULT_CHAT_CHANNEL_ID);
515
653
  }
@@ -530,12 +668,19 @@ export class ChatController {
530
668
  clearSession(): void {
531
669
  this.stopVerificationPolling();
532
670
  this.stopSafetyRefresh();
671
+ this.stopRealtimeSubscriptions();
533
672
  this.closeAllConnections();
673
+ this.sessionToken = null;
534
674
  this.user = null;
535
675
  this.sessionChecked = false;
536
676
  for (const channel of this.channelStates.values()) {
537
677
  channel.pendingMessages = [];
678
+ channel.draftClientMessageId = channel.draft.trim()
679
+ ? createClientMessageId()
680
+ : null;
538
681
  channel.lastViewedMessageId = null;
682
+ channel.unreadCount = 0;
683
+ channel.notificationsEnabled = false;
539
684
  }
540
685
  this.emit();
541
686
  }
@@ -548,26 +693,32 @@ export class ChatController {
548
693
  return;
549
694
  }
550
695
  this.syncVerificationPolling();
696
+ void this.refreshChatState().catch(() => {});
551
697
  }
552
698
 
553
699
  reset(clearSession = false): void {
554
700
  this.stopVerificationPolling();
555
701
  this.stopSafetyRefresh();
702
+ this.stopRealtimeSubscriptions();
556
703
  this.closeAllConnections();
557
704
  for (const [channelId, channel] of this.channelStates) {
558
705
  this.clearDraftSyncTimer(channel);
559
706
  channel.messages = [];
560
707
  channel.pendingMessages = [];
561
708
  channel.draft = "";
709
+ channel.draftClientMessageId = null;
562
710
  channel.replyToId = null;
563
711
  channel.lastCursor = null;
564
712
  channel.lastViewedMessageId = null;
713
+ channel.unreadCount = 0;
714
+ channel.notificationsEnabled = false;
565
715
  channel.reachedOldestMessage = false;
566
716
  channel.openViewCount = 0;
567
717
  this.persistence?.deleteResource(TRANSCRIPT_KIND, channelId, { sourceKey: TRANSCRIPT_SOURCE });
568
718
  this.persistChannelState(channelId);
569
719
  }
570
720
  if (clearSession) {
721
+ this.sessionToken = null;
571
722
  this.user = null;
572
723
  apiClient.setSessionToken(null);
573
724
  this.persistence?.deleteState(SESSION_STATE_KEY);
@@ -587,6 +738,7 @@ export class ChatController {
587
738
  }
588
739
  this.stopVerificationPolling();
589
740
  this.stopSafetyRefresh();
741
+ this.stopRealtimeSubscriptions();
590
742
  this.closeAllConnections();
591
743
  for (const channel of this.channelStates.values()) {
592
744
  channel.messagesLoading = false;
@@ -597,6 +749,7 @@ export class ChatController {
597
749
  }
598
750
  this.notifyFn = () => {};
599
751
  this.listeners.clear();
752
+ this.notifiedMessageIds.clear();
600
753
  }
601
754
 
602
755
  send(content: string, replyToId?: string): void {
@@ -606,8 +759,11 @@ export class ChatController {
606
759
  sendToChannel(channelId: string, content: string, replyToId?: string): void {
607
760
  const normalizedChannelId = normalizeChannelId(channelId);
608
761
  const channel = this.ensureChannelState(normalizedChannelId);
609
- if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
610
- if (!channel.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
762
+ const messageContent = content.trim();
763
+ if (!messageContent) return;
764
+ if (!this.user?.emailVerified || !this.sessionToken) return;
765
+ if (this.hasPendingSend(channel, messageContent, replyToId)) return;
766
+ if (!channel.wsConnection && this.user?.emailVerified && this.sessionToken) {
611
767
  this.ensureConnection(normalizedChannelId);
612
768
  }
613
769
  const connection = channel.wsConnection;
@@ -616,14 +772,16 @@ export class ChatController {
616
772
  return;
617
773
  }
618
774
 
619
- const pendingMessage = this.createPendingMessage(normalizedChannelId, content, replyToId);
775
+ const clientMessageId = channel.draftClientMessageId ?? createClientMessageId();
776
+ const pendingMessage = this.createPendingMessage(normalizedChannelId, messageContent, replyToId);
620
777
  channel.pendingMessages = [...channel.pendingMessages, pendingMessage];
621
778
  channel.draft = "";
779
+ channel.draftClientMessageId = null;
622
780
  channel.replyToId = null;
623
781
  this.persistChannelState(normalizedChannelId);
624
782
  this.emit(normalizedChannelId);
625
783
 
626
- void connection.send(content, replyToId).then((message) => {
784
+ void connection.send(messageContent, replyToId, clientMessageId).then((message) => {
627
785
  channel.pendingMessages = channel.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
628
786
  this.mergeMessages(normalizedChannelId, [message]);
629
787
  }).catch((error) => {
@@ -638,10 +796,19 @@ export class ChatController {
638
796
  });
639
797
  }
640
798
 
799
+ private hasPendingSend(channel: ChannelRuntimeState, content: string, replyToId?: string): boolean {
800
+ const replyToKey = replyToId ?? null;
801
+ return channel.pendingMessages.some((message) => (
802
+ message.clientStatus === "sending"
803
+ && message.content === content
804
+ && message.replyToId === replyToKey
805
+ ));
806
+ }
807
+
641
808
  ensureConnection(channelId = DEFAULT_CHAT_CHANNEL_ID): void {
642
809
  const normalizedChannelId = normalizeChannelId(channelId);
643
810
  const channel = this.ensureChannelState(normalizedChannelId);
644
- if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
811
+ if (!this.user?.emailVerified || !this.sessionToken) {
645
812
  this.stopSafetyRefresh();
646
813
  return;
647
814
  }
@@ -675,8 +842,15 @@ export class ChatController {
675
842
  }
676
843
  }
677
844
 
845
+ private applyChannelState(state: ChatChannelState): void {
846
+ const channel = this.ensureChannelState(state.channelId);
847
+ channel.notificationsEnabled = state.notificationsEnabled;
848
+ channel.unreadCount = state.unreadCount;
849
+ channel.lastViewedMessageId = state.lastReadMessageId ?? channel.lastViewedMessageId;
850
+ }
851
+
678
852
  private syncVerificationPolling(): void {
679
- if (!this.appActive || !apiClient.getSessionToken() || !this.user || this.user.emailVerified) {
853
+ if (!this.appActive || !this.sessionToken || !this.user || this.user.emailVerified) {
680
854
  this.stopVerificationPolling();
681
855
  return;
682
856
  }
@@ -686,6 +860,27 @@ export class ChatController {
686
860
  }, VERIFICATION_POLL_MS);
687
861
  }
688
862
 
863
+ private ensureRealtimeSubscriptions(): void {
864
+ if (!this.chatNotificationUnsubscribe) {
865
+ this.chatNotificationUnsubscribe = apiClient.subscribeChatNotifications((notification) => {
866
+ this.handleChatNotification(notification);
867
+ });
868
+ }
869
+ if (!this.chatPresenceUnsubscribe) {
870
+ this.chatPresenceUnsubscribe = apiClient.subscribeChatPresence((onlineCount) => {
871
+ this.onlineCount = onlineCount;
872
+ this.emit();
873
+ });
874
+ }
875
+ }
876
+
877
+ private stopRealtimeSubscriptions(): void {
878
+ this.chatNotificationUnsubscribe?.();
879
+ this.chatNotificationUnsubscribe = null;
880
+ this.chatPresenceUnsubscribe?.();
881
+ this.chatPresenceUnsubscribe = null;
882
+ }
883
+
689
884
  private stopVerificationPolling(): void {
690
885
  if (!this.verificationPollTimer) return;
691
886
  clearInterval(this.verificationPollTimer);
@@ -695,7 +890,7 @@ export class ChatController {
695
890
  private startSafetyRefresh(): void {
696
891
  if (this.safetyRefreshTimer) return;
697
892
  this.safetyRefreshTimer = setInterval(() => {
698
- if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
893
+ if (!this.user?.emailVerified || !this.sessionToken) {
699
894
  this.stopSafetyRefresh();
700
895
  return;
701
896
  }
@@ -723,10 +918,16 @@ export class ChatController {
723
918
  private ensureOpenChannelConnections(): void {
724
919
  const channelIds = new Set<string>([DEFAULT_CHAT_CHANNEL_ID]);
725
920
  for (const [channelId, channel] of this.channelStates) {
726
- if (channel.openViewCount > 0 || channel.wsConnection) {
921
+ if (channel.openViewCount > 0 || channel.notificationsEnabled) {
727
922
  channelIds.add(channelId);
728
923
  }
729
924
  }
925
+ for (const [channelId, channel] of this.channelStates) {
926
+ if (channelIds.has(channelId) || !channel.wsConnection || channel.openViewCount > 0) continue;
927
+ channel.wsConnection.close();
928
+ channel.wsConnection = null;
929
+ channel.wsConnected = false;
930
+ }
730
931
  for (const channelId of channelIds) {
731
932
  this.ensureConnection(channelId);
732
933
  }
@@ -734,7 +935,7 @@ export class ChatController {
734
935
 
735
936
  private getSafetyRefreshChannelIds(): string[] {
736
937
  const active = [...this.channelStates.entries()]
737
- .filter(([, channel]) => channel.openViewCount > 0 || channel.wsConnection)
938
+ .filter(([, channel]) => channel.openViewCount > 0 || channel.notificationsEnabled || channel.wsConnection)
738
939
  .map(([channelId]) => channelId);
739
940
  return active.length > 0 ? active : [DEFAULT_CHAT_CHANNEL_ID];
740
941
  }
@@ -743,6 +944,8 @@ export class ChatController {
743
944
  const channel = this.ensureChannelState(channelId);
744
945
  const legacyTimestampCursor = isLegacyTimestampCursor(channel.lastCursor);
745
946
  const hasIncrementalCursor = !!channel.lastCursor;
947
+ const hadMessages = channel.messages.length > 0;
948
+ const countIncrementalUnread = hadMessages && hasIncrementalCursor && !legacyTimestampCursor;
746
949
 
747
950
  try {
748
951
  const messages = await apiClient.getMessages(channelId, {
@@ -753,7 +956,7 @@ export class ChatController {
753
956
  channel.reachedOldestMessage = true;
754
957
  }
755
958
  if (messages.length > 0) {
756
- this.mergeMessages(channelId, messages);
959
+ this.mergeMessages(channelId, messages, { countUnread: countIncrementalUnread });
757
960
  return;
758
961
  }
759
962
  if (legacyTimestampCursor) {
@@ -762,7 +965,7 @@ export class ChatController {
762
965
  channel.reachedOldestMessage = true;
763
966
  }
764
967
  if (fullRefresh.length > 0) {
765
- this.mergeMessages(channelId, fullRefresh);
968
+ this.mergeMessages(channelId, fullRefresh, { countUnread: false });
766
969
  return;
767
970
  }
768
971
  channel.lastCursor = null;
@@ -775,7 +978,7 @@ export class ChatController {
775
978
  channel.reachedOldestMessage = true;
776
979
  }
777
980
  if (messages.length > 0) {
778
- this.mergeMessages(channelId, messages);
981
+ this.mergeMessages(channelId, messages, { countUnread: false });
779
982
  return;
780
983
  }
781
984
  this.persistChannelState(channelId);
@@ -795,20 +998,20 @@ export class ChatController {
795
998
  this.persistChannelState(channelId);
796
999
  return;
797
1000
  }
798
- this.mergeMessages(channelId, messages, { notifyMentions: false });
1001
+ this.mergeMessages(channelId, messages, { countUnread: false });
799
1002
  }
800
1003
 
801
1004
  private mergeMessages(
802
1005
  channelIdOrMessages: string | ChatMessage[],
803
- maybeMessages?: ChatMessage[] | { notifyMentions?: boolean },
804
- maybeOptions?: { notifyMentions?: boolean },
1006
+ maybeMessages?: ChatMessage[] | MergeMessagesOptions,
1007
+ maybeOptions?: MergeMessagesOptions,
805
1008
  ): void {
806
1009
  const channelId = Array.isArray(channelIdOrMessages) ? DEFAULT_CHAT_CHANNEL_ID : channelIdOrMessages;
807
1010
  const messages = Array.isArray(channelIdOrMessages)
808
1011
  ? channelIdOrMessages
809
1012
  : (maybeMessages as ChatMessage[] | undefined) ?? [];
810
1013
  const options = Array.isArray(channelIdOrMessages)
811
- ? maybeMessages as { notifyMentions?: boolean } | undefined
1014
+ ? maybeMessages as MergeMessagesOptions | undefined
812
1015
  : maybeOptions;
813
1016
  const channel = this.ensureChannelState(channelId);
814
1017
  this.reconcilePendingMessages(channelId, messages);
@@ -824,11 +1027,11 @@ export class ChatController {
824
1027
  channel.messages = [...merged.values()]
825
1028
  .sort(compareMessages);
826
1029
  channel.lastCursor = channel.messages[channel.messages.length - 1]?.id ?? channel.lastCursor;
1030
+ const freshIncoming = [...incoming.values()].filter((message) => message.user.id !== this.user?.id);
827
1031
  if (channel.openViewCount > 0) {
828
1032
  this.markViewedThroughLatestMessage(channelId, false);
829
- }
830
- if (options?.notifyMentions !== false) {
831
- this.trackUnreadMentions(channelId, [...incoming.values()]);
1033
+ } else if (freshIncoming.length > 0 && options?.countUnread !== false) {
1034
+ channel.unreadCount += freshIncoming.length;
832
1035
  }
833
1036
  this.persistTranscript(channelId);
834
1037
  this.emit(channelId);
@@ -836,7 +1039,7 @@ export class ChatController {
836
1039
 
837
1040
  private persistSession(): void {
838
1041
  const value = {
839
- sessionToken: apiClient.getSessionToken(),
1042
+ sessionToken: this.sessionToken,
840
1043
  user: this.user,
841
1044
  } satisfies PersistedSessionState;
842
1045
  this.resume?.setState(SESSION_STATE_KEY, value, {
@@ -857,6 +1060,7 @@ export class ChatController {
857
1060
  const channel = this.ensureChannelState(channelId);
858
1061
  const value = {
859
1062
  draft: channel.draft,
1063
+ draftClientMessageId: channel.draftClientMessageId,
860
1064
  replyToId: channel.replyToId,
861
1065
  lastCursor: channel.lastCursor,
862
1066
  lastViewedMessageId: channel.lastViewedMessageId,
@@ -911,27 +1115,27 @@ export class ChatController {
911
1115
  this.persistChannelState(channelId);
912
1116
  }
913
1117
 
914
- private trackUnreadMentions(channelId: string, messages: ChatMessage[]): void {
915
- const channel = this.ensureChannelState(channelId);
916
- if (channel.openViewCount > 0) {
917
- return;
1118
+ private handleChatNotification(notification: ChatNotification, options: { countUnread?: boolean } = {}): void {
1119
+ this.mergeMessages(notification.channelId, [notification.message], { countUnread: options.countUnread });
1120
+ const channel = this.ensureChannelState(notification.channelId);
1121
+ if (channel.openViewCount === 0) {
1122
+ this.notifyServerMessage(notification);
918
1123
  }
1124
+ void apiClient.markChatNotificationsDelivered([notification.id]).catch(() => {});
1125
+ }
919
1126
 
920
- const freshMentions = this.getMentionMessages(messages);
921
- if (freshMentions.length === 0) return;
922
-
923
- if (freshMentions.length === 1) {
924
- this.notifyFn({
925
- title: "Gloomberb chat",
926
- body: formatMentionToast(freshMentions[0]!),
927
- type: "info",
928
- desktop: "when-inactive",
929
- });
930
- return;
931
- }
1127
+ private notifyServerMessage(notification: ChatNotification): void {
1128
+ if (this.notifiedMessageIds.has(notification.messageId)) return;
1129
+ this.notifiedMessageIds.add(notification.messageId);
1130
+ const body = notification.type === "reply"
1131
+ ? formatReplyToast(notification.message)
1132
+ : notification.type === "mention"
1133
+ ? formatMentionToast(notification.message)
1134
+ : formatChannelToast(notification.channelId, notification.message);
932
1135
  this.notifyFn({
933
1136
  title: "Gloomberb chat",
934
- body: `${freshMentions.length} new mentions in #${channelId}.`,
1137
+ subtitle: `#${notification.channelId}`,
1138
+ body,
935
1139
  type: "info",
936
1140
  desktop: "when-inactive",
937
1141
  });
@@ -966,13 +1170,23 @@ export class ChatController {
966
1170
  private markViewedThroughLatestMessage(channelId: string, persist = true): boolean {
967
1171
  const channel = this.ensureChannelState(channelId);
968
1172
  const latestMessageId = channel.messages[channel.messages.length - 1]?.id ?? null;
969
- if (channel.lastViewedMessageId === latestMessageId) {
1173
+ const previousUnreadCount = channel.unreadCount;
1174
+ if (channel.lastViewedMessageId === latestMessageId && previousUnreadCount === 0) {
970
1175
  return false;
971
1176
  }
972
1177
  channel.lastViewedMessageId = latestMessageId;
1178
+ channel.unreadCount = 0;
973
1179
  if (persist) {
974
1180
  this.persistChannelState(channelId);
975
1181
  }
1182
+ if (latestMessageId && this.user?.emailVerified && this.sessionToken) {
1183
+ void apiClient.updateChatChannelState(channelId, {
1184
+ readThroughMessageId: latestMessageId,
1185
+ }).then((state) => {
1186
+ this.applyChannelState(state);
1187
+ this.emit();
1188
+ }).catch(() => {});
1189
+ }
976
1190
  return true;
977
1191
  }
978
1192
 
@@ -1001,7 +1215,7 @@ export class ChatController {
1001
1215
  replyTo: replyToMessage
1002
1216
  ? {
1003
1217
  content: replyToMessage.content,
1004
- user: { username: replyToMessage.user.username },
1218
+ user: { id: replyToMessage.user.id, username: replyToMessage.user.username },
1005
1219
  }
1006
1220
  : null,
1007
1221
  clientStatus: "sending",