gloomberb 0.17.3 → 0.17.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/package.json +2 -1
- package/src/api-client/chat.ts +28 -0
- package/src/api-client/errors.ts +11 -1
- package/src/api-client/index.ts +4 -1
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/components/index.ts +1 -1
- package/src/components/metric-treemap/heat-surface.tsx +76 -14
- package/src/components/metric-treemap/heat-tiles.ts +5 -3
- package/src/components/metric-treemap/index.tsx +1 -1
- package/src/components/ui/button.tsx +24 -9
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- package/src/plugins/builtin/chat/controller/connections.ts +18 -0
- package/src/plugins/builtin/chat/controller/fetch.ts +5 -2
- package/src/plugins/builtin/chat/controller/index.ts +49 -21
- package/src/plugins/builtin/chat/controller/notifications.ts +132 -72
- package/src/plugins/builtin/chat/controller/realtime.ts +37 -1
- package/src/plugins/builtin/cot/model.ts +20 -0
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/docs/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +2 -3
- package/src/plugins/builtin/futures/model.ts +4 -2
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +2 -3
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/electrobun/bun/desktop/macos-dock-badge.ts +4 -3
- package/src/renderers/electrobun/bun/desktop/macos-dock-icon.ts +2 -3
- package/src/renderers/electrobun/bun/desktop/macos-objc.ts +17 -6
- package/src/sources/gloomberb-cloud/normalizers.ts +12 -4
- package/src/time-series/resolve.ts +5 -3
- package/src/ui/resize-settle.ts +128 -0
- package/src/utils/calendar-date.ts +56 -0
- package/src/utils/decimal-comma.ts +31 -0
- package/src/utils/futures-generic.ts +25 -4
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
type ChatChannel,
|
|
12
12
|
type ChatChannelState,
|
|
13
13
|
type ChatMessage,
|
|
14
|
+
type ChatNotification,
|
|
14
15
|
type ChatReaction,
|
|
15
16
|
type ChatReactionsUpdate,
|
|
16
17
|
type PersistedAuthUser,
|
|
@@ -56,10 +57,9 @@ import {
|
|
|
56
57
|
ensureChatChannelConnection,
|
|
57
58
|
getOpenConnectionChannelIds,
|
|
58
59
|
getSafetyRefreshChannelIds as getChannelIdsForSafetyRefresh,
|
|
60
|
+
markConnectedChannelsForBackfill,
|
|
59
61
|
} from "./connections";
|
|
60
|
-
import {
|
|
61
|
-
handleChatNotification as handleChatNotificationEvent,
|
|
62
|
-
} from "./notifications";
|
|
62
|
+
import { ChatNotificationAlerts } from "./notifications";
|
|
63
63
|
import {
|
|
64
64
|
CHAT_MESSAGE_EDIT_WINDOW_LABEL,
|
|
65
65
|
DISCORD_MESSAGE_EDIT_NOTICE,
|
|
@@ -116,7 +116,14 @@ export class ChatController {
|
|
|
116
116
|
);
|
|
117
117
|
private notifyFn: (notification: AppNotificationRequest) => AppNotificationDelivery | void = () => {};
|
|
118
118
|
private openMessageFn: ((channelId: string, messageId: string) => void) | undefined;
|
|
119
|
-
private
|
|
119
|
+
private readonly alerts = new ChatNotificationAlerts({
|
|
120
|
+
getAppActive: () => this.appActive,
|
|
121
|
+
ensureChannelState: (channelId) => this.ensureChannelState(channelId),
|
|
122
|
+
mergeMessages: (channelId, messages, options) => this.mergeMessages(channelId, messages, options),
|
|
123
|
+
getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
|
|
124
|
+
getNotify: () => this.notifyFn,
|
|
125
|
+
getOpenMessage: () => this.openMessageFn,
|
|
126
|
+
});
|
|
120
127
|
|
|
121
128
|
private readonly storage = new ChatControllerStorage({
|
|
122
129
|
emit: (channelId) => this.emit(channelId),
|
|
@@ -156,6 +163,11 @@ export class ChatController {
|
|
|
156
163
|
getUser: () => this.session.user,
|
|
157
164
|
refreshSession: () => this.refreshSession(),
|
|
158
165
|
handleNotification: (notification) => this.handleChatNotification(notification),
|
|
166
|
+
handleChannelState: (state) => {
|
|
167
|
+
this.applyChannelState(state);
|
|
168
|
+
this.emit(state.channelId);
|
|
169
|
+
this.emit();
|
|
170
|
+
},
|
|
159
171
|
handleReactions: (update) => this.applyReactionsUpdate(update),
|
|
160
172
|
setOnlineCount: (onlineCount) => {
|
|
161
173
|
this.channelCatalog.setOnlineCount(onlineCount);
|
|
@@ -163,6 +175,7 @@ export class ChatController {
|
|
|
163
175
|
emit: () => this.emit(),
|
|
164
176
|
getSafetyRefreshChannelIds: () => getChannelIdsForSafetyRefresh(this.storage.channelStates),
|
|
165
177
|
runSafetyRefresh: (channelId) => this.messageLoading.runMessagesRefresh(channelId, { showLoading: false }),
|
|
178
|
+
resyncChannels: () => this.resyncConnectedChannels(),
|
|
166
179
|
});
|
|
167
180
|
|
|
168
181
|
attachPersistence(persistence: PluginPersistence, resume?: PluginResumeState): void {
|
|
@@ -170,6 +183,14 @@ export class ChatController {
|
|
|
170
183
|
this.hydrate();
|
|
171
184
|
}
|
|
172
185
|
|
|
186
|
+
/**
|
|
187
|
+
* A popped-out window has its own chat state but leaves background banners
|
|
188
|
+
* to the main window, so one message raises one banner per app.
|
|
189
|
+
*/
|
|
190
|
+
setRaisesBackgroundAlerts(raises: boolean): void {
|
|
191
|
+
this.alerts.raisesBackgroundAlerts = raises;
|
|
192
|
+
}
|
|
193
|
+
|
|
173
194
|
setNotifier(
|
|
174
195
|
notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void,
|
|
175
196
|
openMessage?: (channelId: string, messageId: string) => void,
|
|
@@ -466,7 +487,7 @@ export class ChatController {
|
|
|
466
487
|
},
|
|
467
488
|
closeAllConnections: () => this.closeAllConnections(),
|
|
468
489
|
emit: () => this.emit(),
|
|
469
|
-
stopRealtime: () => this.
|
|
490
|
+
stopRealtime: () => this.stopRealtime(),
|
|
470
491
|
});
|
|
471
492
|
}
|
|
472
493
|
|
|
@@ -502,7 +523,7 @@ export class ChatController {
|
|
|
502
523
|
persistChannelState: (channelId) => this.storage.persistChannelState(channelId),
|
|
503
524
|
persistSession: () => this.storage.persistSession(this.session.sessionToken, this.session.user),
|
|
504
525
|
shouldClearSession: clearSession,
|
|
505
|
-
stopRealtime: () => this.
|
|
526
|
+
stopRealtime: () => this.stopRealtime(),
|
|
506
527
|
});
|
|
507
528
|
}
|
|
508
529
|
|
|
@@ -514,7 +535,7 @@ export class ChatController {
|
|
|
514
535
|
});
|
|
515
536
|
disposeChatControllerRuntime({
|
|
516
537
|
channelStates: this.storage.channelStates,
|
|
517
|
-
clearNotifications: () => this.
|
|
538
|
+
clearNotifications: () => this.alerts.clear(),
|
|
518
539
|
clearView: () => this.view.clear(),
|
|
519
540
|
closeAllConnections: () => this.closeAllConnections(),
|
|
520
541
|
flushDraftSync: (channelId) => this.storage.flushDraftSync(channelId),
|
|
@@ -522,7 +543,7 @@ export class ChatController {
|
|
|
522
543
|
this.notifyFn = () => {};
|
|
523
544
|
this.openMessageFn = undefined;
|
|
524
545
|
},
|
|
525
|
-
stopRealtime: () => this.
|
|
546
|
+
stopRealtime: () => this.stopRealtime(),
|
|
526
547
|
});
|
|
527
548
|
}
|
|
528
549
|
|
|
@@ -778,6 +799,12 @@ export class ChatController {
|
|
|
778
799
|
closeAllChannelConnections(this.storage.channelStates.values());
|
|
779
800
|
}
|
|
780
801
|
|
|
802
|
+
/** Live updates stop, and so do alerts still waiting to go up: nobody signed in reads them. */
|
|
803
|
+
private stopRealtime(): void {
|
|
804
|
+
this.realtime.stopAll();
|
|
805
|
+
this.alerts.clear();
|
|
806
|
+
}
|
|
807
|
+
|
|
781
808
|
private applySignedOutSession(): void {
|
|
782
809
|
applySignedOutChatControllerSession({
|
|
783
810
|
channelStates: this.storage.channelStates.values(),
|
|
@@ -785,10 +812,21 @@ export class ChatController {
|
|
|
785
812
|
emit: () => this.emit(),
|
|
786
813
|
persistSession: (sessionToken, user) => this.storage.persistSession(sessionToken, user),
|
|
787
814
|
session: this.session,
|
|
788
|
-
stopRealtime: () => this.
|
|
815
|
+
stopRealtime: () => this.stopRealtime(),
|
|
789
816
|
});
|
|
790
817
|
}
|
|
791
818
|
|
|
819
|
+
/** Each channel read live reads its latest page in full, after any read already out. */
|
|
820
|
+
private resyncConnectedChannels(): void {
|
|
821
|
+
for (const channelId of markConnectedChannelsForBackfill(this.storage.channelStates)) {
|
|
822
|
+
const inFlight = this.ensureChannelState(channelId).refreshMessagesPromise ?? Promise.resolve();
|
|
823
|
+
void inFlight
|
|
824
|
+
.catch(() => {})
|
|
825
|
+
.then(() => this.messageLoading.runMessagesRefresh(channelId, { showLoading: false }))
|
|
826
|
+
.catch(() => {});
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
|
|
792
830
|
private ensureOpenChannelConnections(): void {
|
|
793
831
|
const channelIds = getOpenConnectionChannelIds(this.storage.channelStates);
|
|
794
832
|
closeInactiveChannelConnections(this.storage.channelStates, channelIds);
|
|
@@ -823,20 +861,10 @@ export class ChatController {
|
|
|
823
861
|
}
|
|
824
862
|
|
|
825
863
|
private handleChatNotification(
|
|
826
|
-
notification:
|
|
864
|
+
notification: ChatNotification,
|
|
827
865
|
options: { countUnread?: boolean } = {},
|
|
828
866
|
): void {
|
|
829
|
-
|
|
830
|
-
notification,
|
|
831
|
-
options,
|
|
832
|
-
appActive: this.appActive,
|
|
833
|
-
ensureChannelState: (channelId) => this.ensureChannelState(channelId),
|
|
834
|
-
mergeMessages: (channelId, messages, mergeOptions) => this.mergeMessages(channelId, messages, mergeOptions),
|
|
835
|
-
getChannel: (channelId) => this.channelCatalog.getChannels().find((channel) => channel.id === channelId),
|
|
836
|
-
notifiedMessageIds: this.notifiedMessageIds,
|
|
837
|
-
notify: this.notifyFn,
|
|
838
|
-
openMessage: this.openMessageFn,
|
|
839
|
-
});
|
|
867
|
+
this.alerts.handle(notification, options);
|
|
840
868
|
}
|
|
841
869
|
|
|
842
870
|
private getUnreadMentionCount(channelId: string): number {
|
|
@@ -11,83 +11,143 @@ import {
|
|
|
11
11
|
formatReplyToast,
|
|
12
12
|
} from "./utils";
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
14
|
+
/**
|
|
15
|
+
* How long an app in the background holds a chat banner. The reader may be
|
|
16
|
+
* reading the message at that moment on another device or in a popped-out
|
|
17
|
+
* window, which tells the server within a second; the banner goes up only if
|
|
18
|
+
* the server still has the message unseen.
|
|
19
|
+
*/
|
|
20
|
+
const BACKGROUND_ALERT_DELAY_MS = 2_000;
|
|
17
21
|
|
|
18
|
-
|
|
19
|
-
notification,
|
|
20
|
-
channel,
|
|
21
|
-
notifiedMessageIds,
|
|
22
|
-
notify,
|
|
23
|
-
openMessage,
|
|
24
|
-
}: {
|
|
25
|
-
notification: ChatNotification;
|
|
26
|
-
channel: ChatChannel | undefined;
|
|
27
|
-
notifiedMessageIds: Set<string>;
|
|
28
|
-
notify: (notification: AppNotificationRequest) => AppNotificationDelivery | void;
|
|
29
|
-
openMessage?: (channelId: string, messageId: string) => void;
|
|
30
|
-
}): boolean {
|
|
31
|
-
if (notifiedMessageIds.has(notification.messageId)) return true;
|
|
32
|
-
const channelTitle = formatChatPaneTitle(channel, notification.channelId);
|
|
33
|
-
const body = notification.type === "reply"
|
|
34
|
-
? formatReplyToast(notification.message)
|
|
35
|
-
: notification.type === "mention"
|
|
36
|
-
? formatMentionToast(notification.message)
|
|
37
|
-
: formatChannelToast(notification.message, channel?.kind === "direct");
|
|
38
|
-
const delivered = wasNotificationDelivered(notify({
|
|
39
|
-
title: channelTitle,
|
|
40
|
-
body,
|
|
41
|
-
type: "info",
|
|
42
|
-
desktop: "when-inactive",
|
|
43
|
-
...(openMessage ? {
|
|
44
|
-
action: {
|
|
45
|
-
label: "Open",
|
|
46
|
-
onClick: () => openMessage(notification.channelId, notification.messageId),
|
|
47
|
-
},
|
|
48
|
-
} : {}),
|
|
49
|
-
}));
|
|
50
|
-
if (delivered) {
|
|
51
|
-
notifiedMessageIds.add(notification.messageId);
|
|
52
|
-
}
|
|
53
|
-
return delivered;
|
|
54
|
-
}
|
|
22
|
+
type Notify = (notification: AppNotificationRequest) => AppNotificationDelivery | void;
|
|
55
23
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
options = {},
|
|
59
|
-
ensureChannelState,
|
|
60
|
-
mergeMessages,
|
|
61
|
-
appActive,
|
|
62
|
-
getChannel,
|
|
63
|
-
notifiedMessageIds,
|
|
64
|
-
notify,
|
|
65
|
-
openMessage,
|
|
66
|
-
}: {
|
|
67
|
-
notification: ChatNotification;
|
|
68
|
-
options?: { countUnread?: boolean };
|
|
24
|
+
interface ChatNotificationAlertsOptions {
|
|
25
|
+
getAppActive: () => boolean;
|
|
69
26
|
ensureChannelState: (channelId: string) => ChannelRuntimeState;
|
|
70
27
|
mergeMessages: (channelId: string, messages: ChatMessage[], options?: MergeMessagesOptions) => void;
|
|
71
|
-
appActive: boolean;
|
|
72
28
|
getChannel: (channelId: string) => ChatChannel | undefined;
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
29
|
+
getNotify: () => Notify;
|
|
30
|
+
getOpenMessage: () => ((channelId: string, messageId: string) => void) | undefined;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Whether this window holds the message as read: its read marker is at or after it. */
|
|
34
|
+
function isReadHere(channel: ChannelRuntimeState, messageId: string): boolean {
|
|
35
|
+
const viewedId = channel.lastViewedMessageId;
|
|
36
|
+
if (!viewedId) return false;
|
|
37
|
+
if (viewedId === messageId) return true;
|
|
38
|
+
const viewedIndex = channel.messages.findIndex((message) => message.id === viewedId);
|
|
39
|
+
if (viewedIndex < 0) return false;
|
|
40
|
+
const messageIndex = channel.messages.findIndex((message) => message.id === messageId);
|
|
41
|
+
return messageIndex >= 0 && messageIndex <= viewedIndex;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Tells the server how a notification reached the reader. `seen`: where they
|
|
46
|
+
* were looking (the open channel, a toast in the foreground), which also
|
|
47
|
+
* stops the phone push waiting on it. Without it, only a banner on a
|
|
48
|
+
* computer nobody may be at, which still stops the email and the reminder on
|
|
49
|
+
* the next reconnect.
|
|
50
|
+
*/
|
|
51
|
+
function acknowledge(notification: ChatNotification, seen: boolean): void {
|
|
52
|
+
void apiClient.markChatNotificationsDelivered([notification.id], { seen }).catch(() => {});
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Decides, once per message, whether a chat notification becomes a toast or
|
|
57
|
+
* a desktop banner. A message already read here or on another device stays
|
|
58
|
+
* quiet, whether it arrives live or again on reconnect.
|
|
59
|
+
*/
|
|
60
|
+
export class ChatNotificationAlerts {
|
|
61
|
+
private readonly notifiedMessageIds = new Set<string>();
|
|
62
|
+
private readonly waiting = new Map<string, ReturnType<typeof setTimeout>>();
|
|
63
|
+
private generation = 0;
|
|
64
|
+
/** False in a popped-out window: the main window raises the background banners. */
|
|
65
|
+
raisesBackgroundAlerts = true;
|
|
66
|
+
backgroundDelayMs = BACKGROUND_ALERT_DELAY_MS;
|
|
67
|
+
|
|
68
|
+
constructor(private readonly options: ChatNotificationAlertsOptions) {}
|
|
69
|
+
|
|
70
|
+
handle(notification: ChatNotification, options: { countUnread?: boolean } = {}): void {
|
|
71
|
+
this.options.mergeMessages(notification.channelId, [notification.message], { countUnread: options.countUnread });
|
|
72
|
+
if (this.notifiedMessageIds.has(notification.messageId)) {
|
|
73
|
+
// Shown here already; this copy came back on a reconnect.
|
|
74
|
+
acknowledge(notification, false);
|
|
75
|
+
return;
|
|
76
|
+
}
|
|
77
|
+
if (this.waiting.has(notification.id) || this.settledHere(notification)) return;
|
|
78
|
+
|
|
79
|
+
if (this.options.getAppActive()) {
|
|
80
|
+
this.present(notification);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (!this.raisesBackgroundAlerts) return;
|
|
84
|
+
const generation = this.generation;
|
|
85
|
+
this.waiting.set(notification.id, setTimeout(() => {
|
|
86
|
+
this.waiting.delete(notification.id);
|
|
87
|
+
void this.presentIfUnseen(notification, generation);
|
|
88
|
+
}, this.backgroundDelayMs));
|
|
89
89
|
}
|
|
90
|
-
|
|
91
|
-
|
|
90
|
+
|
|
91
|
+
/** Drops what is waiting and what was shown, as on sign-out. */
|
|
92
|
+
clear(): void {
|
|
93
|
+
this.generation += 1;
|
|
94
|
+
for (const timer of this.waiting.values()) clearTimeout(timer);
|
|
95
|
+
this.waiting.clear();
|
|
96
|
+
this.notifiedMessageIds.clear();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Read here, or open in front of the reader: nothing to raise, and the server hears it was seen. */
|
|
100
|
+
private settledHere(notification: ChatNotification): boolean {
|
|
101
|
+
const channel = this.options.ensureChannelState(notification.channelId);
|
|
102
|
+
const viewing = this.options.getAppActive() && channel.focusedViewCount > 0;
|
|
103
|
+
if (!viewing && !isReadHere(channel, notification.messageId)) return false;
|
|
104
|
+
this.notifiedMessageIds.add(notification.messageId);
|
|
105
|
+
acknowledge(notification, true);
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private async presentIfUnseen(notification: ChatNotification, generation: number): Promise<void> {
|
|
110
|
+
let unseen = true;
|
|
111
|
+
try {
|
|
112
|
+
const ids = await apiClient.getUnseenChatNotificationIds([notification.id]);
|
|
113
|
+
unseen = ids.includes(notification.id);
|
|
114
|
+
} catch {
|
|
115
|
+
// A server that cannot say (an older one, a dropped request) gets the banner, as before.
|
|
116
|
+
}
|
|
117
|
+
if (generation !== this.generation || this.notifiedMessageIds.has(notification.messageId)) return;
|
|
118
|
+
if (!unseen) {
|
|
119
|
+
// Read, or shown in front of the reader, on another device or window.
|
|
120
|
+
this.notifiedMessageIds.add(notification.messageId);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
if (this.settledHere(notification)) return;
|
|
124
|
+
this.present(notification);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
private present(notification: ChatNotification): void {
|
|
128
|
+
const channel = this.options.getChannel(notification.channelId);
|
|
129
|
+
const body = notification.type === "reply"
|
|
130
|
+
? formatReplyToast(notification.message)
|
|
131
|
+
: notification.type === "mention"
|
|
132
|
+
? formatMentionToast(notification.message)
|
|
133
|
+
: formatChannelToast(notification.message, channel?.kind === "direct");
|
|
134
|
+
const openMessage = this.options.getOpenMessage();
|
|
135
|
+
const delivery = this.options.getNotify()({
|
|
136
|
+
title: formatChatPaneTitle(channel, notification.channelId),
|
|
137
|
+
body,
|
|
138
|
+
type: "info",
|
|
139
|
+
desktop: "when-inactive",
|
|
140
|
+
...(openMessage ? {
|
|
141
|
+
action: {
|
|
142
|
+
label: "Open",
|
|
143
|
+
onClick: () => openMessage(notification.channelId, notification.messageId),
|
|
144
|
+
},
|
|
145
|
+
} : {}),
|
|
146
|
+
});
|
|
147
|
+
const toastVisible = !!delivery && delivery.toastVisible;
|
|
148
|
+
// Nothing reached the reader: it stays pending for the email and the next reconnect.
|
|
149
|
+
if (!toastVisible && !(delivery && delivery.desktopRequested)) return;
|
|
150
|
+
this.notifiedMessageIds.add(notification.messageId);
|
|
151
|
+
acknowledge(notification, toastVisible);
|
|
92
152
|
}
|
|
93
153
|
}
|
|
@@ -1,4 +1,9 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
apiClient,
|
|
3
|
+
type ChatChannelState,
|
|
4
|
+
type ChatNotification,
|
|
5
|
+
type ChatReactionsUpdate,
|
|
6
|
+
} from "../../../../api-client";
|
|
2
7
|
import {
|
|
3
8
|
SAFETY_REFRESH_MS,
|
|
4
9
|
SESSION_RETRY_MS,
|
|
@@ -11,20 +16,25 @@ interface ChatControllerRealtimeOptions {
|
|
|
11
16
|
getUser: () => { emailVerified?: boolean } | null;
|
|
12
17
|
refreshSession: () => Promise<void>;
|
|
13
18
|
handleNotification: (notification: ChatNotification) => void;
|
|
19
|
+
handleChannelState: (state: ChatChannelState) => void;
|
|
14
20
|
handleReactions: (update: ChatReactionsUpdate) => void;
|
|
15
21
|
setOnlineCount: (onlineCount: number) => void;
|
|
16
22
|
emit: () => void;
|
|
17
23
|
getSafetyRefreshChannelIds: () => string[];
|
|
18
24
|
runSafetyRefresh: (channelId: string) => Promise<void>;
|
|
25
|
+
/** The socket came back after a drop: the channels read live read their latest page in full. */
|
|
26
|
+
resyncChannels: () => void;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
export class ChatControllerRealtime {
|
|
22
30
|
private verificationPollTimer: ReturnType<typeof setInterval> | null = null;
|
|
23
31
|
private sessionRetryTimer: ReturnType<typeof setTimeout> | null = null;
|
|
24
32
|
private safetyRefreshTimer: ReturnType<typeof setInterval> | null = null;
|
|
33
|
+
private socketConnectionUnsubscribe: (() => void) | null = null;
|
|
25
34
|
private chatNotificationUnsubscribe: (() => void) | null = null;
|
|
26
35
|
private chatPresenceUnsubscribe: (() => void) | null = null;
|
|
27
36
|
private chatReactionsUnsubscribe: (() => void) | null = null;
|
|
37
|
+
private channelStateUnsubscribe: (() => void) | null = null;
|
|
28
38
|
|
|
29
39
|
constructor(private readonly options: ChatControllerRealtimeOptions) {}
|
|
30
40
|
|
|
@@ -72,21 +82,47 @@ export class ChatControllerRealtime {
|
|
|
72
82
|
this.options.handleReactions(update);
|
|
73
83
|
});
|
|
74
84
|
}
|
|
85
|
+
if (!this.channelStateUnsubscribe) {
|
|
86
|
+
// Read or muted on another device or window: the unread count, the
|
|
87
|
+
// Dock badge and alerts still waiting follow without a refocus.
|
|
88
|
+
this.channelStateUnsubscribe = apiClient.subscribeChatChannelState((state) => {
|
|
89
|
+
this.options.handleChannelState(state);
|
|
90
|
+
});
|
|
91
|
+
}
|
|
75
92
|
if (!this.chatPresenceUnsubscribe) {
|
|
76
93
|
this.chatPresenceUnsubscribe = apiClient.subscribeChatPresence((onlineCount) => {
|
|
77
94
|
this.options.setOnlineCount(onlineCount);
|
|
78
95
|
this.options.emit();
|
|
79
96
|
});
|
|
80
97
|
}
|
|
98
|
+
if (!this.socketConnectionUnsubscribe) {
|
|
99
|
+
// Frames sent while the socket was down are gone, and the catch-up
|
|
100
|
+
// only reads newer messages, so a reaction, edit or removal made
|
|
101
|
+
// meanwhile is read with a full page once it is back.
|
|
102
|
+
let dropped = false;
|
|
103
|
+
this.socketConnectionUnsubscribe = apiClient.subscribeSocketConnection((event) => {
|
|
104
|
+
if (event === "closed") {
|
|
105
|
+
dropped = true;
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
if (!dropped) return;
|
|
109
|
+
dropped = false;
|
|
110
|
+
this.options.resyncChannels();
|
|
111
|
+
});
|
|
112
|
+
}
|
|
81
113
|
}
|
|
82
114
|
|
|
83
115
|
stopRealtimeSubscriptions(): void {
|
|
116
|
+
this.socketConnectionUnsubscribe?.();
|
|
117
|
+
this.socketConnectionUnsubscribe = null;
|
|
84
118
|
this.chatNotificationUnsubscribe?.();
|
|
85
119
|
this.chatNotificationUnsubscribe = null;
|
|
86
120
|
this.chatPresenceUnsubscribe?.();
|
|
87
121
|
this.chatPresenceUnsubscribe = null;
|
|
88
122
|
this.chatReactionsUnsubscribe?.();
|
|
89
123
|
this.chatReactionsUnsubscribe = null;
|
|
124
|
+
this.channelStateUnsubscribe?.();
|
|
125
|
+
this.channelStateUnsubscribe = null;
|
|
90
126
|
}
|
|
91
127
|
|
|
92
128
|
stopVerificationPolling(): void {
|
|
@@ -67,11 +67,31 @@ const PRICE_OVERLAYS: Record<string, { exchange: string; priceSymbol: string }>
|
|
|
67
67
|
"040701": { exchange: "NYB", priceSymbol: "OJ=F" }, "058644": { exchange: "CME", priceSymbol: "LBR=F" },
|
|
68
68
|
"057642": { exchange: "CME", priceSymbol: "LE=F" }, "054642": { exchange: "CME", priceSymbol: "HE=F" },
|
|
69
69
|
"061641": { exchange: "CME", priceSymbol: "GF=F" },
|
|
70
|
+
// Sector, index and energy roots added on 2026-10-14, each CFTC market matched
|
|
71
|
+
// by name and open interest against the board and checked for five years of
|
|
72
|
+
// front-price history the same day.
|
|
73
|
+
"138749": { exchange: "CME", priceSymbol: "XAE=F" }, "13874H": { exchange: "CME", priceSymbol: "XAB=F" },
|
|
74
|
+
"13874F": { exchange: "CME", priceSymbol: "XAI=F" }, "138748": { exchange: "CME", priceSymbol: "XAP=F" },
|
|
75
|
+
"13874E": { exchange: "CME", priceSymbol: "XAV=F" }, "13874C": { exchange: "CME", priceSymbol: "XAF=F" },
|
|
76
|
+
"13874I": { exchange: "CME", priceSymbol: "XAK=F" }, "13874P": { exchange: "CME", priceSymbol: "XAZ=F" },
|
|
77
|
+
"13874J": { exchange: "CME", priceSymbol: "XAU=F" }, "240741": { exchange: "CME", priceSymbol: "NKD=F" },
|
|
78
|
+
"240743": { exchange: "CME", priceSymbol: "NIY=F" }, "13874U": { exchange: "CME", priceSymbol: "MES=F" },
|
|
79
|
+
"209747": { exchange: "CME", priceSymbol: "MNQ=F" }, "239747": { exchange: "CME", priceSymbol: "M2K=F" },
|
|
80
|
+
"124608": { exchange: "CBT", priceSymbol: "MYM=F" },
|
|
81
|
+
};
|
|
82
|
+
/**
|
|
83
|
+
* Board roots the CFTC reports whose front alias has no five years of daily
|
|
84
|
+
* history to overlay (checked 2026-10-14): positioning without a price line.
|
|
85
|
+
* The CFTC reports the consumer discretionary sector some weeks only.
|
|
86
|
+
*/
|
|
87
|
+
const UNPRICED_BOARD_MARKETS: Readonly<Record<string, string>> = {
|
|
88
|
+
EMD: "33874A", RX: "124606", XAY: "138747", HH: "023A55",
|
|
70
89
|
};
|
|
71
90
|
/** The CFTC market a FUT board root reports under, by its price symbol; null for one the CFTC does not report (TTF). */
|
|
72
91
|
function boardMarketCode(contract: FuturesContract): string | null {
|
|
73
92
|
return Object.values(ROOTS).find((row) => row.priceSymbol === contract.symbol)?.code
|
|
74
93
|
?? Object.entries(PRICE_OVERLAYS).find(([, row]) => row.priceSymbol === contract.symbol)?.[0]
|
|
94
|
+
?? UNPRICED_BOARD_MARKETS[contract.code]
|
|
75
95
|
?? null;
|
|
76
96
|
}
|
|
77
97
|
/**
|
|
@@ -10,7 +10,7 @@ import { teamViewsStore } from "../cloud/team/views";
|
|
|
10
10
|
import { setPaneSettings } from "../../../pane-settings";
|
|
11
11
|
import { customViewInstanceSettings } from "./index";
|
|
12
12
|
import type { DataTableColumn } from "../../../components/ui/data-table/types";
|
|
13
|
-
import {
|
|
13
|
+
import { PaneStatusBody, TextPromptDialog, usePaneFooter } from "../../../components";
|
|
14
14
|
import { useAsyncResource } from "../../../react/async-resource";
|
|
15
15
|
import { useShortcut } from "../../../react/input";
|
|
16
16
|
import { isPlainKey } from "../../../utils/keyboard";
|
|
@@ -370,7 +370,7 @@ export function CustomViewPane({ focused, width, height }: PaneProps) {
|
|
|
370
370
|
<PaneStatusBody
|
|
371
371
|
error={error}
|
|
372
372
|
errorTitle="This view could not load."
|
|
373
|
-
|
|
373
|
+
retry={{ onRetry: reload }}
|
|
374
374
|
/>
|
|
375
375
|
);
|
|
376
376
|
}
|
|
@@ -182,7 +182,7 @@ function LessonEntry({ url, width, onOpenLink }: { url: string; width: number; o
|
|
|
182
182
|
<EmptyState
|
|
183
183
|
title="This lesson is not saved for offline reading."
|
|
184
184
|
hint="It is kept once it has been read with a connection."
|
|
185
|
-
|
|
185
|
+
retry={{ onRetry: lesson.retry }}
|
|
186
186
|
/>
|
|
187
187
|
);
|
|
188
188
|
}
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
import {
|
|
3
|
-
Button,
|
|
4
3
|
DataTableView,
|
|
5
4
|
PaneStatusBody,
|
|
6
5
|
QueryBar,
|
|
@@ -286,7 +285,7 @@ export function FuturesPane({ focused, width, height }: PaneProps) {
|
|
|
286
285
|
height={height}
|
|
287
286
|
error={errorMessage ?? "No quotes returned."}
|
|
288
287
|
subject="Futures quotes"
|
|
289
|
-
|
|
288
|
+
retry={{ onRetry: refresh }}
|
|
290
289
|
/>
|
|
291
290
|
);
|
|
292
291
|
}
|
|
@@ -389,7 +388,7 @@ export const futuresModule: PluginModule = {
|
|
|
389
388
|
paneId: FUTURES_PANE_ID,
|
|
390
389
|
label: "Futures Board",
|
|
391
390
|
description:
|
|
392
|
-
"Each root's most active futures contract across equity index, rates, energy, metals, agriculture, livestock, and FX with last price, unit, session change, search, and collapsible sectors.",
|
|
391
|
+
"Each root's most active futures contract across equity index, equity sectors, rates, energy, metals, agriculture, livestock, and FX with last price, unit, session change, search, and collapsible sectors.",
|
|
393
392
|
keywords: [
|
|
394
393
|
"futures",
|
|
395
394
|
"commodities",
|
|
@@ -40,7 +40,8 @@ export function futuresRowId(row: FuturesTableRow): string {
|
|
|
40
40
|
return row.type === "header" ? `header-${row.sector}` : row.contract.symbol;
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
|
|
43
|
+
/** "Nov 26" or "Futures,Nov-2026"; a hyphen takes only a whole year, since "Futures,Nov-20" is 2026 cut short. */
|
|
44
|
+
const CONTRACT_MONTH = /\b(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)(?: (\d{4}|\d{2})|-(\d{4}))$/i;
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* The alias stays the row identity while its quoted contract rolls, so the
|
|
@@ -66,7 +67,8 @@ export function quotedContractMonth(
|
|
|
66
67
|
const match = CONTRACT_MONTH.exec(quote.name?.trim() ?? "");
|
|
67
68
|
if (!match) return null;
|
|
68
69
|
const month = MONTH_NAMES.findIndex((name) => name.toLowerCase() === match[1]!.toLowerCase());
|
|
69
|
-
const
|
|
70
|
+
const written = match[2] ?? match[3]!;
|
|
71
|
+
const year = written.length === 2 ? 2000 + Number(written) : Number(written);
|
|
70
72
|
return { year, month };
|
|
71
73
|
}
|
|
72
74
|
|
|
@@ -9,8 +9,8 @@ import { FUTURES_CONTRACTS, FUTURES_SECTOR_LABELS, type FuturesContract } from "
|
|
|
9
9
|
const US_CENT_CONTRACTS: ReadonlySet<string> = new Set(["ZC", "ZS", "ZL", "ZW", "KE", "KC", "SB", "CT"]);
|
|
10
10
|
|
|
11
11
|
/**
|
|
12
|
-
* The unit the board prints, not a blanket dollar price. Index
|
|
13
|
-
* points, rates are points (the board prints decimals, not 32nds), cents
|
|
12
|
+
* The unit the board prints, not a blanket dollar price. Index and sector
|
|
13
|
+
* futures are points, rates are points (the board prints decimals, not 32nds), cents
|
|
14
14
|
* contracts are USX, and a declared quote unit wins over USD. Currency
|
|
15
15
|
* futures are dollars per unit of the foreign currency.
|
|
16
16
|
*/
|
|
@@ -18,7 +18,7 @@ function futuresSeriesUnit(contract: FuturesContract): string {
|
|
|
18
18
|
if (contract.unit?.startsWith("US cents") || US_CENT_CONTRACTS.has(contract.code)) return "USX";
|
|
19
19
|
if (contract.unit?.startsWith("EUR")) return "EUR";
|
|
20
20
|
if (contract.unit?.startsWith("USD")) return "USD";
|
|
21
|
-
if (contract.sector === "equity-index" || contract.sector === "rates") return "points";
|
|
21
|
+
if (contract.sector === "equity-index" || contract.sector === "equity-sectors" || contract.sector === "rates") return "points";
|
|
22
22
|
return "USD";
|
|
23
23
|
}
|
|
24
24
|
|
|
@@ -4,7 +4,6 @@ import type { CloudJobsMoverPayload, CloudJobsPosting, CloudJobsSummaryPayload }
|
|
|
4
4
|
import {
|
|
5
5
|
chartTableChromeRows,
|
|
6
6
|
Badge,
|
|
7
|
-
Button,
|
|
8
7
|
ChartTableHeader,
|
|
9
8
|
CompositeChart,
|
|
10
9
|
DataTableStackView,
|
|
@@ -536,7 +535,7 @@ function CompanyPanel({
|
|
|
536
535
|
[symbol, companyName],
|
|
537
536
|
);
|
|
538
537
|
const resource = useAsyncResource<JobsCompanyState>(request);
|
|
539
|
-
const { data, status, error, reload } = resource;
|
|
538
|
+
const { data, status, error, loading, reload } = resource;
|
|
540
539
|
|
|
541
540
|
// `r` asks again in every state, including a failed or uncovered company.
|
|
542
541
|
usePaneRefreshKey(reload, { focused });
|
|
@@ -555,8 +554,10 @@ function CompanyPanel({
|
|
|
555
554
|
if ((status === "idle" || status === "loading") && !data) {
|
|
556
555
|
return <PaneStatusBody loading align="center" loadingLabel={`Loading ${symbol} hiring...`} />;
|
|
557
556
|
}
|
|
558
|
-
|
|
559
|
-
|
|
557
|
+
// A failed look at an uncovered company says so: the old answer left up
|
|
558
|
+
// would read as the new one.
|
|
559
|
+
if (status === "error" && (!data || data.kind === "uncovered")) {
|
|
560
|
+
return <PaneStatusBody error={error ?? "Could not load hiring data."} errorTitle="Could not load hiring data." retry={{ onRetry: reload }} />;
|
|
560
561
|
}
|
|
561
562
|
if (!data) return null;
|
|
562
563
|
if (data.kind === "pending") {
|
|
@@ -569,11 +570,13 @@ function CompanyPanel({
|
|
|
569
570
|
);
|
|
570
571
|
}
|
|
571
572
|
if (data.kind === "uncovered") {
|
|
573
|
+
// The answer stays on screen while it asks again, so the button says it is asking.
|
|
572
574
|
return (
|
|
573
|
-
<
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
575
|
+
<PaneStatusBody
|
|
576
|
+
empty
|
|
577
|
+
emptyTitle={`No careers system found for ${symbol}.`}
|
|
578
|
+
emptyMessage="Gloomberb reads companies' own careers systems. This one either has none we can read or lists roles only on third-party job boards."
|
|
579
|
+
retry={{ onRetry: reload, pending: loading, label: "Look again", pendingLabel: "Looking again...", unchanged: "Still no careers system." }}
|
|
577
580
|
/>
|
|
578
581
|
);
|
|
579
582
|
}
|
|
@@ -684,7 +687,7 @@ function HomeView({ width, height, focused, registrationId }: { width: number; h
|
|
|
684
687
|
}, []);
|
|
685
688
|
|
|
686
689
|
if (status === "loading" && !data) return <PaneStatusBody loading align="center" loadingLabel="Loading hiring data..." />;
|
|
687
|
-
if (status === "error" && !data) return <PaneStatusBody error={error ?? "Could not load hiring data."} errorTitle="Could not load hiring data."
|
|
690
|
+
if (status === "error" && !data) return <PaneStatusBody error={error ?? "Could not load hiring data."} errorTitle="Could not load hiring data." retry={{ onRetry: reload }} />;
|
|
688
691
|
|
|
689
692
|
const tableHeight = Math.max(3, height - (nativePaneChrome ? 1 : 0));
|
|
690
693
|
return (
|