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