gloomberb 0.17.2 → 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/futures-curve.ts +16 -0
- package/src/api-client/index.ts +22 -9
- package/src/api-client/normalizers.ts +21 -0
- package/src/api-client/socket.ts +27 -0
- package/src/api-client/types.ts +3 -1
- package/src/app/runtime/startup.ts +4 -0
- package/src/cli/commands/market.ts +60 -9
- package/src/cli/commands/search.ts +43 -2
- package/src/cli/commands/ticker.ts +100 -21
- package/src/cli/desktop-pane-shot.ts +34 -9
- package/src/cli/fx-pair.ts +17 -0
- package/src/cli/glossary.ts +134 -6
- package/src/cli/history-rows.ts +16 -4
- package/src/cli/index.ts +44 -12
- package/src/cli/known-gaps.ts +21 -2
- package/src/cli/listing-arg.ts +19 -4
- package/src/cli/listing-suggestion.ts +33 -2
- package/src/cli/pane-functions/dom.ts +39 -6
- package/src/cli/pane-functions/freshness.ts +48 -5
- package/src/cli/pane-functions/function-help.ts +1 -1
- package/src/cli/pane-functions/headless.ts +85 -19
- package/src/cli/pane-functions/index.ts +60 -4
- package/src/cli/pane-functions/market-session.ts +69 -6
- package/src/cli/pane-functions/resolver.ts +4 -2
- package/src/cli/pane-functions/screenshot.ts +14 -1
- package/src/cli/scroll-truncation.ts +5 -1
- package/src/cli/symbol-args.ts +41 -3
- package/src/cli/symbol-list.ts +19 -7
- package/src/cli/unknown-command.ts +70 -0
- package/src/components/chart/native/surface/visibility.ts +37 -7
- package/src/components/index.ts +1 -1
- package/src/components/layout/header.tsx +348 -102
- package/src/components/markdown-text.tsx +40 -66
- package/src/components/market-board/index.tsx +12 -6
- 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/reader/detail-reader.tsx +28 -6
- package/src/components/ticker/badge/index.tsx +2 -0
- package/src/components/ui/button.tsx +24 -9
- package/src/components/ui/display.tsx +6 -1
- package/src/components/ui/external-link.tsx +10 -2
- package/src/components/ui/index.ts +1 -1
- package/src/components/ui/status.tsx +148 -5
- package/src/data/memory-resource-store.ts +1 -1
- package/src/data/resource-store.ts +13 -1
- package/src/docs/snapshot.json +13 -13
- package/src/i18n/es.ts +1 -0
- package/src/i18n/ja.ts +1 -0
- package/src/i18n/ko.ts +1 -0
- package/src/i18n/zh-cn.ts +1 -0
- package/src/i18n/zh-tw.ts +1 -0
- package/src/layout-marketplace/gallery-desktop.tsx +2 -2
- package/src/market-data/futures-board.ts +44 -1
- package/src/market-data/futures-contract.ts +10 -0
- package/src/market-data/market/format.ts +7 -2
- package/src/market-data/market/freshness.ts +25 -1
- package/src/market-data/quotes/regular-session-valuation.ts +32 -1
- package/src/market-data/quotes/resolution.ts +2 -0
- package/src/plugins/builtin/account-management/calendar-tab.tsx +1 -1
- package/src/plugins/builtin/account-management/pane.tsx +17 -13
- package/src/plugins/builtin/alerts/cli.ts +72 -11
- package/src/plugins/builtin/attention/headless.ts +19 -10
- package/src/plugins/builtin/attention/model.ts +22 -0
- package/src/plugins/builtin/awards/headless.ts +5 -1
- package/src/plugins/builtin/bond-calculator/pane.tsx +2 -1
- package/src/plugins/builtin/catalysts/headless.ts +5 -1
- package/src/plugins/builtin/changelog/index.tsx +11 -8
- package/src/plugins/builtin/chart-composer/editor/desktop-view.tsx +2 -0
- package/src/plugins/builtin/chat/attachments/desktop.tsx +2 -1
- package/src/plugins/builtin/chat/attachments/model.ts +5 -3
- package/src/plugins/builtin/chat/content/footer.tsx +6 -0
- package/src/plugins/builtin/chat/content/open-targets.ts +13 -4
- package/src/plugins/builtin/chat/content/transcript.tsx +2 -0
- package/src/plugins/builtin/chat/content/whole-message-copy.ts +70 -0
- 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/chat/layout.ts +91 -31
- package/src/plugins/builtin/chat/markdown.ts +94 -0
- package/src/plugins/builtin/chat/message/desktop.tsx +3 -2
- package/src/plugins/builtin/chat/message/inline-tokens.tsx +197 -80
- package/src/plugins/builtin/chat/message/render-state.ts +3 -0
- package/src/plugins/builtin/chat/message/terminal.tsx +4 -4
- package/src/plugins/builtin/cloud/thesis/exposure.ts +18 -2
- package/src/plugins/builtin/company-attention/headless.ts +8 -5
- package/src/plugins/builtin/company-attention/model.ts +53 -2
- package/src/plugins/builtin/company-attention/pane.tsx +1 -1
- package/src/plugins/builtin/congress-trades/detail.tsx +7 -4
- package/src/plugins/builtin/congress-trades/pane.tsx +3 -1
- package/src/plugins/builtin/correlation/relationship/pane.tsx +3 -7
- package/src/plugins/builtin/cot/client.ts +12 -4
- package/src/plugins/builtin/cot/model.ts +20 -0
- package/src/plugins/builtin/cot/pane.tsx +2 -2
- package/src/plugins/builtin/crypto-board/model.ts +7 -3
- package/src/plugins/builtin/custom-view/pane.tsx +2 -2
- package/src/plugins/builtin/debt-maturities/pane.tsx +1 -6
- package/src/plugins/builtin/debug/index.tsx +15 -15
- package/src/plugins/builtin/dividend-yield/client.ts +8 -1
- package/src/plugins/builtin/dividend-yield/headless.ts +4 -1
- package/src/plugins/builtin/dividend-yield/pane.tsx +6 -5
- package/src/plugins/builtin/docs/index.tsx +5 -3
- package/src/plugins/builtin/docs/pane.tsx +19 -2
- package/src/plugins/builtin/docs/targets.ts +5 -3
- package/src/plugins/builtin/earnings-calls/data.ts +22 -0
- package/src/plugins/builtin/earnings-calls/pane.tsx +24 -14
- package/src/plugins/builtin/equity-screener/headless.ts +4 -3
- package/src/plugins/builtin/equity-screener/live.ts +27 -1
- package/src/plugins/builtin/equity-screener/pane.tsx +21 -2
- package/src/plugins/builtin/fear-greed/charts.tsx +5 -1
- package/src/plugins/builtin/fear-greed/pane.tsx +1 -1
- package/src/plugins/builtin/futures/index.tsx +33 -6
- package/src/plugins/builtin/futures/model.ts +69 -5
- package/src/plugins/builtin/futures/series.ts +3 -3
- package/src/plugins/builtin/futures/table.tsx +31 -11
- package/src/plugins/builtin/futures/use-board-terms.ts +89 -0
- package/src/plugins/builtin/holders/pane.tsx +6 -1
- package/src/plugins/builtin/jobs/pane.tsx +12 -9
- package/src/plugins/builtin/market-heatmap/index.tsx +6 -15
- package/src/plugins/builtin/mna/client.ts +12 -2
- package/src/plugins/builtin/mna/detail.tsx +2 -2
- package/src/plugins/builtin/mna/pane.tsx +2 -2
- package/src/plugins/builtin/news/headless.ts +4 -2
- package/src/plugins/builtin/notes/owner.tsx +5 -4
- package/src/plugins/builtin/notes/ticker-notes-tab.tsx +2 -3
- package/src/plugins/builtin/options-calculator/pane.tsx +2 -3
- package/src/plugins/builtin/plugin-marketplace/pane.tsx +1 -1
- package/src/plugins/builtin/portfolio-list/cli/bulk-symbols.ts +8 -5
- package/src/plugins/builtin/portfolio-list/cli/portfolio-command.ts +4 -3
- package/src/plugins/builtin/portfolio-list/cli/shared.ts +9 -0
- package/src/plugins/builtin/portfolio-list/cli/watchlist-command.ts +6 -3
- package/src/plugins/builtin/research/equity-diagnostic-pane.tsx +4 -12
- package/src/plugins/builtin/research/event-model.ts +14 -0
- package/src/plugins/builtin/research/headless-schema.ts +31 -1
- package/src/plugins/builtin/research/relative-valuation-columns.ts +289 -0
- package/src/plugins/builtin/research/relative-valuation-currency.ts +164 -0
- package/src/plugins/builtin/research/relative-valuation-headless.ts +94 -11
- package/src/plugins/builtin/research/relative-valuation-model.ts +4 -1
- package/src/plugins/builtin/research/relative-valuation-pane.tsx +13 -4
- package/src/plugins/builtin/research/relative-valuation-peers.ts +302 -0
- package/src/plugins/builtin/research-search/data.ts +11 -0
- package/src/plugins/builtin/research-search/pane.tsx +13 -2
- package/src/plugins/builtin/scanner/hilo-pane.tsx +7 -17
- package/src/plugins/builtin/seasonality/client.ts +23 -0
- package/src/plugins/builtin/seasonality/pane.tsx +4 -2
- package/src/plugins/builtin/sectors/index.tsx +7 -3
- package/src/plugins/builtin/shared/report-freshness.ts +6 -2
- package/src/plugins/builtin/short-interest/headless.ts +13 -3
- package/src/plugins/builtin/short-interest/index.tsx +1 -1
- package/src/plugins/builtin/short-interest/model.ts +62 -3
- package/src/plugins/builtin/short-interest/pane.tsx +2 -2
- package/src/plugins/builtin/short-interest/schedule.ts +107 -0
- package/src/plugins/builtin/short-interest/watch-client.ts +54 -8
- package/src/plugins/builtin/short-interest/watch-headless.ts +8 -3
- package/src/plugins/builtin/short-interest/watch-model.ts +52 -23
- package/src/plugins/builtin/short-interest/watch-pane.tsx +1 -1
- package/src/plugins/builtin/supply-chain/flow.tsx +14 -4
- package/src/plugins/builtin/themes/headless.ts +1 -1
- package/src/plugins/builtin/thirteenf/overlap-pane.tsx +23 -27
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -1
- package/src/plugins/builtin/ticker-detail/overview/model.ts +4 -3
- package/src/plugins/builtin/ticker-detail/overview-tab.tsx +4 -4
- package/src/plugins/builtin/ticker-detail/quote-monitor/card.tsx +9 -4
- package/src/plugins/builtin/vol-surface/pane.tsx +16 -1
- package/src/plugins/builtin/volatility/chart-model.ts +1 -1
- package/src/plugins/builtin/volatility/charts.tsx +1 -1
- package/src/renderers/dom/build-assets.ts +17 -4
- package/src/renderers/dom/desktop/controls.tsx +2 -0
- package/src/renderers/dom/styles.css +6 -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 +13 -4
- package/src/sources/provider-router/batches.ts +2 -1
- package/src/sources/provider-router/cache.ts +3 -0
- package/src/sync/baseline.ts +19 -14
- package/src/sync/controller.ts +140 -40
- package/src/sync/core-contributors.ts +256 -32
- package/src/sync/types.ts +44 -0
- package/src/time-series/resolve.ts +24 -5
- package/src/types/data-provider.ts +8 -0
- package/src/types/financials.ts +40 -4
- package/src/types/headless.ts +43 -0
- package/src/types/plugin.ts +7 -0
- 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/declared-dividends.ts +39 -0
- package/src/utils/forward-dividend.ts +34 -11
- package/src/utils/fundamentals.ts +13 -2
- package/src/utils/futures-generic.ts +25 -4
- package/src/utils/markdown.ts +180 -0
- package/src/utils/market-capitalization.ts +22 -0
- package/src/utils/plugin-proxy-hosts.json +5 -0
- package/src/utils/utc-time.ts +14 -3
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { useEffect, useRef } from "react";
|
|
2
|
+
import type { ChatMessage } from "../../../../api-client";
|
|
3
|
+
|
|
4
|
+
const BODY_SELECTOR = "[data-gloom-role=chat-message-body]";
|
|
5
|
+
const MESSAGE_ID_ATTRIBUTE = "data-gloom-chat-message-id";
|
|
6
|
+
|
|
7
|
+
/** Whether the selection takes in every character the body draws, first to last. */
|
|
8
|
+
function coversBody(range: Range, body: Element): boolean {
|
|
9
|
+
const walker = body.ownerDocument.createTreeWalker(body, 4 /* NodeFilter.SHOW_TEXT */);
|
|
10
|
+
let first: [Node, number] | null = null;
|
|
11
|
+
let last: [Node, number] | null = null;
|
|
12
|
+
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
|
|
13
|
+
const text = node.textContent ?? "";
|
|
14
|
+
const start = text.search(/\S/);
|
|
15
|
+
if (start < 0) continue;
|
|
16
|
+
first ??= [node, start];
|
|
17
|
+
last = [node, text.trimEnd().length];
|
|
18
|
+
}
|
|
19
|
+
if (!first || !last) return false;
|
|
20
|
+
try {
|
|
21
|
+
return range.comparePoint(first[0], first[1]) === 0 && range.comparePoint(last[0], last[1]) === 0;
|
|
22
|
+
} catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* What a copy of the selection puts on the clipboard when it takes in whole
|
|
29
|
+
* messages of this list: each as it was written, marks and all, one per line.
|
|
30
|
+
* Null when it takes in part of a message, which copies as drawn, or anything
|
|
31
|
+
* outside this list's messages.
|
|
32
|
+
*/
|
|
33
|
+
function wholeMessagesText(doc: Document, messages: readonly ChatMessage[]): string | null {
|
|
34
|
+
const selection = doc.getSelection();
|
|
35
|
+
if (!selection || selection.rangeCount === 0 || selection.isCollapsed) return null;
|
|
36
|
+
const range = selection.getRangeAt(0);
|
|
37
|
+
const bodies = Array.from(doc.querySelectorAll(BODY_SELECTOR)).filter((body) => range.intersectsNode(body));
|
|
38
|
+
if (bodies.length === 0) return null;
|
|
39
|
+
const parts: string[] = [];
|
|
40
|
+
for (const body of bodies) {
|
|
41
|
+
const id = body.closest(`[${MESSAGE_ID_ATTRIBUTE}]`)?.getAttribute(MESSAGE_ID_ATTRIBUTE);
|
|
42
|
+
const message = id ? messages.find((candidate) => candidate.id === id) : undefined;
|
|
43
|
+
if (!message || !coversBody(range, body)) return null;
|
|
44
|
+
parts.push(message.content);
|
|
45
|
+
}
|
|
46
|
+
return parts.join("\n");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Desktop and web: copying whole messages copies what their authors wrote,
|
|
51
|
+
* markdown and share links included, not the text as drawn. Copying part of a
|
|
52
|
+
* message copies what it shows.
|
|
53
|
+
*/
|
|
54
|
+
export function useWholeMessageCopy(enabled: boolean, messages: readonly ChatMessage[]): void {
|
|
55
|
+
const messagesRef = useRef(messages);
|
|
56
|
+
messagesRef.current = messages;
|
|
57
|
+
useEffect(() => {
|
|
58
|
+
const doc = (globalThis as { document?: Document }).document;
|
|
59
|
+
if (!enabled || !doc) return;
|
|
60
|
+
const onCopy = (event: ClipboardEvent) => {
|
|
61
|
+
if (event.defaultPrevented || !event.clipboardData) return;
|
|
62
|
+
const text = wholeMessagesText(doc, messagesRef.current);
|
|
63
|
+
if (text === null) return;
|
|
64
|
+
event.clipboardData.setData("text/plain", text);
|
|
65
|
+
event.preventDefault();
|
|
66
|
+
};
|
|
67
|
+
doc.addEventListener("copy", onCopy);
|
|
68
|
+
return () => doc.removeEventListener("copy", onCopy);
|
|
69
|
+
}, [enabled]);
|
|
70
|
+
}
|
|
@@ -21,6 +21,21 @@ export function getSafetyRefreshChannelIds(channelStates: Iterable<[string, Chan
|
|
|
21
21
|
return active.length > 0 ? active : [DEFAULT_CHAT_CHANNEL_ID];
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
/**
|
|
25
|
+
* The channels read live, each marked to read its latest page in full on its
|
|
26
|
+
* next refresh: after the socket was down, what changed on messages already
|
|
27
|
+
* held (reactions, edits, removals) does not come with newer messages.
|
|
28
|
+
*/
|
|
29
|
+
export function markConnectedChannelsForBackfill(channelStates: Iterable<[string, ChannelRuntimeState]>): string[] {
|
|
30
|
+
const channelIds: string[] = [];
|
|
31
|
+
for (const [channelId, channel] of channelStates) {
|
|
32
|
+
if (!channel.wsConnected) continue;
|
|
33
|
+
channel.backfilled = false;
|
|
34
|
+
channelIds.push(channelId);
|
|
35
|
+
}
|
|
36
|
+
return channelIds;
|
|
37
|
+
}
|
|
38
|
+
|
|
24
39
|
export function getOpenConnectionChannelIds(channelStates: Iterable<[string, ChannelRuntimeState]>): Set<string> {
|
|
25
40
|
const channelIds = new Set<string>([DEFAULT_CHAT_CHANNEL_ID]);
|
|
26
41
|
for (const [channelId, channel] of channelStates) {
|
|
@@ -76,6 +91,9 @@ export function ensureChatChannelConnection({
|
|
|
76
91
|
if (channel.wsConnected) return;
|
|
77
92
|
channel.wsConnected = true;
|
|
78
93
|
|
|
94
|
+
// Reactions, edits and removals on messages already held while the channel
|
|
95
|
+
// was not read live come only with a full page, not with newer messages.
|
|
96
|
+
channel.backfilled = false;
|
|
79
97
|
void refreshMessages().catch(() => {});
|
|
80
98
|
|
|
81
99
|
channel.wsConnection = connectChannel(
|
|
@@ -35,8 +35,11 @@ export async function fetchLatestChannelMessages(
|
|
|
35
35
|
limit: MESSAGE_PAGE_SIZE,
|
|
36
36
|
after: backfillGaps ? undefined : channel.lastCursor ?? undefined,
|
|
37
37
|
});
|
|
38
|
-
|
|
39
|
-
if (backfillGaps)
|
|
38
|
+
// A full page asked for while this read was out still happens next time.
|
|
39
|
+
if (backfillGaps) {
|
|
40
|
+
channel.backfilled = true;
|
|
41
|
+
channel.backfilledAs = readerId;
|
|
42
|
+
}
|
|
40
43
|
if ((!hasIncrementalCursor || backfillGaps) && messages.length < MESSAGE_PAGE_SIZE) {
|
|
41
44
|
channel.reachedOldestMessage = true;
|
|
42
45
|
}
|
|
@@ -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 {
|