gloomberb 0.7.0 → 0.7.2

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 (126) hide show
  1. package/package.json +1 -1
  2. package/src/app.tsx +149 -51
  3. package/src/capabilities/factories.ts +3 -0
  4. package/src/components/chart/chart-renderer.ts +24 -12
  5. package/src/components/chart/chart.test.ts +43 -0
  6. package/src/components/chart/comparison-chart-renderer.test.ts +26 -0
  7. package/src/components/chart/comparison-chart-renderer.ts +13 -6
  8. package/src/components/chart/comparison-stock-chart.tsx +15 -12
  9. package/src/components/chart/price-axis-labels.tsx +114 -0
  10. package/src/components/chart/static-chart-surface.tsx +2 -0
  11. package/src/components/chart/stock-chart.tsx +31 -20
  12. package/src/components/chart/time-axis-label.tsx +78 -1
  13. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +0 -28
  14. package/src/components/command-bar/command-bar.test.tsx +114 -31
  15. package/src/components/command-bar/command-bar.tsx +587 -335
  16. package/src/components/command-bar/command-registry.ts +0 -10
  17. package/src/components/command-bar/helpers.ts +2 -5
  18. package/src/components/command-bar/list-model.ts +1 -2
  19. package/src/components/command-bar/theme-picker.tsx +254 -0
  20. package/src/components/command-bar/view-model.test.ts +4 -4
  21. package/src/components/command-bar/view-model.ts +1 -1
  22. package/src/components/command-bar/workflow-types.ts +1 -2
  23. package/src/components/layout/detached-pane-shell.tsx +29 -10
  24. package/src/components/layout/floating-pane.tsx +5 -2
  25. package/src/components/layout/header.tsx +2 -0
  26. package/src/components/layout/pane-content.test.tsx +64 -0
  27. package/src/components/layout/pane-content.tsx +2 -0
  28. package/src/components/layout/pane-sizing.ts +9 -2
  29. package/src/components/layout/pane-title.ts +4 -1
  30. package/src/components/layout/pane.tsx +11 -5
  31. package/src/components/layout/shell.test.tsx +198 -0
  32. package/src/components/layout/shell.tsx +33 -2
  33. package/src/components/layout/status-bar.tsx +2 -0
  34. package/src/components/speedometer-gauge.tsx +29 -25
  35. package/src/components/table-view-shared.tsx +85 -3
  36. package/src/components/ticker-list-table-view.tsx +16 -2
  37. package/src/components/ticker-list-table.test.tsx +82 -1
  38. package/src/components/ticker-list-table.tsx +22 -9
  39. package/src/components/ui/data-table.tsx +12 -9
  40. package/src/components/ui/frame.tsx +2 -0
  41. package/src/components/ui/table-layout.test.ts +27 -1
  42. package/src/components/ui/table-layout.ts +39 -1
  43. package/src/components/ui/tabs.tsx +34 -1
  44. package/src/components/ui/ui.test.tsx +68 -1
  45. package/src/core/state/app-state.ts +4 -14
  46. package/src/market-data/coordinator.test.ts +75 -0
  47. package/src/market-data/coordinator.ts +218 -48
  48. package/src/news/aggregator.test.ts +49 -1
  49. package/src/news/aggregator.ts +48 -0
  50. package/src/news/hooks.ts +6 -2
  51. package/src/plugins/builtin/ai/screener-pane.tsx +3 -2
  52. package/src/plugins/builtin/analytics/index.tsx +1 -18
  53. package/src/plugins/builtin/chat-controller.test.ts +83 -1
  54. package/src/plugins/builtin/chat-controller.ts +491 -243
  55. package/src/plugins/builtin/chat.test.tsx +425 -7
  56. package/src/plugins/builtin/chat.tsx +1109 -211
  57. package/src/plugins/builtin/cloud-tweets.tsx +1 -0
  58. package/src/plugins/builtin/fear-greed/index.tsx +148 -39
  59. package/src/plugins/builtin/holders/index.test.ts +13 -1
  60. package/src/plugins/builtin/holders/index.tsx +54 -14
  61. package/src/plugins/builtin/market-movers/index.tsx +12 -18
  62. package/src/plugins/builtin/market-movers/screener.test.ts +39 -1
  63. package/src/plugins/builtin/market-movers/screener.ts +70 -17
  64. package/src/plugins/builtin/news-wire/breaking-pane.tsx +3 -2
  65. package/src/plugins/builtin/news-wire/industry-pane.tsx +4 -18
  66. package/src/plugins/builtin/news-wire/news-detail-view.tsx +46 -4
  67. package/src/plugins/builtin/news-wire/news-preset-pane.tsx +3 -2
  68. package/src/plugins/builtin/notes.tsx +1 -0
  69. package/src/plugins/builtin/options.tsx +2 -16
  70. package/src/plugins/builtin/portfolio-list/pane.tsx +16 -39
  71. package/src/plugins/builtin/research/index.test.ts +94 -0
  72. package/src/plugins/builtin/research/index.tsx +159 -12
  73. package/src/plugins/builtin/ticker-detail/pane.tsx +28 -22
  74. package/src/plugins/builtin/ticker-detail.test.tsx +38 -0
  75. package/src/plugins/plugin-runtime.test.tsx +34 -0
  76. package/src/plugins/plugin-runtime.tsx +7 -2
  77. package/src/plugins/prediction-markets/controller.ts +0 -15
  78. package/src/plugins/prediction-markets/detail/pane.tsx +1 -0
  79. package/src/plugins/registry.ts +3 -3
  80. package/src/plugins/ticker-navigation.test.ts +66 -0
  81. package/src/plugins/ticker-navigation.ts +44 -0
  82. package/src/react/input.ts +1 -1
  83. package/src/renderers/electrobun/bun/index.ts +32 -11
  84. package/src/renderers/electrobun/bun/window-focus.ts +37 -0
  85. package/src/renderers/electrobun/shared/protocol.ts +7 -1
  86. package/src/renderers/electrobun/view/app-services.ts +88 -31
  87. package/src/renderers/electrobun/view/backend-rpc.ts +15 -0
  88. package/src/renderers/electrobun/view/data-table.tsx +111 -96
  89. package/src/renderers/electrobun/view/desktop-controls.tsx +8 -0
  90. package/src/renderers/electrobun/view/desktop-window-bridge.ts +12 -1
  91. package/src/renderers/electrobun/view/dialog-host.tsx +2 -0
  92. package/src/renderers/electrobun/view/input-host.tsx +1 -1
  93. package/src/renderers/electrobun/view/main.tsx +18 -1
  94. package/src/renderers/electrobun/view/styles.css +6 -8
  95. package/src/renderers/electrobun/view/toast-host.tsx +29 -3
  96. package/src/renderers/electrobun/view/ui-host.tsx +101 -5
  97. package/src/renderers/opentui/input-host.tsx +1 -1
  98. package/src/sources/gloomberb-cloud.test.ts +86 -1
  99. package/src/sources/gloomberb-cloud.ts +117 -21
  100. package/src/sources/provider-router.test.ts +122 -0
  101. package/src/sources/provider-router.ts +198 -11
  102. package/src/sources/yahoo-finance.test.ts +44 -0
  103. package/src/sources/yahoo-finance.ts +6 -0
  104. package/src/state/app-bootstrap.ts +20 -7
  105. package/src/state/app-context-selector.test.tsx +189 -4
  106. package/src/state/app-context.test.ts +20 -0
  107. package/src/state/app-context.tsx +48 -16
  108. package/src/state/use-inline-tickers.ts +13 -10
  109. package/src/theme/colors.test.ts +75 -0
  110. package/src/theme/colors.ts +64 -3
  111. package/src/theme/theme-context.tsx +26 -0
  112. package/src/types/capability-route-source.ts +1 -0
  113. package/src/types/data-provider.ts +15 -1
  114. package/src/types/desktop-window.ts +6 -0
  115. package/src/types/financials.ts +2 -0
  116. package/src/types/plugin.ts +1 -1
  117. package/src/ui/host.tsx +14 -2
  118. package/src/ui/index.tsx +1 -0
  119. package/src/utils/api-client.test.ts +32 -0
  120. package/src/utils/api-client.ts +119 -18
  121. package/src/utils/double-escape-close.test.ts +33 -0
  122. package/src/utils/double-escape-close.ts +40 -0
  123. package/src/utils/throttled-fetch.test.ts +22 -0
  124. package/src/utils/throttled-fetch.ts +3 -1
  125. package/src/utils/ticker-search.ts +53 -9
  126. package/src/version.ts +1 -1
