gloomberb 0.3.7 → 0.4.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 (143) hide show
  1. package/README.md +19 -8
  2. package/package.json +1 -1
  3. package/src/app.tsx +45 -69
  4. package/src/cli/base-converter.ts +31 -0
  5. package/src/cli/commands/search.ts +11 -3
  6. package/src/cli/commands/ticker.ts +22 -11
  7. package/src/cli/context.ts +27 -32
  8. package/src/cli/helpers.ts +1 -4
  9. package/src/cli/index.ts +140 -123
  10. package/src/cli/registry.test.ts +226 -0
  11. package/src/cli/registry.ts +201 -0
  12. package/src/cli.test.ts +219 -0
  13. package/src/components/chart/chart-controller.ts +309 -0
  14. package/src/components/chart/chart-data.ts +5 -6
  15. package/src/components/chart/chart-pane-settings.ts +27 -0
  16. package/src/components/chart/chart-resolution.test.ts +56 -0
  17. package/src/components/chart/chart-resolution.ts +362 -0
  18. package/src/components/chart/chart-types.ts +12 -4
  19. package/src/components/chart/chart-viewport.test.ts +25 -0
  20. package/src/components/chart/chart-viewport.ts +28 -0
  21. package/src/components/chart/comparison-chart-data.ts +20 -27
  22. package/src/components/chart/comparison-stock-chart.tsx +374 -99
  23. package/src/components/chart/stock-chart-renderer-switch.test.tsx +124 -0
  24. package/src/components/chart/stock-chart.tsx +455 -106
  25. package/src/components/command-bar/__snapshots__/command-bar.test.tsx.snap +4 -4
  26. package/src/components/command-bar/command-bar.test.tsx +650 -5
  27. package/src/components/command-bar/command-bar.tsx +363 -63
  28. package/src/components/command-bar/command-registry.ts +8 -0
  29. package/src/components/command-bar/helpers.test.ts +23 -1
  30. package/src/components/command-bar/helpers.ts +14 -5
  31. package/src/components/command-bar/pane-template-display.ts +12 -0
  32. package/src/components/command-bar/root-shortcuts.ts +2 -1
  33. package/src/components/command-bar/workflow-ops.ts +4 -2
  34. package/src/components/detail-feed-view.test.tsx +124 -49
  35. package/src/components/detail-feed-view.tsx +396 -160
  36. package/src/components/layout/floating-pane.tsx +4 -2
  37. package/src/components/layout/header.tsx +3 -3
  38. package/src/components/layout/pane-header.tsx +26 -2
  39. package/src/components/layout/pane-sizing.ts +6 -0
  40. package/src/components/layout/pane.tsx +5 -1
  41. package/src/components/layout/shell.test.tsx +77 -5
  42. package/src/components/layout/shell.tsx +67 -52
  43. package/src/components/layout/status-bar.test.tsx +66 -0
  44. package/src/components/layout/status-bar.tsx +26 -5
  45. package/src/components/onboarding/onboarding-wizard.test.tsx +65 -0
  46. package/src/components/onboarding/onboarding-wizard.tsx +5 -6
  47. package/src/components/ticker-list-table.test.tsx +83 -0
  48. package/src/components/ticker-list-table.tsx +181 -97
  49. package/src/components/ui/data-table.tsx +50 -5
  50. package/src/components/ui/index.ts +1 -0
  51. package/src/components/ui/page-stack-view.tsx +60 -0
  52. package/src/components/use-double-click-activation.ts +31 -0
  53. package/src/index.tsx +10 -10
  54. package/src/market-data/coordinator.test.ts +92 -4
  55. package/src/market-data/coordinator.ts +32 -5
  56. package/src/market-data/hooks.test.tsx +120 -0
  57. package/src/market-data/hooks.tsx +53 -16
  58. package/src/market-data/request-types.ts +4 -2
  59. package/src/market-data/selectors.ts +24 -58
  60. package/src/plugins/builtin/chat-controller.test.ts +400 -3
  61. package/src/plugins/builtin/chat-controller.ts +328 -35
  62. package/src/plugins/builtin/chat.test.tsx +118 -6
  63. package/src/plugins/builtin/chat.tsx +180 -116
  64. package/src/plugins/builtin/comparison-chart.test.tsx +66 -5
  65. package/src/plugins/builtin/comparison-chart.tsx +21 -1
  66. package/src/plugins/builtin/layout-manager.tsx +9 -0
  67. package/src/plugins/builtin/news.tsx +1 -11
  68. package/src/plugins/builtin/portfolio-list/cli/commands.ts +656 -0
  69. package/src/plugins/builtin/portfolio-list/command-bar.ts +122 -0
  70. package/src/plugins/builtin/portfolio-list/header.tsx +16 -34
  71. package/src/plugins/builtin/portfolio-list/index.tsx +5 -0
  72. package/src/plugins/builtin/portfolio-list/metrics.test.ts +61 -0
  73. package/src/plugins/builtin/portfolio-list/metrics.ts +2 -2
  74. package/src/plugins/builtin/portfolio-list/mutations.test.ts +187 -0
  75. package/src/plugins/builtin/portfolio-list/mutations.ts +226 -0
  76. package/src/plugins/builtin/portfolio-list/pane.tsx +39 -17
  77. package/src/plugins/builtin/portfolio-list/table.tsx +11 -2
  78. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.test.tsx +163 -0
  79. package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +81 -0
  80. package/src/plugins/builtin/sec.tsx +1 -11
  81. package/src/plugins/builtin/ticker-detail/chart-tab.tsx +13 -2
  82. package/src/plugins/builtin/ticker-detail/financials-tab.tsx +21 -0
  83. package/src/plugins/builtin/ticker-detail/overview-tab.tsx +45 -10
  84. package/src/plugins/builtin/ticker-detail/pane.tsx +35 -17
  85. package/src/plugins/builtin/ticker-detail/settings.ts +19 -1
  86. package/src/plugins/builtin/ticker-detail.test.tsx +47 -0
  87. package/src/plugins/catalog.ts +65 -0
  88. package/src/plugins/ibkr/broker-adapter.ts +28 -0
  89. package/src/plugins/ibkr/gateway-service.test.ts +67 -1
  90. package/src/plugins/ibkr/gateway-service.ts +147 -7
  91. package/src/plugins/ibkr/index.tsx +7 -1
  92. package/src/plugins/pane-manager.test.ts +1 -1
  93. package/src/plugins/plugin-runtime.tsx +27 -7
  94. package/src/plugins/prediction-markets/cli.ts +28 -0
  95. package/src/plugins/prediction-markets/controller-data.ts +132 -17
  96. package/src/plugins/prediction-markets/controller.ts +142 -122
  97. package/src/plugins/prediction-markets/index.tsx +7 -1
  98. package/src/plugins/prediction-markets/launch.ts +51 -16
  99. package/src/plugins/prediction-markets/metrics.ts +9 -5
  100. package/src/plugins/prediction-markets/pane.test.tsx +197 -57
  101. package/src/plugins/prediction-markets/pane.tsx +80 -155
  102. package/src/plugins/prediction-markets/services/fetch.ts +4 -0
  103. package/src/plugins/prediction-markets/table.tsx +6 -3
  104. package/src/plugins/prediction-markets/test-helpers.tsx +20 -5
  105. package/src/plugins/prediction-markets/types.ts +0 -1
  106. package/src/plugins/registry.ts +23 -0
  107. package/src/sources/gloomberb-cloud.test.ts +29 -0
  108. package/src/sources/gloomberb-cloud.ts +89 -1
  109. package/src/sources/provider-router.test.ts +265 -3
  110. package/src/sources/provider-router.ts +239 -11
  111. package/src/sources/yahoo-finance.test.ts +26 -0
  112. package/src/sources/yahoo-finance.ts +54 -3
  113. package/src/state/app-context-selector.test.tsx +99 -0
  114. package/src/state/app-context.test.ts +18 -0
  115. package/src/state/app-context.tsx +170 -65
  116. package/src/state/selectors.ts +25 -0
  117. package/src/theme/colors.ts +8 -8
  118. package/src/theme/themes.test.ts +2 -0
  119. package/src/theme/themes.ts +22 -10
  120. package/src/types/broker.ts +21 -0
  121. package/src/types/config.ts +17 -1
  122. package/src/types/data-provider.ts +18 -0
  123. package/src/types/financials.ts +31 -1
  124. package/src/types/plugin.ts +70 -0
  125. package/src/updater.test.ts +2 -0
  126. package/src/utils/api-client.test.ts +57 -0
  127. package/src/utils/api-client.ts +47 -12
  128. package/src/utils/back-navigation.ts +27 -0
  129. package/src/utils/format.test.ts +14 -0
  130. package/src/utils/format.ts +3 -3
  131. package/src/utils/market-status.ts +46 -1
  132. package/src/utils/quote-freshness.ts +127 -0
  133. package/src/utils/quote-resolution.test.ts +277 -0
  134. package/src/utils/quote-resolution.ts +641 -0
  135. package/src/utils/slugify.ts +3 -0
  136. package/src/utils/timestamp.ts +25 -0
  137. package/src/version.ts +1 -1
  138. package/src/cli/commands/portfolio.ts +0 -210
  139. package/src/cli/commands/watchlist.ts +0 -210
  140. package/src/plugins/builtin/data-providers.ts +0 -16
  141. package/src/sources/exchange-rates.ts +0 -18
  142. package/src/utils/ascii-chart.ts +0 -172
  143. package/src/utils/ticker-tokenizer.test.ts +0 -48
