gloomberb 0.6.1 → 0.7.1

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