@@ -1,10 +1,10 @@
1
1
  import type { AppNotificationRequest, PluginPersistence, PluginResumeState } from "../../types/plugin";
2
- import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
2
+ import { apiClient, type ChatChannel, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
3
3
  import { debugLog } from "../../utils/debug-log";
4
4
  import { toTimestampMillis } from "../../utils/timestamp";
5
5
 
6
6
  const SESSION_STATE_KEY = "session";
7
- const CHANNEL_STATE_KEY = "channel:everyone";
7
+ const DEFAULT_CHAT_CHANNEL_ID = "everyone";
8
8
  const TRANSCRIPT_KIND = "channel-transcript";
9
9
  const TRANSCRIPT_SOURCE = "server";
10
10
  const MESSAGE_PAGE_SIZE = 50;
@@ -42,6 +42,9 @@ interface PersistedTranscript {
42
42
  }
43
43
 
44
44
  export interface ChatControllerSnapshot {
45
+ channelId: string;
46
+ channels: ChatChannel[];
47
+ channelsLoading: boolean;
45
48
  loading: boolean;
46
49
  loadingOlderMessages: boolean;
47
50
  hasOlderMessages: boolean;
@@ -55,6 +58,74 @@ export interface ChatControllerSnapshot {
55
58
 
56
59
  type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
57
60
 
61
+ interface ChannelRuntimeState {
62
+ hydrated: boolean;
63
+ messagesLoading: boolean;
64
+ olderMessagesLoading: boolean;
65
+ refreshMessagesPromise: Promise<void> | null;
66
+ loadOlderMessagesPromise: Promise<void> | null;
67
+ reachedOldestMessage: boolean;
68
+ messages: ChatMessage[];
69
+ pendingMessages: ChatMessage[];
70
+ draft: string;
71
+ replyToId: string | null;
72
+ lastCursor: string | null;
73
+ lastViewedMessageId: string | null;
74
+ wsConnection: ChatConnection | null;
75
+ wsConnected: boolean;
76
+ draftSyncTimer: ReturnType<typeof setTimeout> | null;
77
+ openViewCount: number;
78
+ }
79
+
80
+ interface ChannelListenerEntry {
81
+ channelId: string;
82
+ listener: (snapshot: ChatControllerSnapshot) => void;
83
+ }
84
+
85
+ function channelStateKey(channelId: string): string {
86
+ return `channel:${channelId}`;
87
+ }
88
+
89
+ function normalizeChannelId(channelId: string | null | undefined): string {
90
+ const normalized = channelId?.trim();
91
+ return normalized || DEFAULT_CHAT_CHANNEL_ID;
92
+ }
93
+
94
+ function createEmptyChannelState(): ChannelRuntimeState {
95
+ return {
96
+ hydrated: false,
97
+ messagesLoading: false,
98
+ olderMessagesLoading: false,
99
+ refreshMessagesPromise: null,
100
+ loadOlderMessagesPromise: null,
101
+ reachedOldestMessage: false,
102
+ messages: [],
103
+ pendingMessages: [],
104
+ draft: "",
105
+ replyToId: null,
106
+ lastCursor: null,
107
+ lastViewedMessageId: null,
108
+ wsConnection: null,
109
+ wsConnected: false,
110
+ draftSyncTimer: null,
111
+ openViewCount: 0,
112
+ };
113
+ }
114
+
115
+ function normalizeChannels(channels: ChatChannel[]): ChatChannel[] {
116
+ const byId = new Map<string, ChatChannel>();
117
+ for (const channel of channels) {
118
+ const id = channel.id.trim();
119
+ if (!id) continue;
120
+ byId.set(id, {
121
+ ...channel,
122
+ id,
123
+ name: channel.name.trim() || id,
124
+ });
125
+ }
126
+ return [...byId.values()];
127
+ }
128
+
58
129
  function normalizeUsername(username: string | null | undefined): string | null {
59
130
  const trimmed = username?.trim();
60
131
  return trimmed ? trimmed.toLowerCase() : null;
@@ -112,27 +183,20 @@ export class ChatController {
112
183
  private appActive = true;
113
184
  private hydrated = false;
114
185
  private sessionChecked = false;
115
- private messagesLoading = false;
116
- private olderMessagesLoading = false;
117
- private refreshMessagesPromise: Promise<void> | null = null;
118
- private loadOlderMessagesPromise: Promise<void> | null = null;
119
- private reachedOldestMessage = false;
120
186
  private user: { id: string; username: string; emailVerified: boolean } | null = null;
121
- private messages: ChatMessage[] = [];
122
- private pendingMessages: ChatMessage[] = [];
123
- private draft = "";
124
- private replyToId: string | null = null;
125
- private lastCursor: string | null = null;
126
- private lastViewedMessageId: string | null = null;
127
- private wsConnection: ChatConnection | null = null;
128
- private wsConnected = false;
187
+ private channels: ChatChannel[] = [];
188
+ private channelsLoading = false;
189
+ private channelsPromise: Promise<void> | null = null;
190
+ private channelStates = new Map<string, ChannelRuntimeState>();
129
191
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
130
192
  private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
131
- private draftSyncTimer: ReturnType<typeof setTimeout> | null = null;
132
- private openViewCount = 0;
133
193
  private pendingMessageSeq = 0;
134
194
  private notifyFn: (notification: AppNotificationRequest) => void = () => {};
135
- private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
195
+ private listeners = new Set<ChannelListenerEntry>();
196
+
197
+ private get wsConnection(): ChatConnection | null {
198
+ return this.ensureChannelState(DEFAULT_CHAT_CHANNEL_ID).wsConnection;
199
+ }
136
200
 
137
201
  attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
138
202
  this.persistence = persistence;
@@ -166,64 +230,154 @@ export class ChatController {
166
230
  }
167
231
  : null;
168
232
  this.sessionChecked = true;
169
-
170
- const channel = this.resume?.getState<PersistedChannelState>(CHANNEL_STATE_KEY, {
171
- schemaVersion: CHANNEL_SCHEMA_VERSION,
172
- }) ?? this.persistence.getState<PersistedChannelState>(CHANNEL_STATE_KEY, {
173
- schemaVersion: CHANNEL_SCHEMA_VERSION,
174
- });
175
- this.draft = channel?.draft ?? "";
176
- this.replyToId = channel?.replyToId ?? null;
177
- const persistedCursor = channel?.lastCursor ?? null;
178
- const persistedViewedMessageId = channel?.lastViewedMessageId ?? null;
179
-
180
- const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, "everyone", {
181
- sourceKey: TRANSCRIPT_SOURCE,
182
- schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
183
- allowExpired: true,
184
- });
185
- this.messages = transcript?.value.messages ?? [];
186
- this.lastCursor = resolveHydratedCursor(this.messages, persistedCursor);
187
- this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
188
- ? persistedViewedMessageId ?? getLatestMessageId(this.messages)
189
- : null;
233
+ this.ensureChannelState(DEFAULT_CHAT_CHANNEL_ID);
190
234
  this.syncVerificationPolling();
191
235
  }
192
236
 
193
- subscribe(listener: (snapshot: ChatControllerSnapshot) => void): () => void {
194
- this.listeners.add(listener);
195
- listener(this.getSnapshot());
237
+ subscribe(listener: (snapshot: ChatControllerSnapshot) => void): () => void;
238
+ subscribe(channelId: string, listener: (snapshot: ChatControllerSnapshot) => void): () => void;
239
+ subscribe(
240
+ channelIdOrListener: string | ((snapshot: ChatControllerSnapshot) => void),
241
+ maybeListener?: (snapshot: ChatControllerSnapshot) => void,
242
+ ): () => void {
243
+ const channelId = typeof channelIdOrListener === "string"
244
+ ? normalizeChannelId(channelIdOrListener)
245
+ : DEFAULT_CHAT_CHANNEL_ID;
246
+ const listener = typeof channelIdOrListener === "function" ? channelIdOrListener : maybeListener;
247
+ if (!listener) return () => {};
248
+ const entry = { channelId, listener };
249
+ this.listeners.add(entry);
250
+ listener(this.getSnapshot(channelId));
196
251
  return () => {
197
- this.listeners.delete(listener);
252
+ this.listeners.delete(entry);
198
253
  };
199
254
  }
200
255
 
201
- getSnapshot(): ChatControllerSnapshot {
256
+ getSnapshot(channelId = DEFAULT_CHAT_CHANNEL_ID): ChatControllerSnapshot {
257
+ const normalizedChannelId = normalizeChannelId(channelId);
258
+ const channel = this.ensureChannelState(normalizedChannelId);
202
259
  return {
203
- loading: !this.sessionChecked || this.messagesLoading,
204
- loadingOlderMessages: this.olderMessagesLoading,
205
- hasOlderMessages: this.messages.length > 0 && !this.reachedOldestMessage,
260
+ channelId: normalizedChannelId,
261
+ channels: this.channels,
262
+ channelsLoading: this.channelsLoading,
263
+ loading: !this.sessionChecked || channel.messagesLoading,
264
+ loadingOlderMessages: channel.olderMessagesLoading,
265
+ hasOlderMessages: channel.messages.length > 0 && !channel.reachedOldestMessage,
206
266
  hasSavedSession: !!apiClient.getSessionToken(),
207
267
  user: this.user,
208
- messages: this.getVisibleMessages(),
209
- draft: this.draft,
210
- replyToId: this.replyToId,
211
- unreadMentionCount: this.getUnreadMentionCount(),
268
+ messages: this.getVisibleMessages(normalizedChannelId),
269
+ draft: channel.draft,
270
+ replyToId: channel.replyToId,
271
+ unreadMentionCount: this.getUnreadMentionCount(normalizedChannelId),
212
272
  };
213
273
  }
214
274
 
275
+ getChannels(): ChatChannel[] {
276
+ return this.channels;
277
+ }
278
+
279
+ async refreshChannels(): Promise<void> {
280
+ if (this.channelsPromise) return this.channelsPromise;
281
+ this.channelsLoading = true;
282
+ this.emit();
283
+
284
+ const request = apiClient.getChannels()
285
+ .then((channels) => {
286
+ this.channels = normalizeChannels(channels);
287
+ })
288
+ .catch(() => {
289
+ // Keep the last backend-provided list if the refresh fails.
290
+ })
291
+ .finally(() => {
292
+ this.channelsLoading = false;
293
+ this.channelsPromise = null;
294
+ this.emit();
295
+ });
296
+
297
+ this.channelsPromise = request;
298
+ return request;
299
+ }
300
+
301
+ async resolveRequiredChannelId(channelId: string): Promise<string> {
302
+ const normalizedChannelId = normalizeChannelId(channelId);
303
+ if (this.isKnownChannelId(normalizedChannelId)) {
304
+ return normalizedChannelId;
305
+ }
306
+ await this.refreshChannels();
307
+ if (this.isKnownChannelId(normalizedChannelId)) {
308
+ return normalizedChannelId;
309
+ }
310
+ throw new Error(`Unknown chat channel "#${normalizedChannelId}".`);
311
+ }
312
+
313
+ async resolvePreferredChannelId(channelId: string | null | undefined): Promise<string> {
314
+ const normalizedChannelId = normalizeChannelId(channelId);
315
+ if (this.isKnownChannelId(normalizedChannelId)) {
316
+ return normalizedChannelId;
317
+ }
318
+ await this.refreshChannels();
319
+ if (this.isKnownChannelId(normalizedChannelId)) {
320
+ return normalizedChannelId;
321
+ }
322
+ if (this.isKnownChannelId(DEFAULT_CHAT_CHANNEL_ID)) {
323
+ return DEFAULT_CHAT_CHANNEL_ID;
324
+ }
325
+ return this.channels[0]?.id ?? DEFAULT_CHAT_CHANNEL_ID;
326
+ }
327
+
328
+ private isKnownChannelId(channelId: string): boolean {
329
+ return this.channels.some((channel) => channel.id === channelId);
330
+ }
331
+
332
+ private ensureChannelState(channelId: string): ChannelRuntimeState {
333
+ const normalizedChannelId = normalizeChannelId(channelId);
334
+ const existing = this.channelStates.get(normalizedChannelId);
335
+ if (existing) return existing;
336
+
337
+ const channel = createEmptyChannelState();
338
+ this.channelStates.set(normalizedChannelId, channel);
339
+ this.hydrateChannelState(normalizedChannelId, channel);
340
+ return channel;
341
+ }
342
+
343
+ private hydrateChannelState(channelId: string, channel: ChannelRuntimeState): void {
344
+ if (channel.hydrated || !this.persistence) return;
345
+ channel.hydrated = true;
346
+
347
+ const persistedChannel = this.resume?.getState<PersistedChannelState>(channelStateKey(channelId), {
348
+ schemaVersion: CHANNEL_SCHEMA_VERSION,
349
+ }) ?? this.persistence.getState<PersistedChannelState>(channelStateKey(channelId), {
350
+ schemaVersion: CHANNEL_SCHEMA_VERSION,
351
+ });
352
+ channel.draft = persistedChannel?.draft ?? "";
353
+ channel.replyToId = persistedChannel?.replyToId ?? null;
354
+ const persistedCursor = persistedChannel?.lastCursor ?? null;
355
+ const persistedViewedMessageId = persistedChannel?.lastViewedMessageId ?? null;
356
+
357
+ const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, channelId, {
358
+ sourceKey: TRANSCRIPT_SOURCE,
359
+ schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
360
+ allowExpired: true,
361
+ });
362
+ channel.messages = transcript?.value.messages ?? [];
363
+ channel.lastCursor = resolveHydratedCursor(channel.messages, persistedCursor);
364
+ channel.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
365
+ ? persistedViewedMessageId ?? getLatestMessageId(channel.messages)
366
+ : null;
367
+ }
368
+
215
369
  async refreshSession(): Promise<void> {
216
370
  const token = apiClient.getSessionToken();
217
371
  if (!token) {
218
372
  this.stopVerificationPolling();
219
373
  this.stopSafetyRefresh();
220
- this.wsConnection?.close();
221
- this.wsConnection = null;
222
- this.wsConnected = false;
374
+ this.closeAllConnections();
223
375
  this.user = null;
224
376
  this.sessionChecked = true;
225
- this.pendingMessages = [];
226
- this.lastViewedMessageId = null;
377
+ for (const channel of this.channelStates.values()) {
378
+ channel.pendingMessages = [];
379
+ channel.lastViewedMessageId = null;
380
+ }
227
381
  this.persistSession();
228
382
  this.emit();
229
383
  return;
@@ -233,14 +387,14 @@ export class ChatController {
233
387
  if (!session) {
234
388
  this.stopVerificationPolling();
235
389
  this.stopSafetyRefresh();
236
- this.wsConnection?.close();
237
- this.wsConnection = null;
238
- this.wsConnected = false;
390
+ this.closeAllConnections();
239
391
  apiClient.setSessionToken(null);
240
392
  this.user = null;
241
393
  this.sessionChecked = true;
242
- this.pendingMessages = [];
243
- this.lastViewedMessageId = null;
394
+ for (const channel of this.channelStates.values()) {
395
+ channel.pendingMessages = [];
396
+ channel.lastViewedMessageId = null;
397
+ }
244
398
  this.persistSession();
245
399
  this.emit();
246
400
  return;
@@ -251,8 +405,10 @@ export class ChatController {
251
405
  this.user = nextUser;
252
406
  const nextIdentity = `${nextUser.id}:${normalizeUsername(nextUser.username) ?? ""}`;
253
407
  if (previousIdentity && previousIdentity !== nextIdentity) {
254
- this.lastViewedMessageId = this.messages[this.messages.length - 1]?.id ?? null;
255
- this.persistChannelState();
408
+ for (const [channelId, channel] of this.channelStates) {
409
+ channel.lastViewedMessageId = channel.messages[channel.messages.length - 1]?.id ?? null;
410
+ this.persistChannelState(channelId);
411
+ }
256
412
  }
257
413
  this.sessionChecked = true;
258
414
  this.persistSession();
@@ -260,100 +416,127 @@ export class ChatController {
260
416
 
261
417
  if (nextUser?.emailVerified) {
262
418
  this.stopVerificationPolling();
263
- this.ensureConnection();
419
+ this.ensureOpenChannelConnections();
264
420
  return;
265
421
  }
266
422
 
267
423
  this.syncVerificationPolling();
268
424
  this.stopSafetyRefresh();
269
- this.wsConnection?.close();
270
- this.wsConnection = null;
271
- this.wsConnected = false;
425
+ this.closeAllConnections();
272
426
  }
273
427
 
274
428
  async refreshMessages(): Promise<void> {
275
- return this.runMessagesRefresh({ showLoading: true });
429
+ return this.refreshChannelMessages(DEFAULT_CHAT_CHANNEL_ID);
276
430
  }
277
431
 
278
- private async runMessagesRefresh(options: { showLoading: boolean }): Promise<void> {
279
- if (this.refreshMessagesPromise) return this.refreshMessagesPromise;
432
+ async refreshChannelMessages(channelId: string): Promise<void> {
433
+ return this.runMessagesRefresh(normalizeChannelId(channelId), { showLoading: true });
434
+ }
435
+
436
+ private async runMessagesRefresh(channelId: string, options: { showLoading: boolean }): Promise<void> {
437
+ const channel = this.ensureChannelState(channelId);
438
+ if (channel.refreshMessagesPromise) return channel.refreshMessagesPromise;
280
439
 
281
440
  if (options.showLoading) {
282
- this.messagesLoading = true;
283
- this.emit();
441
+ channel.messagesLoading = true;
442
+ this.emit(channelId);
284
443
  }
285
444
 
286
- const request = this.fetchMessages()
445
+ const request = this.fetchMessages(channelId)
287
446
  .catch(() => {
288
- this.persistChannelState();
447
+ this.persistChannelState(channelId);
289
448
  })
290
449
  .finally(() => {
291
450
  if (options.showLoading) {
292
- this.messagesLoading = false;
451
+ channel.messagesLoading = false;
293
452
  }
294
- this.refreshMessagesPromise = null;
295
- this.emit();
453
+ channel.refreshMessagesPromise = null;
454
+ this.emit(channelId);
296
455
  });
297
456
 
298
- this.refreshMessagesPromise = request;
457
+ channel.refreshMessagesPromise = request;
299
458
  return request;
300
459
  }
301
460
 
302
461
  async loadOlderMessages(): Promise<void> {
303
- if (this.loadOlderMessagesPromise) return this.loadOlderMessagesPromise;
304
- const before = this.messages[0]?.id ?? null;
305
- if (!before || this.reachedOldestMessage) return;
462
+ return this.loadOlderChannelMessages(DEFAULT_CHAT_CHANNEL_ID);
463
+ }
306
464
 
307
- this.olderMessagesLoading = true;
308
- this.emit();
465
+ async loadOlderChannelMessages(channelId: string): Promise<void> {
466
+ const normalizedChannelId = normalizeChannelId(channelId);
467
+ const channel = this.ensureChannelState(normalizedChannelId);
468
+ if (channel.loadOlderMessagesPromise) return channel.loadOlderMessagesPromise;
469
+ const before = channel.messages[0]?.id ?? null;
470
+ if (!before || channel.reachedOldestMessage) return;
471
+
472
+ channel.olderMessagesLoading = true;
473
+ this.emit(normalizedChannelId);
309
474
 
310
- const request = this.fetchOlderMessages(before)
475
+ const request = this.fetchOlderMessages(normalizedChannelId, before)
311
476
  .catch(() => {
312
- this.persistChannelState();
477
+ this.persistChannelState(normalizedChannelId);
313
478
  })
314
479
  .finally(() => {
315
- this.olderMessagesLoading = false;
316
- this.loadOlderMessagesPromise = null;
317
- this.emit();
480
+ channel.olderMessagesLoading = false;
481
+ channel.loadOlderMessagesPromise = null;
482
+ this.emit(normalizedChannelId);
318
483
  });
319
484
 
320
- this.loadOlderMessagesPromise = request;
485
+ channel.loadOlderMessagesPromise = request;
321
486
  return request;
322
487
  }
323
488
 
324
489
  setDraft(draft: string): void {
325
- if (draft === this.draft) return;
326
- this.draft = draft;
327
- this.scheduleDraftSync();
490
+ this.setChannelDraft(DEFAULT_CHAT_CHANNEL_ID, draft);
491
+ }
492
+
493
+ setChannelDraft(channelId: string, draft: string): void {
494
+ const normalizedChannelId = normalizeChannelId(channelId);
495
+ const channel = this.ensureChannelState(normalizedChannelId);
496
+ if (draft === channel.draft) return;
497
+ channel.draft = draft;
498
+ this.scheduleDraftSync(normalizedChannelId);
328
499
  }
329
500
 
330
501
  setReplyToId(replyToId: string | null): void {
331
- this.replyToId = replyToId;
332
- this.persistChannelState();
333
- this.emit();
502
+ this.setChannelReplyToId(DEFAULT_CHAT_CHANNEL_ID, replyToId);
503
+ }
504
+
505
+ setChannelReplyToId(channelId: string, replyToId: string | null): void {
506
+ const normalizedChannelId = normalizeChannelId(channelId);
507
+ const channel = this.ensureChannelState(normalizedChannelId);
508
+ channel.replyToId = replyToId;
509
+ this.persistChannelState(normalizedChannelId);
510
+ this.emit(normalizedChannelId);
334
511
  }
335
512
 
336
513
  attachView(): () => void {
337
- this.openViewCount += 1;
338
- if (this.markViewedThroughLatestMessage()) {
339
- this.emit();
514
+ return this.attachChannelView(DEFAULT_CHAT_CHANNEL_ID);
515
+ }
516
+
517
+ attachChannelView(channelId: string): () => void {
518
+ const normalizedChannelId = normalizeChannelId(channelId);
519
+ const channel = this.ensureChannelState(normalizedChannelId);
520
+ channel.openViewCount += 1;
521
+ if (this.markViewedThroughLatestMessage(normalizedChannelId)) {
522
+ this.emit(normalizedChannelId);
340
523
  }
341
524
  return () => {
342
- this.openViewCount = Math.max(0, this.openViewCount - 1);
343
- this.flushDraftSync();
525
+ channel.openViewCount = Math.max(0, channel.openViewCount - 1);
526
+ this.flushDraftSync(normalizedChannelId);
344
527
  };
345
528
  }
346
529
 
347
530
  clearSession(): void {
348
531
  this.stopVerificationPolling();
349
532
  this.stopSafetyRefresh();
350
- this.wsConnection?.close();
351
- this.wsConnection = null;
352
- this.wsConnected = false;
533
+ this.closeAllConnections();
353
534
  this.user = null;
354
535
  this.sessionChecked = false;
355
- this.pendingMessages = [];
356
- this.lastViewedMessageId = null;
536
+ for (const channel of this.channelStates.values()) {
537
+ channel.pendingMessages = [];
538
+ channel.lastViewedMessageId = null;
539
+ }
357
540
  this.emit();
358
541
  }
359
542
 
@@ -370,17 +553,20 @@ export class ChatController {
370
553
  reset(clearSession = false): void {
371
554
  this.stopVerificationPolling();
372
555
  this.stopSafetyRefresh();
373
- this.wsConnection?.close();
374
- this.wsConnection = null;
375
- this.wsConnected = false;
376
- this.messages = [];
377
- this.pendingMessages = [];
378
- this.draft = "";
379
- this.replyToId = null;
380
- this.lastCursor = null;
381
- this.lastViewedMessageId = null;
382
- this.reachedOldestMessage = false;
383
- this.openViewCount = 0;
556
+ this.closeAllConnections();
557
+ for (const [channelId, channel] of this.channelStates) {
558
+ this.clearDraftSyncTimer(channel);
559
+ channel.messages = [];
560
+ channel.pendingMessages = [];
561
+ channel.draft = "";
562
+ channel.replyToId = null;
563
+ channel.lastCursor = null;
564
+ channel.lastViewedMessageId = null;
565
+ channel.reachedOldestMessage = false;
566
+ channel.openViewCount = 0;
567
+ this.persistence?.deleteResource(TRANSCRIPT_KIND, channelId, { sourceKey: TRANSCRIPT_SOURCE });
568
+ this.persistChannelState(channelId);
569
+ }
384
570
  if (clearSession) {
385
571
  this.user = null;
386
572
  apiClient.setSessionToken(null);
@@ -388,92 +574,101 @@ export class ChatController {
388
574
  } else {
389
575
  this.persistSession();
390
576
  }
391
- this.persistence?.deleteResource(TRANSCRIPT_KIND, "everyone", { sourceKey: TRANSCRIPT_SOURCE });
392
- this.persistChannelState();
393
577
  this.emit();
394
578
  }
395
579
 
396
580
  dispose(): void {
397
581
  chatLog.info("dispose controller", {
398
582
  listeners: this.listeners.size,
399
- hasConnection: !!this.wsConnection,
583
+ connections: [...this.channelStates.values()].filter((channel) => !!channel.wsConnection).length,
400
584
  });
401
- this.flushDraftSync();
585
+ for (const [channelId] of this.channelStates) {
586
+ this.flushDraftSync(channelId);
587
+ }
402
588
  this.stopVerificationPolling();
403
589
  this.stopSafetyRefresh();
404
- this.wsConnection?.close();
405
- this.wsConnection = null;
406
- this.wsConnected = false;
407
- this.messagesLoading = false;
408
- this.olderMessagesLoading = false;
409
- this.refreshMessagesPromise = null;
410
- this.loadOlderMessagesPromise = null;
411
- this.openViewCount = 0;
590
+ this.closeAllConnections();
591
+ for (const channel of this.channelStates.values()) {
592
+ channel.messagesLoading = false;
593
+ channel.olderMessagesLoading = false;
594
+ channel.refreshMessagesPromise = null;
595
+ channel.loadOlderMessagesPromise = null;
596
+ channel.openViewCount = 0;
597
+ }
412
598
  this.notifyFn = () => {};
413
599
  this.listeners.clear();
414
600
  }
415
601
 
416
602
  send(content: string, replyToId?: string): void {
603
+ this.sendToChannel(DEFAULT_CHAT_CHANNEL_ID, content, replyToId);
604
+ }
605
+
606
+ sendToChannel(channelId: string, content: string, replyToId?: string): void {
607
+ const normalizedChannelId = normalizeChannelId(channelId);
608
+ const channel = this.ensureChannelState(normalizedChannelId);
417
609
  if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
418
- if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
419
- this.ensureConnection();
610
+ if (!channel.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
611
+ this.ensureConnection(normalizedChannelId);
420
612
  }
421
- const connection = this.wsConnection;
613
+ const connection = channel.wsConnection;
422
614
  if (!connection) {
423
615
  this.notifyFn({ body: "Unable to send message right now.", type: "error" });
424
616
  return;
425
617
  }
426
618
 
427
- const pendingMessage = this.createPendingMessage(content, replyToId);
428
- this.pendingMessages = [...this.pendingMessages, pendingMessage];
429
- this.draft = "";
430
- this.replyToId = null;
431
- this.persistChannelState();
432
- this.emit();
619
+ const pendingMessage = this.createPendingMessage(normalizedChannelId, content, replyToId);
620
+ channel.pendingMessages = [...channel.pendingMessages, pendingMessage];
621
+ channel.draft = "";
622
+ channel.replyToId = null;
623
+ this.persistChannelState(normalizedChannelId);
624
+ this.emit(normalizedChannelId);
433
625
 
434
626
  void connection.send(content, replyToId).then((message) => {
435
- this.pendingMessages = this.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
436
- this.mergeMessages([message]);
627
+ channel.pendingMessages = channel.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
628
+ this.mergeMessages(normalizedChannelId, [message]);
437
629
  }).catch((error) => {
438
630
  const errorMessage = error instanceof Error && error.message ? error.message : "Failed to send message.";
439
- this.pendingMessages = this.pendingMessages.map((entry) => (
631
+ channel.pendingMessages = channel.pendingMessages.map((entry) => (
440
632
  entry.id === pendingMessage.id
441
633
  ? { ...entry, clientStatus: "failed", clientError: errorMessage }
442
634
  : entry
443
635
  ));
444
- this.emit();
636
+ this.emit(normalizedChannelId);
445
637
  this.notifyFn({ body: errorMessage, type: "error" });
446
638
  });
447
639
  }
448
640
 
449
- ensureConnection(): void {
641
+ ensureConnection(channelId = DEFAULT_CHAT_CHANNEL_ID): void {
642
+ const normalizedChannelId = normalizeChannelId(channelId);
643
+ const channel = this.ensureChannelState(normalizedChannelId);
450
644
  if (!this.user?.emailVerified || !apiClient.getSessionToken()) {
451
645
  this.stopSafetyRefresh();
452
646
  return;
453
647
  }
454
648
  this.startSafetyRefresh();
455
- if (this.wsConnected) return;
456
- this.wsConnected = true;
649
+ if (channel.wsConnected) return;
650
+ channel.wsConnected = true;
457
651
 
458
- void this.refreshMessages().catch(() => {});
652
+ void this.refreshChannelMessages(normalizedChannelId).catch(() => {});
459
653
 
460
- this.wsConnection = apiClient.connectChannel(
461
- "everyone",
654
+ channel.wsConnection = apiClient.connectChannel(
655
+ normalizedChannelId,
462
656
  (message) => {
463
- if (this.messages.some((entry) => entry.id === message.id)) return;
464
- this.mergeMessages([message]);
657
+ if (channel.messages.some((entry) => entry.id === message.id)) return;
658
+ this.mergeMessages(normalizedChannelId, [message]);
465
659
  },
466
660
  () => {
467
- this.wsConnected = false;
661
+ channel.wsConnected = false;
468
662
  },
469
663
  );
470
664
  }
471
665
 
472
- private emit(): void {
473
- const snapshot = this.getSnapshot();
474
- for (const listener of this.listeners) {
666
+ private emit(channelId?: string): void {
667
+ for (const entry of this.listeners) {
668
+ if (channelId && entry.channelId !== channelId) continue;
669
+ const snapshot = this.getSnapshot(entry.channelId);
475
670
  try {
476
- listener(snapshot);
671
+ entry.listener(snapshot);
477
672
  } catch {
478
673
  // ignore subscriber errors
479
674
  }
@@ -504,7 +699,9 @@ export class ChatController {
504
699
  this.stopSafetyRefresh();
505
700
  return;
506
701
  }
507
- void this.runMessagesRefresh({ showLoading: false }).catch(() => {});
702
+ for (const channelId of this.getSafetyRefreshChannelIds()) {
703
+ void this.runMessagesRefresh(channelId, { showLoading: false }).catch(() => {});
704
+ }
508
705
  }, SAFETY_REFRESH_MS);
509
706
  this.safetyRefreshTimer.unref?.();
510
707
  }
@@ -515,85 +712,126 @@ export class ChatController {
515
712
  this.safetyRefreshTimer = null;
516
713
  }
517
714
 
518
- private async fetchMessages(): Promise<void> {
519
- const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
520
- const hasIncrementalCursor = !!this.lastCursor;
715
+ private closeAllConnections(): void {
716
+ for (const channel of this.channelStates.values()) {
717
+ channel.wsConnection?.close();
718
+ channel.wsConnection = null;
719
+ channel.wsConnected = false;
720
+ }
721
+ }
722
+
723
+ private ensureOpenChannelConnections(): void {
724
+ const channelIds = new Set<string>([DEFAULT_CHAT_CHANNEL_ID]);
725
+ for (const [channelId, channel] of this.channelStates) {
726
+ if (channel.openViewCount > 0 || channel.wsConnection) {
727
+ channelIds.add(channelId);
728
+ }
729
+ }
730
+ for (const channelId of channelIds) {
731
+ this.ensureConnection(channelId);
732
+ }
733
+ }
734
+
735
+ private getSafetyRefreshChannelIds(): string[] {
736
+ const active = [...this.channelStates.entries()]
737
+ .filter(([, channel]) => channel.openViewCount > 0 || channel.wsConnection)
738
+ .map(([channelId]) => channelId);
739
+ return active.length > 0 ? active : [DEFAULT_CHAT_CHANNEL_ID];
740
+ }
741
+
742
+ private async fetchMessages(channelId: string): Promise<void> {
743
+ const channel = this.ensureChannelState(channelId);
744
+ const legacyTimestampCursor = isLegacyTimestampCursor(channel.lastCursor);
745
+ const hasIncrementalCursor = !!channel.lastCursor;
521
746
 
522
747
  try {
523
- const messages = await apiClient.getMessages("everyone", {
748
+ const messages = await apiClient.getMessages(channelId, {
524
749
  limit: MESSAGE_PAGE_SIZE,
525
- after: this.lastCursor ?? undefined,
750
+ after: channel.lastCursor ?? undefined,
526
751
  });
527
752
  if (!hasIncrementalCursor && messages.length < MESSAGE_PAGE_SIZE) {
528
- this.reachedOldestMessage = true;
753
+ channel.reachedOldestMessage = true;
529
754
  }
530
755
  if (messages.length > 0) {
531
- this.mergeMessages(messages);
756
+ this.mergeMessages(channelId, messages);
532
757
  return;
533
758
  }
534
759
  if (legacyTimestampCursor) {
535
- const fullRefresh = await apiClient.getMessages("everyone", { limit: MESSAGE_PAGE_SIZE });
760
+ const fullRefresh = await apiClient.getMessages(channelId, { limit: MESSAGE_PAGE_SIZE });
536
761
  if (fullRefresh.length < MESSAGE_PAGE_SIZE) {
537
- this.reachedOldestMessage = true;
762
+ channel.reachedOldestMessage = true;
538
763
  }
539
764
  if (fullRefresh.length > 0) {
540
- this.mergeMessages(fullRefresh);
765
+ this.mergeMessages(channelId, fullRefresh);
541
766
  return;
542
767
  }
543
- this.lastCursor = null;
768
+ channel.lastCursor = null;
544
769
  }
545
- this.persistChannelState();
770
+ this.persistChannelState(channelId);
546
771
  return;
547
772
  } catch {
548
- const messages = await apiClient.getMessages("everyone", { limit: MESSAGE_PAGE_SIZE });
773
+ const messages = await apiClient.getMessages(channelId, { limit: MESSAGE_PAGE_SIZE });
549
774
  if (messages.length < MESSAGE_PAGE_SIZE) {
550
- this.reachedOldestMessage = true;
775
+ channel.reachedOldestMessage = true;
551
776
  }
552
777
  if (messages.length > 0) {
553
- this.mergeMessages(messages);
778
+ this.mergeMessages(channelId, messages);
554
779
  return;
555
780
  }
556
- this.persistChannelState();
781
+ this.persistChannelState(channelId);
557
782
  }
558
783
  }
559
784
 
560
- private async fetchOlderMessages(before: string): Promise<void> {
561
- const messages = await apiClient.getMessages("everyone", {
785
+ private async fetchOlderMessages(channelId: string, before: string): Promise<void> {
786
+ const channel = this.ensureChannelState(channelId);
787
+ const messages = await apiClient.getMessages(channelId, {
562
788
  limit: MESSAGE_PAGE_SIZE,
563
789
  before,
564
790
  });
565
791
  if (messages.length < MESSAGE_PAGE_SIZE) {
566
- this.reachedOldestMessage = true;
792
+ channel.reachedOldestMessage = true;
567
793
  }
568
794
  if (messages.length === 0) {
569
- this.persistChannelState();
795
+ this.persistChannelState(channelId);
570
796
  return;
571
797
  }
572
- this.mergeMessages(messages, { notifyMentions: false });
798
+ this.mergeMessages(channelId, messages, { notifyMentions: false });
573
799
  }
574
800
 
575
- private mergeMessages(messages: ChatMessage[], options?: { notifyMentions?: boolean }): void {
576
- this.reconcilePendingMessages(messages);
801
+ private mergeMessages(
802
+ channelIdOrMessages: string | ChatMessage[],
803
+ maybeMessages?: ChatMessage[] | { notifyMentions?: boolean },
804
+ maybeOptions?: { notifyMentions?: boolean },
805
+ ): void {
806
+ const channelId = Array.isArray(channelIdOrMessages) ? DEFAULT_CHAT_CHANNEL_ID : channelIdOrMessages;
807
+ const messages = Array.isArray(channelIdOrMessages)
808
+ ? channelIdOrMessages
809
+ : (maybeMessages as ChatMessage[] | undefined) ?? [];
810
+ const options = Array.isArray(channelIdOrMessages)
811
+ ? maybeMessages as { notifyMentions?: boolean } | undefined
812
+ : maybeOptions;
813
+ const channel = this.ensureChannelState(channelId);
814
+ this.reconcilePendingMessages(channelId, messages);
577
815
  const merged = new Map<string, ChatMessage>();
578
816
  const incoming = new Map<string, ChatMessage>();
579
- for (const message of this.messages) merged.set(message.id, message);
817
+ for (const message of channel.messages) merged.set(message.id, message);
580
818
  for (const message of messages) {
581
819
  if (!merged.has(message.id)) {
582
820
  incoming.set(message.id, message);
583
821
  }
584
822
  merged.set(message.id, message);
585
823
  }
586
- this.messages = [...merged.values()]
824
+ channel.messages = [...merged.values()]
587
825
  .sort(compareMessages);
588
- this.lastCursor = this.messages[this.messages.length - 1]?.id ?? this.lastCursor;
589
- if (this.openViewCount > 0) {
590
- this.markViewedThroughLatestMessage(false);
826
+ channel.lastCursor = channel.messages[channel.messages.length - 1]?.id ?? channel.lastCursor;
827
+ if (channel.openViewCount > 0) {
828
+ this.markViewedThroughLatestMessage(channelId, false);
591
829
  }
592
830
  if (options?.notifyMentions !== false) {
593
- this.trackUnreadMentions([...incoming.values()]);
831
+ this.trackUnreadMentions(channelId, [...incoming.values()]);
594
832
  }
595
- this.persistTranscript();
596
- this.emit();
833
+ this.persistTranscript(channelId);
834
+ this.emit(channelId);
597
835
  }
598
836
 
599
837
  private persistSession(): void {
@@ -609,67 +847,73 @@ export class ChatController {
609
847
  });
610
848
  }
611
849
 
612
- private persistChannelState(): void {
613
- this.clearDraftSyncTimer();
614
- this.writeChannelState();
850
+ private persistChannelState(channelId: string): void {
851
+ const channel = this.ensureChannelState(channelId);
852
+ this.clearDraftSyncTimer(channel);
853
+ this.writeChannelState(channelId);
615
854
  }
616
855
 
617
- private writeChannelState(): void {
856
+ private writeChannelState(channelId: string): void {
857
+ const channel = this.ensureChannelState(channelId);
618
858
  const value = {
619
- draft: this.draft,
620
- replyToId: this.replyToId,
621
- lastCursor: this.lastCursor,
622
- lastViewedMessageId: this.lastViewedMessageId,
859
+ draft: channel.draft,
860
+ replyToId: channel.replyToId,
861
+ lastCursor: channel.lastCursor,
862
+ lastViewedMessageId: channel.lastViewedMessageId,
623
863
  } satisfies PersistedChannelState;
624
- this.resume?.setState(CHANNEL_STATE_KEY, value, {
864
+ this.resume?.setState(channelStateKey(channelId), value, {
625
865
  schemaVersion: CHANNEL_SCHEMA_VERSION,
626
866
  });
627
- this.persistence?.setState(CHANNEL_STATE_KEY, value, {
867
+ this.persistence?.setState(channelStateKey(channelId), value, {
628
868
  schemaVersion: CHANNEL_SCHEMA_VERSION,
629
869
  });
630
870
  }
631
871
 
632
- private scheduleDraftSync(): void {
633
- this.clearDraftSyncTimer();
634
- this.draftSyncTimer = setTimeout(() => {
635
- this.draftSyncTimer = null;
636
- this.writeChannelState();
637
- this.emit();
872
+ private scheduleDraftSync(channelId: string): void {
873
+ const channel = this.ensureChannelState(channelId);
874
+ this.clearDraftSyncTimer(channel);
875
+ channel.draftSyncTimer = setTimeout(() => {
876
+ channel.draftSyncTimer = null;
877
+ this.writeChannelState(channelId);
878
+ this.emit(channelId);
638
879
  }, DRAFT_SYNC_DEBOUNCE_MS);
639
- this.draftSyncTimer.unref?.();
880
+ channel.draftSyncTimer.unref?.();
640
881
  }
641
882
 
642
- private clearDraftSyncTimer(): void {
643
- if (!this.draftSyncTimer) return;
644
- clearTimeout(this.draftSyncTimer);
645
- this.draftSyncTimer = null;
883
+ private clearDraftSyncTimer(channel: ChannelRuntimeState): void {
884
+ if (!channel.draftSyncTimer) return;
885
+ clearTimeout(channel.draftSyncTimer);
886
+ channel.draftSyncTimer = null;
646
887
  }
647
888
 
648
- private flushDraftSync(): void {
649
- if (!this.draftSyncTimer) return;
650
- this.clearDraftSyncTimer();
651
- this.writeChannelState();
889
+ private flushDraftSync(channelId: string): void {
890
+ const channel = this.ensureChannelState(channelId);
891
+ if (!channel.draftSyncTimer) return;
892
+ this.clearDraftSyncTimer(channel);
893
+ this.writeChannelState(channelId);
652
894
  }
653
895
 
654
- private persistTranscript(): void {
655
- if (this.messages.length === 0) {
656
- this.persistence?.deleteResource(TRANSCRIPT_KIND, "everyone", { sourceKey: TRANSCRIPT_SOURCE });
657
- this.persistChannelState();
896
+ private persistTranscript(channelId: string): void {
897
+ const channel = this.ensureChannelState(channelId);
898
+ if (channel.messages.length === 0) {
899
+ this.persistence?.deleteResource(TRANSCRIPT_KIND, channelId, { sourceKey: TRANSCRIPT_SOURCE });
900
+ this.persistChannelState(channelId);
658
901
  return;
659
902
  }
660
903
 
661
- this.persistence?.setResource(TRANSCRIPT_KIND, "everyone", {
662
- messages: this.messages.slice(-MAX_CACHED_MESSAGES),
904
+ this.persistence?.setResource(TRANSCRIPT_KIND, channelId, {
905
+ messages: channel.messages.slice(-MAX_CACHED_MESSAGES),
663
906
  } satisfies PersistedTranscript, {
664
907
  sourceKey: TRANSCRIPT_SOURCE,
665
908
  schemaVersion: TRANSCRIPT_SCHEMA_VERSION,
666
909
  cachePolicy: TRANSCRIPT_CACHE_POLICY,
667
910
  });
668
- this.persistChannelState();
911
+ this.persistChannelState(channelId);
669
912
  }
670
913
 
671
- private trackUnreadMentions(messages: ChatMessage[]): void {
672
- if (this.openViewCount > 0) {
914
+ private trackUnreadMentions(channelId: string, messages: ChatMessage[]): void {
915
+ const channel = this.ensureChannelState(channelId);
916
+ if (channel.openViewCount > 0) {
673
917
  return;
674
918
  }
675
919
 
@@ -687,25 +931,26 @@ export class ChatController {
687
931
  }
688
932
  this.notifyFn({
689
933
  title: "Gloomberb chat",
690
- body: `${freshMentions.length} new mentions in #everyone.`,
934
+ body: `${freshMentions.length} new mentions in #${channelId}.`,
691
935
  type: "info",
692
936
  desktop: "when-inactive",
693
937
  });
694
938
  }
695
939
 
696
- private getUnreadMentionCount(): number {
697
- return this.getUnreadMentionMessages().length;
940
+ private getUnreadMentionCount(channelId: string): number {
941
+ return this.getUnreadMentionMessages(channelId).length;
698
942
  }
699
943
 
700
- private getUnreadMentionMessages(): ChatMessage[] {
701
- if (!this.lastViewedMessageId) {
702
- return this.getMentionMessages(this.messages);
944
+ private getUnreadMentionMessages(channelId: string): ChatMessage[] {
945
+ const channel = this.ensureChannelState(channelId);
946
+ if (!channel.lastViewedMessageId) {
947
+ return this.getMentionMessages(channel.messages);
703
948
  }
704
949
 
705
- const viewedIndex = this.messages.findIndex((message) => message.id === this.lastViewedMessageId);
950
+ const viewedIndex = channel.messages.findIndex((message) => message.id === channel.lastViewedMessageId);
706
951
  const unseenMessages = viewedIndex >= 0
707
- ? this.messages.slice(viewedIndex + 1)
708
- : this.messages;
952
+ ? channel.messages.slice(viewedIndex + 1)
953
+ : channel.messages;
709
954
  return this.getMentionMessages(unseenMessages);
710
955
  }
711
956
 
@@ -718,31 +963,33 @@ export class ChatController {
718
963
  ));
719
964
  }
720
965
 
721
- private markViewedThroughLatestMessage(persist = true): boolean {
722
- const latestMessageId = this.messages[this.messages.length - 1]?.id ?? null;
723
- if (this.lastViewedMessageId === latestMessageId) {
966
+ private markViewedThroughLatestMessage(channelId: string, persist = true): boolean {
967
+ const channel = this.ensureChannelState(channelId);
968
+ const latestMessageId = channel.messages[channel.messages.length - 1]?.id ?? null;
969
+ if (channel.lastViewedMessageId === latestMessageId) {
724
970
  return false;
725
971
  }
726
- this.lastViewedMessageId = latestMessageId;
972
+ channel.lastViewedMessageId = latestMessageId;
727
973
  if (persist) {
728
- this.persistChannelState();
974
+ this.persistChannelState(channelId);
729
975
  }
730
976
  return true;
731
977
  }
732
978
 
733
- private getVisibleMessages(): ChatMessage[] {
734
- return [...this.messages, ...this.pendingMessages]
979
+ private getVisibleMessages(channelId: string): ChatMessage[] {
980
+ const channel = this.ensureChannelState(channelId);
981
+ return [...channel.messages, ...channel.pendingMessages]
735
982
  .sort(compareMessages);
736
983
  }
737
984
 
738
- private createPendingMessage(content: string, replyToId?: string): ChatMessage {
985
+ private createPendingMessage(channelId: string, content: string, replyToId?: string): ChatMessage {
739
986
  const replyToMessage = replyToId
740
- ? this.getVisibleMessages().find((message) => message.id === replyToId) ?? null
987
+ ? this.getVisibleMessages(channelId).find((message) => message.id === replyToId) ?? null
741
988
  : null;
742
989
  const pendingId = `local:${Date.now()}:${this.pendingMessageSeq += 1}`;
743
990
  return {
744
991
  id: pendingId,
745
- channelId: "everyone",
992
+ channelId,
746
993
  content,
747
994
  replyToId: replyToId ?? null,
748
995
  createdAt: new Date().toISOString(),
@@ -762,10 +1009,11 @@ export class ChatController {
762
1009
  };
763
1010
  }
764
1011
 
765
- private reconcilePendingMessages(messages: ChatMessage[]): void {
766
- if (this.pendingMessages.length === 0 || messages.length === 0) return;
1012
+ private reconcilePendingMessages(channelId: string, messages: ChatMessage[]): void {
1013
+ const channel = this.ensureChannelState(channelId);
1014
+ if (channel.pendingMessages.length === 0 || messages.length === 0) return;
767
1015
 
768
- const remaining = [...this.pendingMessages];
1016
+ const remaining = [...channel.pendingMessages];
769
1017
  for (const incoming of messages) {
770
1018
  const incomingMs = toTimestampMillis(incoming.createdAt);
771
1019
  const pendingIndex = remaining.findIndex((pending) => (
@@ -778,7 +1026,7 @@ export class ChatController {
778
1026
  remaining.splice(pendingIndex, 1);
779
1027
  }
780
1028
  }
781
- this.pendingMessages = remaining;
1029
+ channel.pendingMessages = remaining;
782
1030
  }
783
1031
  }
784
1032