@@ -1,5 +1,7 @@
1
1
  import type { PluginPersistence, PluginResumeState } from "../../types/plugin";
2
2
  import { apiClient, type ChatMessage, type PersistedAuthUser } from "../../utils/api-client";
3
+ import { debugLog } from "../../utils/debug-log";
4
+ import { toTimestampMillis } from "../../utils/timestamp";
3
5
 
4
6
  const SESSION_STATE_KEY = "session";
5
7
  const CHANNEL_STATE_KEY = "channel:everyone";
@@ -8,12 +10,16 @@ const TRANSCRIPT_SOURCE = "server";
8
10
  const MAX_CACHED_MESSAGES = 50;
9
11
  const SESSION_SCHEMA_VERSION = 1;
10
12
  const CHANNEL_SCHEMA_VERSION = 1;
11
- const TRANSCRIPT_SCHEMA_VERSION = 1;
13
+ const TRANSCRIPT_SCHEMA_VERSION = 2;
12
14
  const TRANSCRIPT_CACHE_POLICY = {
13
15
  staleMs: 30 * 24 * 60 * 60_000,
14
16
  expireMs: 90 * 24 * 60 * 60_000,
15
17
  };
16
18
  const VERIFICATION_POLL_MS = 5_000;
19
+ const ISO_TIMESTAMP_CURSOR = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
20
+ const USERNAME_MENTION = /(^|[^A-Za-z0-9_])@([A-Za-z][A-Za-z0-9_]{2,29})(?![A-Za-z0-9_])/g;
21
+ const PENDING_RECONCILE_WINDOW_MS = 2 * 60_000;
22
+ const chatLog = debugLog.createLogger("chat-controller");
17
23
 
18
24
  interface PersistedSessionState {
19
25
  sessionToken: string | null;
@@ -25,6 +31,7 @@ interface PersistedChannelState {
25
31
  draft: string;
26
32
  replyToId: string | null;
27
33
  lastCursor: string | null;
34
+ lastViewedMessageId?: string | null;
28
35
  }
29
36
 
30
37
  interface PersistedTranscript {
@@ -38,9 +45,45 @@ export interface ChatControllerSnapshot {
38
45
  messages: ChatMessage[];
39
46
  draft: string;
40
47
  replyToId: string | null;
48
+ unreadMentionCount: number;
41
49
  }
42
50
 
43
- type ChatConnection = { send: (content: string, replyToId?: string) => void; close: () => void };
51
+ type ChatConnection = { send: (content: string, replyToId?: string) => Promise<ChatMessage>; close: () => void };
52
+ type ChatToastOptions = { duration?: number; type?: "info" | "success" | "error" };
53
+
54
+ function normalizeUsername(username: string | null | undefined): string | null {
55
+ const trimmed = username?.trim();
56
+ return trimmed ? trimmed.toLowerCase() : null;
57
+ }
58
+
59
+ function messageMentionsUsername(content: string, username: string): boolean {
60
+ USERNAME_MENTION.lastIndex = 0;
61
+ let match: RegExpExecArray | null;
62
+ while ((match = USERNAME_MENTION.exec(content)) !== null) {
63
+ if ((match[2] ?? "").toLowerCase() === username) {
64
+ return true;
65
+ }
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function formatMentionToast(message: ChatMessage): string {
71
+ const author = message.user.username || "Someone";
72
+ const normalized = message.content.replace(/\s+/g, " ").trim();
73
+ if (!normalized) {
74
+ return `@${author} mentioned you in chat.`;
75
+ }
76
+ const snippet = normalized.length > 72 ? `${normalized.slice(0, 69)}...` : normalized;
77
+ return `@${author} mentioned you: ${snippet}`;
78
+ }
79
+
80
+ function isLegacyTimestampCursor(cursor: string | null): boolean {
81
+ return !!cursor && ISO_TIMESTAMP_CURSOR.test(cursor);
82
+ }
83
+
84
+ function compareMessages(a: ChatMessage, b: ChatMessage): number {
85
+ return a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id);
86
+ }
44
87
 
45
88
  export class ChatController {
46
89
  private persistence: PluginPersistence | null = null;
@@ -48,14 +91,21 @@ export class ChatController {
48
91
  private appActive = true;
49
92
  private hydrated = false;
50
93
  private sessionChecked = false;
94
+ private messagesLoading = false;
95
+ private refreshMessagesPromise: Promise<void> | null = null;
51
96
  private user: { id: string; username: string; emailVerified: boolean } | null = null;
52
97
  private messages: ChatMessage[] = [];
98
+ private pendingMessages: ChatMessage[] = [];
53
99
  private draft = "";
54
100
  private replyToId: string | null = null;
55
101
  private lastCursor: string | null = null;
102
+ private lastViewedMessageId: string | null = null;
56
103
  private wsConnection: ChatConnection | null = null;
57
104
  private wsConnected = false;
58
105
  private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
106
+ private openViewCount = 0;
107
+ private pendingMessageSeq = 0;
108
+ private toastFn: (message: string, options?: ChatToastOptions) => void = () => {};
59
109
  private listeners = new Set<(snapshot: ChatControllerSnapshot) => void>();
60
110
 
61
111
  attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
@@ -64,6 +114,10 @@ export class ChatController {
64
114
  this.hydrate();
65
115
  }
66
116
 
117
+ setToastNotifier(notify: (message: string, options?: ChatToastOptions) => void): void {
118
+ this.toastFn = notify;
119
+ }
120
+
67
121
  hydrate(): void {
68
122
  if (this.hydrated || !this.persistence) return;
69
123
  this.hydrated = true;
@@ -92,7 +146,8 @@ export class ChatController {
92
146
  });
93
147
  this.draft = channel?.draft ?? "";
94
148
  this.replyToId = channel?.replyToId ?? null;
95
- this.lastCursor = channel?.lastCursor ?? null;
149
+ const persistedCursor = channel?.lastCursor ?? null;
150
+ const persistedViewedMessageId = channel?.lastViewedMessageId ?? null;
96
151
 
97
152
  const transcript = this.persistence.getResource<PersistedTranscript>(TRANSCRIPT_KIND, "everyone", {
98
153
  sourceKey: TRANSCRIPT_SOURCE,
@@ -100,6 +155,12 @@ export class ChatController {
100
155
  allowExpired: true,
101
156
  });
102
157
  this.messages = transcript?.value.messages ?? [];
158
+ this.lastCursor = this.messages.length > 0
159
+ ? persistedCursor ?? this.messages[this.messages.length - 1]?.id ?? null
160
+ : null;
161
+ this.lastViewedMessageId = this.user?.id && apiClient.getSessionToken()
162
+ ? persistedViewedMessageId ?? this.messages[this.messages.length - 1]?.id ?? null
163
+ : null;
103
164
  this.syncVerificationPolling();
104
165
  }
105
166
 
@@ -113,12 +174,13 @@ export class ChatController {
113
174
 
114
175
  getSnapshot(): ChatControllerSnapshot {
115
176
  return {
116
- loading: !this.sessionChecked,
177
+ loading: !this.sessionChecked || this.messagesLoading,
117
178
  hasSavedSession: !!apiClient.getSessionToken(),
118
179
  user: this.user,
119
- messages: [...this.messages],
180
+ messages: this.getVisibleMessages(),
120
181
  draft: this.draft,
121
182
  replyToId: this.replyToId,
183
+ unreadMentionCount: this.getUnreadMentionCount(),
122
184
  };
123
185
  }
124
186
 
@@ -126,18 +188,42 @@ export class ChatController {
126
188
  const token = apiClient.getSessionToken();
127
189
  if (!token) {
128
190
  this.stopVerificationPolling();
191
+ this.wsConnection?.close();
192
+ this.wsConnection = null;
193
+ this.wsConnected = false;
129
194
  this.user = null;
130
195
  this.sessionChecked = true;
196
+ this.pendingMessages = [];
197
+ this.lastViewedMessageId = null;
131
198
  this.persistSession();
132
199
  this.emit();
133
200
  return;
134
201
  }
135
202
 
136
203
  const session = await apiClient.getSession();
137
- const nextUser = session
138
- ? { id: session.id, username: session.username ?? session.name, emailVerified: !!session.emailVerified }
139
- : null;
204
+ if (!session) {
205
+ this.stopVerificationPolling();
206
+ this.wsConnection?.close();
207
+ this.wsConnection = null;
208
+ this.wsConnected = false;
209
+ apiClient.setSessionToken(null);
210
+ this.user = null;
211
+ this.sessionChecked = true;
212
+ this.pendingMessages = [];
213
+ this.lastViewedMessageId = null;
214
+ this.persistSession();
215
+ this.emit();
216
+ return;
217
+ }
218
+
219
+ const previousIdentity = `${this.user?.id ?? ""}:${normalizeUsername(this.user?.username) ?? ""}`;
220
+ const nextUser = { id: session.id, username: session.username ?? session.name, emailVerified: !!session.emailVerified };
140
221
  this.user = nextUser;
222
+ const nextIdentity = `${nextUser.id}:${normalizeUsername(nextUser.username) ?? ""}`;
223
+ if (previousIdentity && previousIdentity !== nextIdentity) {
224
+ this.lastViewedMessageId = this.messages[this.messages.length - 1]?.id ?? null;
225
+ this.persistChannelState();
226
+ }
141
227
  this.sessionChecked = true;
142
228
  this.persistSession();
143
229
  this.emit();
@@ -152,10 +238,26 @@ export class ChatController {
152
238
  this.wsConnection?.close();
153
239
  this.wsConnection = null;
154
240
  this.wsConnected = false;
155
- if (!nextUser) {
156
- this.stopVerificationPolling();
157
- this.reset(true);
158
- }
241
+ }
242
+
243
+ async refreshMessages(): Promise<void> {
244
+ if (this.refreshMessagesPromise) return this.refreshMessagesPromise;
245
+
246
+ this.messagesLoading = true;
247
+ this.emit();
248
+
249
+ const request = this.fetchMessages()
250
+ .catch(() => {
251
+ this.persistChannelState();
252
+ })
253
+ .finally(() => {
254
+ this.messagesLoading = false;
255
+ this.refreshMessagesPromise = null;
256
+ this.emit();
257
+ });
258
+
259
+ this.refreshMessagesPromise = request;
260
+ return request;
159
261
  }
160
262
 
161
263
  setDraft(draft: string): void {
@@ -170,6 +272,16 @@ export class ChatController {
170
272
  this.emit();
171
273
  }
172
274
 
275
+ attachView(): () => void {
276
+ this.openViewCount += 1;
277
+ if (this.markViewedThroughLatestMessage()) {
278
+ this.emit();
279
+ }
280
+ return () => {
281
+ this.openViewCount = Math.max(0, this.openViewCount - 1);
282
+ };
283
+ }
284
+
173
285
  clearSession(): void {
174
286
  this.stopVerificationPolling();
175
287
  this.wsConnection?.close();
@@ -177,6 +289,8 @@ export class ChatController {
177
289
  this.wsConnected = false;
178
290
  this.user = null;
179
291
  this.sessionChecked = false;
292
+ this.pendingMessages = [];
293
+ this.lastViewedMessageId = null;
180
294
  this.emit();
181
295
  }
182
296
 
@@ -196,9 +310,12 @@ export class ChatController {
196
310
  this.wsConnection = null;
197
311
  this.wsConnected = false;
198
312
  this.messages = [];
313
+ this.pendingMessages = [];
199
314
  this.draft = "";
200
315
  this.replyToId = null;
201
316
  this.lastCursor = null;
317
+ this.lastViewedMessageId = null;
318
+ this.openViewCount = 0;
202
319
  if (clearSession) {
203
320
  this.user = null;
204
321
  apiClient.setSessionToken(null);
@@ -211,33 +328,60 @@ export class ChatController {
211
328
  this.emit();
212
329
  }
213
330
 
331
+ dispose(): void {
332
+ chatLog.info("dispose controller", {
333
+ listeners: this.listeners.size,
334
+ hasConnection: !!this.wsConnection,
335
+ });
336
+ this.stopVerificationPolling();
337
+ this.wsConnection?.close();
338
+ this.wsConnection = null;
339
+ this.wsConnected = false;
340
+ this.messagesLoading = false;
341
+ this.refreshMessagesPromise = null;
342
+ this.openViewCount = 0;
343
+ this.toastFn = () => {};
344
+ this.listeners.clear();
345
+ }
346
+
214
347
  send(content: string, replyToId?: string): void {
215
- this.wsConnection?.send(content, replyToId);
216
- this.setDraft("");
217
- this.setReplyToId(null);
348
+ if (!this.user?.emailVerified || !apiClient.getSessionToken()) return;
349
+ if (!this.wsConnection && this.user?.emailVerified && apiClient.getSessionToken()) {
350
+ this.ensureConnection();
351
+ }
352
+ const connection = this.wsConnection;
353
+ if (!connection) {
354
+ this.toastFn("Unable to send message right now.", { type: "error" });
355
+ return;
356
+ }
357
+
358
+ const pendingMessage = this.createPendingMessage(content, replyToId);
359
+ this.pendingMessages = [...this.pendingMessages, pendingMessage];
360
+ this.draft = "";
361
+ this.replyToId = null;
362
+ this.persistChannelState();
363
+ this.emit();
364
+
365
+ void connection.send(content, replyToId).then((message) => {
366
+ this.pendingMessages = this.pendingMessages.filter((entry) => entry.id !== pendingMessage.id);
367
+ this.mergeMessages([message]);
368
+ }).catch((error) => {
369
+ const errorMessage = error instanceof Error && error.message ? error.message : "Failed to send message.";
370
+ this.pendingMessages = this.pendingMessages.map((entry) => (
371
+ entry.id === pendingMessage.id
372
+ ? { ...entry, clientStatus: "failed", clientError: errorMessage }
373
+ : entry
374
+ ));
375
+ this.emit();
376
+ this.toastFn(errorMessage, { type: "error" });
377
+ });
218
378
  }
219
379
 
220
380
  ensureConnection(): void {
221
381
  if (this.wsConnected || !this.user?.emailVerified || !apiClient.getSessionToken()) return;
222
382
  this.wsConnected = true;
223
383
 
224
- apiClient.getMessages("everyone", {
225
- limit: MAX_CACHED_MESSAGES,
226
- after: this.lastCursor ?? undefined,
227
- }).then((messages) => {
228
- if (messages.length > 0) {
229
- this.mergeMessages(messages);
230
- return;
231
- }
232
- this.persistChannelState();
233
- }).catch(async () => {
234
- try {
235
- const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
236
- this.mergeMessages(messages);
237
- } catch {
238
- // keep local transcript
239
- }
240
- });
384
+ void this.refreshMessages().catch(() => {});
241
385
 
242
386
  this.wsConnection = apiClient.connectChannel(
243
387
  "everyone",
@@ -279,14 +423,57 @@ export class ChatController {
279
423
  this.verificationPollTimer = null;
280
424
  }
281
425
 
426
+ private async fetchMessages(): Promise<void> {
427
+ const legacyTimestampCursor = isLegacyTimestampCursor(this.lastCursor);
428
+
429
+ try {
430
+ const messages = await apiClient.getMessages("everyone", {
431
+ limit: MAX_CACHED_MESSAGES,
432
+ after: this.lastCursor ?? undefined,
433
+ });
434
+ if (messages.length > 0) {
435
+ this.mergeMessages(messages);
436
+ return;
437
+ }
438
+ if (legacyTimestampCursor) {
439
+ const fullRefresh = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
440
+ if (fullRefresh.length > 0) {
441
+ this.mergeMessages(fullRefresh);
442
+ return;
443
+ }
444
+ this.lastCursor = null;
445
+ }
446
+ this.persistChannelState();
447
+ return;
448
+ } catch {
449
+ const messages = await apiClient.getMessages("everyone", { limit: MAX_CACHED_MESSAGES });
450
+ if (messages.length > 0) {
451
+ this.mergeMessages(messages);
452
+ return;
453
+ }
454
+ this.persistChannelState();
455
+ }
456
+ }
457
+
282
458
  private mergeMessages(messages: ChatMessage[]): void {
459
+ this.reconcilePendingMessages(messages);
283
460
  const merged = new Map<string, ChatMessage>();
461
+ const incoming = new Map<string, ChatMessage>();
284
462
  for (const message of this.messages) merged.set(message.id, message);
285
- for (const message of messages) merged.set(message.id, message);
463
+ for (const message of messages) {
464
+ if (!merged.has(message.id)) {
465
+ incoming.set(message.id, message);
466
+ }
467
+ merged.set(message.id, message);
468
+ }
286
469
  this.messages = [...merged.values()]
287
- .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
470
+ .sort(compareMessages)
288
471
  .slice(-MAX_CACHED_MESSAGES);
289
- this.lastCursor = this.messages[this.messages.length - 1]?.createdAt ?? this.lastCursor;
472
+ this.lastCursor = this.messages[this.messages.length - 1]?.id ?? this.lastCursor;
473
+ if (this.openViewCount > 0) {
474
+ this.markViewedThroughLatestMessage(false);
475
+ }
476
+ this.trackUnreadMentions([...incoming.values()]);
290
477
  this.persistTranscript();
291
478
  this.emit();
292
479
  }
@@ -310,6 +497,7 @@ export class ChatController {
310
497
  draft: this.draft,
311
498
  replyToId: this.replyToId,
312
499
  lastCursor: this.lastCursor,
500
+ lastViewedMessageId: this.lastViewedMessageId,
313
501
  } satisfies PersistedChannelState;
314
502
  this.resume?.setState(CHANNEL_STATE_KEY, value, {
315
503
  schemaVersion: CHANNEL_SCHEMA_VERSION,
@@ -335,6 +523,111 @@ export class ChatController {
335
523
  });
336
524
  this.persistChannelState();
337
525
  }
526
+
527
+ private trackUnreadMentions(messages: ChatMessage[]): void {
528
+ if (this.openViewCount > 0) {
529
+ return;
530
+ }
531
+
532
+ const freshMentions = this.getMentionMessages(messages);
533
+ if (freshMentions.length === 0) return;
534
+ if (!this.appActive) return;
535
+
536
+ if (freshMentions.length === 1) {
537
+ this.toastFn(formatMentionToast(freshMentions[0]!), { type: "info" });
538
+ return;
539
+ }
540
+ this.toastFn(`${freshMentions.length} new mentions in #everyone.`, { type: "info" });
541
+ }
542
+
543
+ private getUnreadMentionCount(): number {
544
+ return this.getUnreadMentionMessages().length;
545
+ }
546
+
547
+ private getUnreadMentionMessages(): ChatMessage[] {
548
+ if (!this.lastViewedMessageId) {
549
+ return this.getMentionMessages(this.messages);
550
+ }
551
+
552
+ const viewedIndex = this.messages.findIndex((message) => message.id === this.lastViewedMessageId);
553
+ const unseenMessages = viewedIndex >= 0
554
+ ? this.messages.slice(viewedIndex + 1)
555
+ : this.messages;
556
+ return this.getMentionMessages(unseenMessages);
557
+ }
558
+
559
+ private getMentionMessages(messages: ChatMessage[]): ChatMessage[] {
560
+ const normalizedUsername = normalizeUsername(this.user?.username);
561
+ if (!normalizedUsername || messages.length === 0) return [];
562
+
563
+ return messages.filter((message) => (
564
+ message.user.id !== this.user?.id && messageMentionsUsername(message.content, normalizedUsername)
565
+ ));
566
+ }
567
+
568
+ private markViewedThroughLatestMessage(persist = true): boolean {
569
+ const latestMessageId = this.messages[this.messages.length - 1]?.id ?? null;
570
+ if (this.lastViewedMessageId === latestMessageId) {
571
+ return false;
572
+ }
573
+ this.lastViewedMessageId = latestMessageId;
574
+ if (persist) {
575
+ this.persistChannelState();
576
+ }
577
+ return true;
578
+ }
579
+
580
+ private getVisibleMessages(): ChatMessage[] {
581
+ return [...this.messages, ...this.pendingMessages]
582
+ .sort(compareMessages)
583
+ .slice(-MAX_CACHED_MESSAGES);
584
+ }
585
+
586
+ private createPendingMessage(content: string, replyToId?: string): ChatMessage {
587
+ const replyToMessage = replyToId
588
+ ? this.getVisibleMessages().find((message) => message.id === replyToId) ?? null
589
+ : null;
590
+ const pendingId = `local:${Date.now()}:${this.pendingMessageSeq += 1}`;
591
+ return {
592
+ id: pendingId,
593
+ channelId: "everyone",
594
+ content,
595
+ replyToId: replyToId ?? null,
596
+ createdAt: new Date().toISOString(),
597
+ user: {
598
+ id: this.user?.id ?? "local",
599
+ username: this.user?.username ?? "you",
600
+ displayName: this.user?.username ?? "You",
601
+ },
602
+ replyTo: replyToMessage
603
+ ? {
604
+ content: replyToMessage.content,
605
+ user: { username: replyToMessage.user.username },
606
+ }
607
+ : null,
608
+ clientStatus: "sending",
609
+ clientError: null,
610
+ };
611
+ }
612
+
613
+ private reconcilePendingMessages(messages: ChatMessage[]): void {
614
+ if (this.pendingMessages.length === 0 || messages.length === 0) return;
615
+
616
+ const remaining = [...this.pendingMessages];
617
+ for (const incoming of messages) {
618
+ const incomingMs = toTimestampMillis(incoming.createdAt);
619
+ const pendingIndex = remaining.findIndex((pending) => (
620
+ pending.user.id === incoming.user.id
621
+ && pending.content === incoming.content
622
+ && pending.replyToId === incoming.replyToId
623
+ && Math.abs(toTimestampMillis(pending.createdAt) - incomingMs) <= PENDING_RECONCILE_WINDOW_MS
624
+ ));
625
+ if (pendingIndex >= 0) {
626
+ remaining.splice(pendingIndex, 1);
627
+ }
628
+ }
629
+ this.pendingMessages = remaining;
630
+ }
338
631
  }
339
632
 
340
633
  export const chatController = new ChatController();