gloomberb 0.9.6 → 0.9.8
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 +1 -1
- package/src/api-client/auth.ts +20 -0
- package/src/api-client/index.ts +48 -0
- package/src/api-client/types.ts +45 -0
- package/src/app/runtime/desktop-deeplink.test.ts +87 -0
- package/src/app/runtime/desktop-deeplink.ts +496 -0
- package/src/app.tsx +34 -0
- package/src/components/chart/bar-chart-renderer.test.ts +87 -12
- package/src/components/chart/bar-chart-renderer.ts +111 -55
- package/src/components/chart/static/bar-chart-surface.tsx +104 -20
- package/src/components/chart/stock-chart/footer.test.ts +65 -0
- package/src/components/chart/stock-chart/footer.ts +68 -13
- package/src/components/chart/stock-chart/resolved.tsx +18 -4
- package/src/components/command-bar/pane-templates/items.ts +11 -3
- package/src/components/command-bar/routes/root/results.ts +1 -1
- package/src/components/command-bar/surface/index.test.tsx +52 -0
- package/src/components/command-bar/workflow/ops.ts +81 -20
- package/src/components/index.ts +1 -0
- package/src/components/toggle-list.tsx +29 -73
- package/src/components/ui/button.tsx +4 -1
- package/src/components/ui/checkbox.tsx +76 -0
- package/src/components/ui/choice-dialog.tsx +15 -3
- package/src/components/ui/frame.tsx +1 -1
- package/src/components/ui/index.ts +2 -0
- package/src/components/ui/message-composer.tsx +4 -0
- package/src/components/ui/native-select.tsx +4 -2
- package/src/market-data/coordinator/events.test.ts +49 -0
- package/src/market-data/coordinator/events.ts +25 -4
- package/src/market-data/coordinator/index.test.ts +223 -0
- package/src/market-data/coordinator/index.ts +40 -3
- package/src/market-data/coordinator/quotes.ts +13 -5
- package/src/market-data/hooks.tsx +17 -13
- package/src/market-data/market/freshness.ts +58 -1
- package/src/market-data/quotes/freshness.test.ts +49 -1
- package/src/market-data/quotes/freshness.ts +8 -0
- package/src/market-data/quotes/resolution.test.ts +185 -0
- package/src/market-data/quotes/resolution.ts +94 -8
- package/src/market-data/request-types.test.ts +61 -1
- package/src/market-data/request-types.ts +42 -3
- package/src/pane-settings.test.ts +1 -0
- package/src/plugins/builtin/account-management/footer.ts +2 -5
- package/src/plugins/builtin/account-management/form-components.tsx +260 -24
- package/src/plugins/builtin/account-management/keyboard.ts +32 -2
- package/src/plugins/builtin/account-management/model.test.ts +99 -4
- package/src/plugins/builtin/account-management/model.ts +263 -3
- package/src/plugins/builtin/account-management/pane.tsx +784 -155
- package/src/plugins/builtin/chat/chat.test.tsx +125 -0
- package/src/plugins/builtin/chat/content/channel-navigation.ts +1 -21
- package/src/plugins/builtin/chat/content/composer-cursor.ts +29 -0
- package/src/plugins/builtin/chat/content/composer-runtime.ts +22 -24
- package/src/plugins/builtin/chat/content/composer.tsx +84 -1
- package/src/plugins/builtin/chat/content/index.tsx +154 -16
- package/src/plugins/builtin/chat/content/layout-metrics.ts +10 -1
- package/src/plugins/builtin/chat/content/mentions.test.ts +74 -0
- package/src/plugins/builtin/chat/content/mentions.ts +88 -0
- package/src/plugins/builtin/chat/content/new-dm-dialog.tsx +264 -0
- package/src/plugins/builtin/chat/content/shortcuts.ts +47 -1
- package/src/plugins/builtin/chat/content/snapshot.ts +7 -0
- package/src/plugins/builtin/chat/content/transcript.tsx +0 -4
- package/src/plugins/builtin/chat/message/profile-popover.test.tsx +36 -0
- package/src/plugins/builtin/chat/message/profile-popover.tsx +124 -20
- package/src/plugins/builtin/chat/sidebar.test.tsx +69 -2
- package/src/plugins/builtin/chat/sidebar.tsx +26 -2
- package/src/plugins/builtin/chat/test-harness.tsx +21 -11
- package/src/plugins/builtin/cloud/plugin.tsx +7 -0
- package/src/plugins/builtin/correlation/index.tsx +4 -1
- package/src/plugins/builtin/correlation/relationship/controls.tsx +8 -2
- package/src/plugins/builtin/correlation/relationship/model.ts +22 -2
- package/src/plugins/builtin/correlation/relationship/pane.tsx +9 -3
- package/src/plugins/builtin/portfolio-list/index.test.ts +102 -1
- package/src/plugins/builtin/portfolio-list/index.tsx +1 -0
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +62 -1
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +414 -8
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +24 -32
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +131 -22
- package/src/plugins/builtin/portfolio-list/settings.ts +36 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/content.tsx +43 -7
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/model.ts +415 -64
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/settings.ts +23 -2
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph/types.ts +8 -1
- package/src/plugins/builtin/ticker-detail/data-panes/fundamental-graph.tsx +3 -1
- package/src/plugins/builtin/ticker-detail/data-panes.test.ts +95 -1
- package/src/plugins/builtin/ticker-detail/index.tsx +2 -0
- package/src/plugins/ibkr/gateway/market-data.ts +12 -5
- package/src/plugins/ibkr/gateway/service/index.test.ts +31 -0
- package/src/plugins/registry/context.ts +15 -0
- package/src/plugins/registry/index.ts +31 -0
- package/src/renderers/electrobun/bun/index.ts +42 -0
- package/src/renderers/electrobun/shared/protocol.ts +5 -0
- package/src/renderers/electrobun/view/backend-rpc.ts +29 -0
- package/src/renderers/electrobun/view/desktop/controls.tsx +135 -4
- package/src/renderers/electrobun/view/desktop/list-view.tsx +38 -25
- package/src/renderers/electrobun/view/desktop-deeplink-bridge.ts +10 -0
- package/src/renderers/electrobun/view/host/box.tsx +0 -2
- package/src/renderers/electrobun/view/host/scroll-box.tsx +1 -2
- package/src/renderers/electrobun/view/host/text.tsx +1 -2
- package/src/renderers/electrobun/view/main.tsx +3 -0
- package/src/renderers/electrobun/view/ui-host.tsx +2 -0
- package/src/sources/provider-router/batches.ts +11 -3
- package/src/sources/provider-router/cached-financials.test.ts +123 -0
- package/src/sources/provider-router/financial-routes.ts +55 -5
- package/src/sources/provider-router/financials.test.ts +49 -0
- package/src/sources/provider-router/financials.ts +40 -3
- package/src/sources/provider-router/index.test.ts +147 -0
- package/src/sources/provider-router/primary.ts +17 -3
- package/src/sources/provider-router/streaming.ts +6 -1
- package/src/sources/sec-edgar.test.ts +42 -0
- package/src/sources/sec-edgar.ts +1 -2
- package/src/sources/yahoo-finance/mappers.test.ts +29 -0
- package/src/sources/yahoo-finance/mappers.ts +2 -1
- package/src/sources/yahoo-finance/requests.ts +2 -1
- package/src/sources/yahoo-finance/snapshots.ts +13 -2
- package/src/sources/yahoo-finance.ts +6 -2
- package/src/state/hooks/quote-streaming.test.ts +14 -1
- package/src/state/hooks/quote-streaming.ts +5 -4
- package/src/sync/controller.ts +296 -0
- package/src/sync/core-contributors.test.ts +251 -0
- package/src/sync/core-contributors.ts +400 -0
- package/src/sync/profile-analytics.ts +39 -0
- package/src/sync/react.ts +53 -0
- package/src/sync/types.ts +77 -0
- package/src/types/config.ts +1 -0
- package/src/types/desktop-deeplink.ts +7 -0
- package/src/types/plugin.ts +3 -0
- package/src/ui/host.tsx +14 -1
- package/src/ui/index.tsx +1 -0
- package/src/utils/exchanges.test.ts +11 -1
- package/src/utils/exchanges.ts +5 -1
- package/src/version.ts +1 -1
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import { useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
+
import { ListView, TextField, type ListViewItem } from "../../../../components/ui";
|
|
3
|
+
import { useShortcut } from "../../../../react/input";
|
|
4
|
+
import { colors, hoverBg } from "../../../../theme/colors";
|
|
5
|
+
import { Box, Text, TextAttributes, type InputRenderable } from "../../../../ui";
|
|
6
|
+
import type { ChatUserSummary } from "../../../../api-client";
|
|
7
|
+
import { isPlainKey } from "../../../../utils/keyboard";
|
|
8
|
+
import {
|
|
9
|
+
hasOnlyDmUsernameArgs,
|
|
10
|
+
parseDmUsernames,
|
|
11
|
+
truncateChannelLabel,
|
|
12
|
+
} from "../channels";
|
|
13
|
+
|
|
14
|
+
const MAX_RECENT_USERS = 6;
|
|
15
|
+
const MIN_DIALOG_WIDTH = 32;
|
|
16
|
+
const MAX_DIALOG_WIDTH = 52;
|
|
17
|
+
const DIALOG_HEIGHT = 10;
|
|
18
|
+
|
|
19
|
+
interface DmUserCandidate {
|
|
20
|
+
username: string;
|
|
21
|
+
displayName: string;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeUsername(value: string | null | undefined): string {
|
|
25
|
+
return value?.trim().replace(/^@+/, "").toLowerCase() ?? "";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function currentTokenQuery(value: string): string {
|
|
29
|
+
const token = value.split(/[\s,]+/).at(-1) ?? "";
|
|
30
|
+
return normalizeUsername(token);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function candidateUsers(
|
|
34
|
+
userByUsername: Map<string, ChatUserSummary>,
|
|
35
|
+
currentUserId: string | null | undefined,
|
|
36
|
+
): DmUserCandidate[] {
|
|
37
|
+
const candidates = new Map<string, DmUserCandidate>();
|
|
38
|
+
for (const [key, user] of userByUsername) {
|
|
39
|
+
if (currentUserId && user.id === currentUserId) continue;
|
|
40
|
+
const username = normalizeUsername(user.username ?? key);
|
|
41
|
+
if (!username) continue;
|
|
42
|
+
candidates.set(username, {
|
|
43
|
+
username,
|
|
44
|
+
displayName: user.displayName?.trim() || `@${username}`,
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
return [...candidates.values()].sort((left, right) => left.username.localeCompare(right.username));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function setUsernameSelected(value: string, username: string, selected: boolean): string {
|
|
51
|
+
const usernames = parseDmUsernames(value).filter((existing) => existing !== username);
|
|
52
|
+
if (selected) usernames.push(username);
|
|
53
|
+
return usernames.map((entry) => `@${entry}`).join(" ") + (usernames.length > 0 ? " " : "");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function usernamesLabel(usernames: string[], width: number): string {
|
|
57
|
+
return truncateChannelLabel(usernames.map((username) => `@${username}`).join(" "), width);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function NewDmDialog({
|
|
61
|
+
width,
|
|
62
|
+
height,
|
|
63
|
+
userByUsername,
|
|
64
|
+
currentUserId,
|
|
65
|
+
onCancel,
|
|
66
|
+
onSubmit,
|
|
67
|
+
}: {
|
|
68
|
+
width: number;
|
|
69
|
+
height: number;
|
|
70
|
+
userByUsername: Map<string, ChatUserSummary>;
|
|
71
|
+
currentUserId?: string | null;
|
|
72
|
+
onCancel: () => void;
|
|
73
|
+
onSubmit: (usernames: string[]) => Promise<void>;
|
|
74
|
+
}) {
|
|
75
|
+
const inputRef = useRef<InputRenderable | null>(null);
|
|
76
|
+
const [value, setValue] = useState("");
|
|
77
|
+
const valueRef = useRef("");
|
|
78
|
+
const [selectedIndex, setSelectedIndex] = useState(0);
|
|
79
|
+
const [submitting, setSubmitting] = useState(false);
|
|
80
|
+
const [error, setError] = useState<string | null>(null);
|
|
81
|
+
const dialogWidth = Math.max(MIN_DIALOG_WIDTH, Math.min(MAX_DIALOG_WIDTH, width - 4));
|
|
82
|
+
const dialogHeight = Math.min(DIALOG_HEIGHT, Math.max(7, height - 2));
|
|
83
|
+
const left = Math.max(0, Math.floor((width - dialogWidth) / 2));
|
|
84
|
+
const top = Math.max(0, Math.floor((height - dialogHeight) / 2));
|
|
85
|
+
const contentWidth = Math.max(1, dialogWidth - 4);
|
|
86
|
+
const selectedUsernames = useMemo(() => parseDmUsernames(value), [value]);
|
|
87
|
+
const selectedUsernameSet = useMemo(() => new Set(selectedUsernames), [selectedUsernames]);
|
|
88
|
+
const allCandidates = useMemo(() => candidateUsers(userByUsername, currentUserId), [currentUserId, userByUsername]);
|
|
89
|
+
const query = currentTokenQuery(value);
|
|
90
|
+
const visibleCandidates = useMemo(() => {
|
|
91
|
+
const filtered = query
|
|
92
|
+
? allCandidates.filter((candidate) => candidate.username.includes(query))
|
|
93
|
+
: allCandidates;
|
|
94
|
+
return filtered.slice(0, MAX_RECENT_USERS);
|
|
95
|
+
}, [allCandidates, query]);
|
|
96
|
+
const items = useMemo<ListViewItem[]>(() => visibleCandidates.map((candidate) => ({
|
|
97
|
+
id: candidate.username,
|
|
98
|
+
label: `@${candidate.username}`,
|
|
99
|
+
detail: candidate.displayName,
|
|
100
|
+
checked: selectedUsernameSet.has(candidate.username),
|
|
101
|
+
})), [selectedUsernameSet, visibleCandidates]);
|
|
102
|
+
const canSubmit = hasOnlyDmUsernameArgs(value) && selectedUsernames.length > 0 && !submitting;
|
|
103
|
+
|
|
104
|
+
useEffect(() => {
|
|
105
|
+
inputRef.current?.focus?.();
|
|
106
|
+
}, []);
|
|
107
|
+
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
setSelectedIndex((current) => Math.max(0, Math.min(current, Math.max(items.length - 1, 0))));
|
|
110
|
+
}, [items.length]);
|
|
111
|
+
|
|
112
|
+
const updateValue = (nextValue: string) => {
|
|
113
|
+
valueRef.current = nextValue;
|
|
114
|
+
setValue(nextValue);
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const toggleCandidate = (username: string) => {
|
|
118
|
+
updateValue(setUsernameSelected(valueRef.current, username, !parseDmUsernames(valueRef.current).includes(username)));
|
|
119
|
+
setError(null);
|
|
120
|
+
queueMicrotask(() => inputRef.current?.focus?.());
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const submit = async () => {
|
|
124
|
+
const submittedValue = valueRef.current;
|
|
125
|
+
const submittedUsernames = parseDmUsernames(submittedValue);
|
|
126
|
+
if (!hasOnlyDmUsernameArgs(submittedValue) || submittedUsernames.length === 0 || submitting) {
|
|
127
|
+
setError("Enter at least one @username.");
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
setSubmitting(true);
|
|
131
|
+
setError(null);
|
|
132
|
+
try {
|
|
133
|
+
await onSubmit(submittedUsernames);
|
|
134
|
+
} catch {
|
|
135
|
+
setError("Could not start conversation.");
|
|
136
|
+
setSubmitting(false);
|
|
137
|
+
}
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
useShortcut((event) => {
|
|
141
|
+
if (event.name === "escape") {
|
|
142
|
+
event.preventDefault?.();
|
|
143
|
+
event.stopPropagation?.();
|
|
144
|
+
onCancel();
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
if (isPlainKey(event, "up", "down")) {
|
|
148
|
+
event.preventDefault?.();
|
|
149
|
+
event.stopPropagation?.();
|
|
150
|
+
setSelectedIndex((current) => {
|
|
151
|
+
if (items.length === 0) return 0;
|
|
152
|
+
return event.name === "up"
|
|
153
|
+
? Math.max(0, current - 1)
|
|
154
|
+
: Math.min(items.length - 1, current + 1);
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
if (event.name === "tab" && items[selectedIndex]) {
|
|
159
|
+
event.preventDefault?.();
|
|
160
|
+
event.stopPropagation?.();
|
|
161
|
+
toggleCandidate(items[selectedIndex]!.id);
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
if (event.name === "enter" || event.name === "return") {
|
|
165
|
+
event.preventDefault?.();
|
|
166
|
+
event.stopPropagation?.();
|
|
167
|
+
void submit();
|
|
168
|
+
}
|
|
169
|
+
}, { allowEditable: true, phase: "before" });
|
|
170
|
+
|
|
171
|
+
return (
|
|
172
|
+
<Box
|
|
173
|
+
position="absolute"
|
|
174
|
+
left={left}
|
|
175
|
+
top={top}
|
|
176
|
+
width={dialogWidth}
|
|
177
|
+
height={dialogHeight}
|
|
178
|
+
flexDirection="column"
|
|
179
|
+
border
|
|
180
|
+
borderColor={colors.borderFocused}
|
|
181
|
+
backgroundColor={colors.bg}
|
|
182
|
+
paddingX={1}
|
|
183
|
+
onMouseDown={(event: any) => {
|
|
184
|
+
event?.preventDefault?.();
|
|
185
|
+
event?.stopPropagation?.();
|
|
186
|
+
}}
|
|
187
|
+
style={{ zIndex: 8 }}
|
|
188
|
+
>
|
|
189
|
+
<Box height={1} flexDirection="row">
|
|
190
|
+
<Text fg={colors.textBright} attributes={TextAttributes.BOLD}>New DM</Text>
|
|
191
|
+
<Box flexGrow={1} />
|
|
192
|
+
<Box width={3} height={1} backgroundColor={colors.panel} onMouseDown={onCancel} style={{ cursor: "pointer" }}>
|
|
193
|
+
<Text fg={colors.text}> x </Text>
|
|
194
|
+
</Box>
|
|
195
|
+
</Box>
|
|
196
|
+
<TextField
|
|
197
|
+
inputRef={inputRef}
|
|
198
|
+
value={value}
|
|
199
|
+
placeholder="@username, @second"
|
|
200
|
+
focused
|
|
201
|
+
width={contentWidth}
|
|
202
|
+
backgroundColor={colors.panel}
|
|
203
|
+
onChange={(nextValue) => {
|
|
204
|
+
updateValue(nextValue);
|
|
205
|
+
setError(null);
|
|
206
|
+
}}
|
|
207
|
+
onSubmit={() => { void submit(); }}
|
|
208
|
+
/>
|
|
209
|
+
<Box height={1}>
|
|
210
|
+
<Text fg={selectedUsernames.length > 0 ? colors.textMuted : colors.textDim}>
|
|
211
|
+
{selectedUsernames.length > 0 ? usernamesLabel(selectedUsernames, contentWidth) : "Recent users"}
|
|
212
|
+
</Text>
|
|
213
|
+
</Box>
|
|
214
|
+
<ListView
|
|
215
|
+
items={items}
|
|
216
|
+
selectedIndex={items.length > 0 ? selectedIndex : -1}
|
|
217
|
+
height={Math.max(1, dialogHeight - 6)}
|
|
218
|
+
bgColor={colors.bg}
|
|
219
|
+
selectedBgColor={colors.selected}
|
|
220
|
+
hoverBgColor={hoverBg()}
|
|
221
|
+
emptyMessage="No recent users"
|
|
222
|
+
selectOnHover
|
|
223
|
+
onSelect={setSelectedIndex}
|
|
224
|
+
onActivate={(item) => toggleCandidate(item.id)}
|
|
225
|
+
renderRow={(item, state) => (
|
|
226
|
+
<Box flexDirection="row" width={contentWidth}>
|
|
227
|
+
<Text fg={state.selected ? colors.selectedText : colors.textDim}>
|
|
228
|
+
{item.checked ? "x " : "+ "}
|
|
229
|
+
</Text>
|
|
230
|
+
<Text
|
|
231
|
+
fg={state.selected ? colors.text : colors.textMuted}
|
|
232
|
+
attributes={state.selected ? TextAttributes.BOLD : 0}
|
|
233
|
+
>
|
|
234
|
+
{truncateChannelLabel(item.label, Math.max(1, contentWidth - 12))}
|
|
235
|
+
</Text>
|
|
236
|
+
<Box flexGrow={1} />
|
|
237
|
+
{item.detail ? (
|
|
238
|
+
<Text fg={colors.textDim}>{truncateChannelLabel(item.detail, 10)}</Text>
|
|
239
|
+
) : null}
|
|
240
|
+
</Box>
|
|
241
|
+
)}
|
|
242
|
+
/>
|
|
243
|
+
<Box height={1} flexDirection="row">
|
|
244
|
+
{error ? (
|
|
245
|
+
<Text fg={colors.negative}>{truncateChannelLabel(error, contentWidth)}</Text>
|
|
246
|
+
) : (
|
|
247
|
+
<Text fg={colors.textDim}>{selectedUsernames.length > 1 ? "Group chat" : "Direct message"}</Text>
|
|
248
|
+
)}
|
|
249
|
+
<Box flexGrow={1} />
|
|
250
|
+
<Box
|
|
251
|
+
width={submitting ? 10 : 7}
|
|
252
|
+
height={1}
|
|
253
|
+
backgroundColor={canSubmit ? colors.selected : colors.panel}
|
|
254
|
+
onMouseDown={() => { void submit(); }}
|
|
255
|
+
style={{ cursor: canSubmit ? "pointer" : "default" }}
|
|
256
|
+
>
|
|
257
|
+
<Text fg={canSubmit ? colors.selectedText : colors.textDim}>
|
|
258
|
+
{submitting ? " Starting " : " Start "}
|
|
259
|
+
</Text>
|
|
260
|
+
</Box>
|
|
261
|
+
</Box>
|
|
262
|
+
</Box>
|
|
263
|
+
);
|
|
264
|
+
}
|
|
@@ -11,6 +11,7 @@ export function useChatContentShortcuts({
|
|
|
11
11
|
blurInput,
|
|
12
12
|
canSend,
|
|
13
13
|
cancelEditMessage,
|
|
14
|
+
commandBarOpen,
|
|
14
15
|
clearReplyTarget,
|
|
15
16
|
cycleChannel,
|
|
16
17
|
focusChannelSidebar,
|
|
@@ -22,6 +23,10 @@ export function useChatContentShortcuts({
|
|
|
22
23
|
inputValueRef,
|
|
23
24
|
loadingOlderMessages,
|
|
24
25
|
messages,
|
|
26
|
+
mentionMenuOpen,
|
|
27
|
+
moveMentionSelection,
|
|
28
|
+
dismissMentionSuggestions,
|
|
29
|
+
commitMentionSelection,
|
|
25
30
|
moveMessageSelection,
|
|
26
31
|
moveSidebarChannelSelection,
|
|
27
32
|
nativePaneChrome,
|
|
@@ -43,6 +48,7 @@ export function useChatContentShortcuts({
|
|
|
43
48
|
blurInput: () => void;
|
|
44
49
|
canSend: boolean;
|
|
45
50
|
cancelEditMessage: () => void;
|
|
51
|
+
commandBarOpen: boolean;
|
|
46
52
|
clearReplyTarget: () => void;
|
|
47
53
|
cycleChannel: (direction: 1 | -1) => boolean;
|
|
48
54
|
focusChannelSidebar: () => boolean;
|
|
@@ -54,6 +60,10 @@ export function useChatContentShortcuts({
|
|
|
54
60
|
inputValueRef: MutableRefObject<string>;
|
|
55
61
|
loadingOlderMessages: boolean;
|
|
56
62
|
messages: ChatMessage[];
|
|
63
|
+
mentionMenuOpen: boolean;
|
|
64
|
+
moveMentionSelection: (direction: "up" | "down") => boolean;
|
|
65
|
+
dismissMentionSuggestions: () => boolean;
|
|
66
|
+
commitMentionSelection: () => boolean;
|
|
57
67
|
moveMessageSelection: (direction: "up" | "down") => boolean;
|
|
58
68
|
moveSidebarChannelSelection: (direction: "up" | "down") => boolean;
|
|
59
69
|
nativePaneChrome?: boolean;
|
|
@@ -71,7 +81,25 @@ export function useChatContentShortcuts({
|
|
|
71
81
|
sidebarFocusedRef: MutableRefObject<boolean>;
|
|
72
82
|
}) {
|
|
73
83
|
useShortcut((event) => {
|
|
74
|
-
if (!focused) return;
|
|
84
|
+
if (!focused || commandBarOpen || !inputFocused || !mentionMenuOpen) return;
|
|
85
|
+
if (
|
|
86
|
+
event.name !== "tab"
|
|
87
|
+
|| event.ctrl
|
|
88
|
+
|| event.meta
|
|
89
|
+
|| event.super
|
|
90
|
+
|| event.alt
|
|
91
|
+
|| event.option
|
|
92
|
+
) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
event.preventDefault?.();
|
|
97
|
+
event.stopPropagation?.();
|
|
98
|
+
commitMentionSelection();
|
|
99
|
+
}, { allowEditable: true, phase: "before" });
|
|
100
|
+
|
|
101
|
+
useShortcut((event) => {
|
|
102
|
+
if (!focused || commandBarOpen) return;
|
|
75
103
|
const isEnterKey = event.name === "return" || event.name === "enter";
|
|
76
104
|
|
|
77
105
|
if (sidebarFocusedRef.current && showChannelSidebar) {
|
|
@@ -117,6 +145,24 @@ export function useChatContentShortcuts({
|
|
|
117
145
|
}
|
|
118
146
|
|
|
119
147
|
if (inputFocused) {
|
|
148
|
+
if (mentionMenuOpen) {
|
|
149
|
+
if (event.name === "escape") {
|
|
150
|
+
event.preventDefault?.();
|
|
151
|
+
event.stopPropagation?.();
|
|
152
|
+
dismissMentionSuggestions();
|
|
153
|
+
return;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (isPlainKey(event, "up", "down")) {
|
|
157
|
+
event.preventDefault?.();
|
|
158
|
+
event.stopPropagation?.();
|
|
159
|
+
if (event.name === "up" || event.name === "down") {
|
|
160
|
+
moveMentionSelection(event.name);
|
|
161
|
+
}
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
|
|
120
166
|
if (event.name === "escape") {
|
|
121
167
|
event.preventDefault?.();
|
|
122
168
|
event.stopPropagation?.();
|
|
@@ -17,6 +17,7 @@ interface ChatSnapshotStateArgs {
|
|
|
17
17
|
initialSnapshot: ChatSnapshot;
|
|
18
18
|
inputRef: MutableRef<TextareaRenderable | null>;
|
|
19
19
|
inputValueRef: MutableRef<string>;
|
|
20
|
+
onComposerStateChange?: (draft: string, cursorOffset: number) => void;
|
|
20
21
|
prependAnchorRef: MutableRef<ChatPrependAnchor | null>;
|
|
21
22
|
setFollowMessages: Dispatch<SetStateAction<boolean>>;
|
|
22
23
|
setSelectedIdx: Dispatch<SetStateAction<number>>;
|
|
@@ -28,18 +29,21 @@ function syncDraftFromSnapshot({
|
|
|
28
29
|
applyingExternalDraftRef,
|
|
29
30
|
inputRef,
|
|
30
31
|
inputValueRef,
|
|
32
|
+
onComposerStateChange,
|
|
31
33
|
snapshot,
|
|
32
34
|
updateComposerRows,
|
|
33
35
|
}: {
|
|
34
36
|
applyingExternalDraftRef: MutableRef<boolean>;
|
|
35
37
|
inputRef: MutableRef<TextareaRenderable | null>;
|
|
36
38
|
inputValueRef: MutableRef<string>;
|
|
39
|
+
onComposerStateChange?: (draft: string, cursorOffset: number) => void;
|
|
37
40
|
snapshot: ChatSnapshot;
|
|
38
41
|
updateComposerRows: (draft: string) => void;
|
|
39
42
|
}) {
|
|
40
43
|
if (inputValueRef.current !== snapshot.draft) {
|
|
41
44
|
inputValueRef.current = snapshot.draft;
|
|
42
45
|
updateComposerRows(snapshot.draft);
|
|
46
|
+
onComposerStateChange?.(snapshot.draft, snapshot.draft.length);
|
|
43
47
|
}
|
|
44
48
|
const textarea = inputRef.current;
|
|
45
49
|
if (textarea && textarea.editBuffer.getText() !== snapshot.draft) {
|
|
@@ -62,6 +66,7 @@ export function useChatSnapshotState({
|
|
|
62
66
|
initialSnapshot,
|
|
63
67
|
inputRef,
|
|
64
68
|
inputValueRef,
|
|
69
|
+
onComposerStateChange,
|
|
65
70
|
prependAnchorRef,
|
|
66
71
|
setFollowMessages,
|
|
67
72
|
setSelectedIdx,
|
|
@@ -110,6 +115,7 @@ export function useChatSnapshotState({
|
|
|
110
115
|
applyingExternalDraftRef,
|
|
111
116
|
inputRef,
|
|
112
117
|
inputValueRef,
|
|
118
|
+
onComposerStateChange,
|
|
113
119
|
snapshot,
|
|
114
120
|
updateComposerRows,
|
|
115
121
|
});
|
|
@@ -130,6 +136,7 @@ export function useChatSnapshotState({
|
|
|
130
136
|
controller,
|
|
131
137
|
inputRef,
|
|
132
138
|
inputValueRef,
|
|
139
|
+
onComposerStateChange,
|
|
133
140
|
prependAnchorRef,
|
|
134
141
|
setFollowMessages,
|
|
135
142
|
setSelectedIdx,
|
|
@@ -29,7 +29,6 @@ interface ChatTranscriptProps {
|
|
|
29
29
|
messages: ChatMessage[];
|
|
30
30
|
nativePaneChrome: boolean | undefined;
|
|
31
31
|
latestEditableMessageId: string | null;
|
|
32
|
-
openDirectMessage: (target: ChatUserSummary) => void;
|
|
33
32
|
openTicker: (symbol: string) => void;
|
|
34
33
|
profilePopoverUser: ChatUserSummary | null;
|
|
35
34
|
registerMessageElement: (messageId: string, node: unknown | null) => void;
|
|
@@ -61,7 +60,6 @@ export function ChatTranscript({
|
|
|
61
60
|
messages,
|
|
62
61
|
nativePaneChrome,
|
|
63
62
|
latestEditableMessageId,
|
|
64
|
-
openDirectMessage,
|
|
65
63
|
openTicker,
|
|
66
64
|
profilePopoverUser,
|
|
67
65
|
registerMessageElement,
|
|
@@ -152,8 +150,6 @@ export function ChatTranscript({
|
|
|
152
150
|
<UserProfilePopover
|
|
153
151
|
user={profilePopoverUser}
|
|
154
152
|
width={chatWidth}
|
|
155
|
-
currentUserId={user?.id}
|
|
156
|
-
onDirectMessage={openDirectMessage}
|
|
157
153
|
onClose={scheduleProfilePopoverClose}
|
|
158
154
|
onKeepOpen={cancelProfilePopoverClose}
|
|
159
155
|
/>
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChatUserSummary } from "../../../../api-client";
|
|
3
|
+
import { hasPublicChatProfileInfo } from "./profile-popover";
|
|
4
|
+
|
|
5
|
+
function makeUser(overrides: Partial<ChatUserSummary>): ChatUserSummary {
|
|
6
|
+
return {
|
|
7
|
+
id: "u1",
|
|
8
|
+
username: "vince",
|
|
9
|
+
displayName: "Vince",
|
|
10
|
+
profilePublic: true,
|
|
11
|
+
...overrides,
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("profile popover", () => {
|
|
16
|
+
test("treats public portfolio analytics as hover profile information", () => {
|
|
17
|
+
expect(hasPublicChatProfileInfo(makeUser({
|
|
18
|
+
bio: null,
|
|
19
|
+
company: null,
|
|
20
|
+
title: null,
|
|
21
|
+
portfolioAnalytics: {
|
|
22
|
+
oneYearReturn: 0.14,
|
|
23
|
+
spyBeta: 1.05,
|
|
24
|
+
},
|
|
25
|
+
}))).toBe(true);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
test("hides analytics when the chat profile is private", () => {
|
|
29
|
+
expect(hasPublicChatProfileInfo(makeUser({
|
|
30
|
+
profilePublic: false,
|
|
31
|
+
portfolioAnalytics: {
|
|
32
|
+
oneYearReturn: 0.14,
|
|
33
|
+
},
|
|
34
|
+
}))).toBe(false);
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -1,36 +1,140 @@
|
|
|
1
1
|
import { Box, Text } from "../../../../ui";
|
|
2
2
|
import { TextAttributes } from "../../../../ui";
|
|
3
3
|
import { colors } from "../../../../theme/colors";
|
|
4
|
-
import type { ChatUserSummary } from "../../../../api-client";
|
|
4
|
+
import type { ChatUserSummary, PublicPortfolioAnalytics } from "../../../../api-client";
|
|
5
|
+
import { formatNumber } from "../../../../utils/format";
|
|
5
6
|
import { truncateChannelLabel } from "../channels";
|
|
6
|
-
import { ChatActionChip } from "./action-chip";
|
|
7
7
|
|
|
8
8
|
export const PROFILE_POPOVER_CLOSE_DELAY_MS = 40;
|
|
9
9
|
|
|
10
|
+
function hasPortfolioAnalytics(analytics: PublicPortfolioAnalytics | null | undefined): boolean {
|
|
11
|
+
return Boolean(
|
|
12
|
+
analytics
|
|
13
|
+
&& (
|
|
14
|
+
analytics.oneYearReturn != null
|
|
15
|
+
|| analytics.spyBeta != null
|
|
16
|
+
),
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
|
|
10
20
|
export function hasPublicChatProfileInfo(user: ChatUserSummary): boolean {
|
|
11
21
|
if (user.profilePublic === false) return false;
|
|
12
|
-
return Boolean(user.bio?.trim() || user.title?.trim() || user.company?.trim());
|
|
22
|
+
return Boolean(user.bio?.trim() || user.title?.trim() || user.company?.trim() || hasPortfolioAnalytics(user.portfolioAnalytics));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function formatSignedPercent(value: number): string {
|
|
26
|
+
const percent = value * 100;
|
|
27
|
+
return `${percent >= 0 ? "+" : ""}${formatNumber(percent, 2)}%`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type AnalyticsMetric = {
|
|
31
|
+
id: "one-year" | "beta";
|
|
32
|
+
label: string;
|
|
33
|
+
value: string;
|
|
34
|
+
rawValue: number;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
function analyticsValueColor(id: string, value: number): string {
|
|
38
|
+
if (id === "one-year") {
|
|
39
|
+
if (value > 0) return colors.positive;
|
|
40
|
+
if (value < 0) return colors.negative;
|
|
41
|
+
}
|
|
42
|
+
return colors.warning;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function analyticsMetrics(analytics: PublicPortfolioAnalytics): AnalyticsMetric[] {
|
|
46
|
+
return [
|
|
47
|
+
analytics.oneYearReturn != null
|
|
48
|
+
? {
|
|
49
|
+
id: "one-year",
|
|
50
|
+
label: "1Y",
|
|
51
|
+
value: formatSignedPercent(analytics.oneYearReturn),
|
|
52
|
+
rawValue: analytics.oneYearReturn,
|
|
53
|
+
}
|
|
54
|
+
: null,
|
|
55
|
+
analytics.spyBeta != null
|
|
56
|
+
? {
|
|
57
|
+
id: "beta",
|
|
58
|
+
label: "Beta",
|
|
59
|
+
value: formatNumber(analytics.spyBeta, 2),
|
|
60
|
+
rawValue: analytics.spyBeta,
|
|
61
|
+
}
|
|
62
|
+
: null,
|
|
63
|
+
].filter((metric): metric is AnalyticsMetric => !!metric);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function headerMetricLabel(metric: AnalyticsMetric): string {
|
|
67
|
+
return metric.id === "beta" ? "Beta" : metric.label;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function headerMetricsNaturalWidth(metrics: AnalyticsMetric[]): number {
|
|
71
|
+
const metricWidth = metrics.reduce((sum, metric) => (
|
|
72
|
+
sum + headerMetricLabel(metric).length + 1 + metric.value.length
|
|
73
|
+
), 0);
|
|
74
|
+
return metricWidth + Math.max(0, metrics.length - 1);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function HeaderAnalyticsStats({
|
|
78
|
+
metrics,
|
|
79
|
+
width,
|
|
80
|
+
}: {
|
|
81
|
+
metrics: AnalyticsMetric[];
|
|
82
|
+
width: number;
|
|
83
|
+
}) {
|
|
84
|
+
if (metrics.length === 0 || width < 8) return null;
|
|
85
|
+
const gapWidth = Math.max(0, metrics.length - 1);
|
|
86
|
+
const naturalWidths = metrics.map((metric) => headerMetricLabel(metric).length + 1 + metric.value.length);
|
|
87
|
+
const availableMetricWidth = Math.max(metrics.length * 4, width - gapWidth);
|
|
88
|
+
let overflow = Math.max(0, naturalWidths.reduce((sum, value) => sum + value, 0) - availableMetricWidth);
|
|
89
|
+
const metricWidths = naturalWidths.map((naturalWidth, index) => {
|
|
90
|
+
const shrinkable = Math.max(0, naturalWidth - 4);
|
|
91
|
+
const shrink = Math.min(shrinkable, Math.ceil(overflow / (naturalWidths.length - index)));
|
|
92
|
+
overflow -= shrink;
|
|
93
|
+
return naturalWidth - shrink;
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return (
|
|
97
|
+
<Box flexDirection="row" gap={1} width={width}>
|
|
98
|
+
{metrics.map((metric, index) => {
|
|
99
|
+
const label = headerMetricLabel(metric);
|
|
100
|
+
const metricWidth = metricWidths[index] ?? 4;
|
|
101
|
+
const labelWidth = Math.max(1, Math.min(label.length, metricWidth - 2));
|
|
102
|
+
const valueWidth = Math.max(1, metricWidth - labelWidth - 1);
|
|
103
|
+
return (
|
|
104
|
+
<Box key={metric.id} width={metricWidth} height={1} flexDirection="row" gap={1}>
|
|
105
|
+
<Text fg={colors.textMuted}>{truncateChannelLabel(label, labelWidth)}</Text>
|
|
106
|
+
<Text fg={analyticsValueColor(metric.id, metric.rawValue)} attributes={TextAttributes.BOLD}>
|
|
107
|
+
{truncateChannelLabel(metric.value, valueWidth)}
|
|
108
|
+
</Text>
|
|
109
|
+
</Box>
|
|
110
|
+
);
|
|
111
|
+
})}
|
|
112
|
+
</Box>
|
|
113
|
+
);
|
|
13
114
|
}
|
|
14
115
|
|
|
15
116
|
export function UserProfilePopover({
|
|
16
117
|
user,
|
|
17
118
|
width,
|
|
18
|
-
currentUserId,
|
|
19
|
-
onDirectMessage,
|
|
20
119
|
onClose,
|
|
21
120
|
onKeepOpen,
|
|
22
121
|
}: {
|
|
23
122
|
user: ChatUserSummary;
|
|
24
123
|
width: number;
|
|
25
|
-
currentUserId?: string | null;
|
|
26
|
-
onDirectMessage: (user: ChatUserSummary) => void;
|
|
27
124
|
onClose: () => void;
|
|
28
125
|
onKeepOpen: () => void;
|
|
29
126
|
}) {
|
|
30
127
|
const popoverWidth = Math.max(24, Math.min(38, width - 4));
|
|
31
128
|
const meta = [user.title, user.company].filter(Boolean).join(" · ");
|
|
32
129
|
const bio = user.bio?.trim();
|
|
33
|
-
const
|
|
130
|
+
const analytics = user.portfolioAnalytics;
|
|
131
|
+
const metrics = analytics ? analyticsMetrics(analytics) : [];
|
|
132
|
+
const headerWidth = Math.max(1, popoverWidth - 2);
|
|
133
|
+
const maxStatsWidth = Math.max(0, headerWidth - 8);
|
|
134
|
+
const statsWidth = metrics.length > 0 && maxStatsWidth >= 8
|
|
135
|
+
? Math.min(headerMetricsNaturalWidth(metrics), maxStatsWidth)
|
|
136
|
+
: 0;
|
|
137
|
+
const usernameWidth = Math.max(1, headerWidth - (statsWidth > 0 ? statsWidth + 1 : 0));
|
|
34
138
|
|
|
35
139
|
return (
|
|
36
140
|
<Box
|
|
@@ -47,19 +151,19 @@ export function UserProfilePopover({
|
|
|
47
151
|
onMouseOut={onClose}
|
|
48
152
|
style={{ zIndex: 4 }}
|
|
49
153
|
>
|
|
50
|
-
<Box height={1} flexDirection="row">
|
|
51
|
-
<
|
|
52
|
-
|
|
53
|
-
|
|
154
|
+
<Box height={1} width={headerWidth} flexDirection="row">
|
|
155
|
+
<Box width={usernameWidth}>
|
|
156
|
+
<Text fg={colors.positive} attributes={TextAttributes.BOLD}>
|
|
157
|
+
{truncateChannelLabel(user.username ? `@${user.username}` : user.displayName, usernameWidth)}
|
|
158
|
+
</Text>
|
|
159
|
+
</Box>
|
|
160
|
+
{statsWidth > 0 ? (
|
|
161
|
+
<>
|
|
162
|
+
<Box width={1} />
|
|
163
|
+
<HeaderAnalyticsStats metrics={metrics} width={statsWidth} />
|
|
164
|
+
</>
|
|
165
|
+
) : null}
|
|
54
166
|
<Box flexGrow={1} />
|
|
55
|
-
<ChatActionChip
|
|
56
|
-
label={canDm ? "DM" : "Closed"}
|
|
57
|
-
width={canDm ? 5 : 9}
|
|
58
|
-
emphasized
|
|
59
|
-
onPress={() => {
|
|
60
|
-
if (canDm) onDirectMessage(user);
|
|
61
|
-
}}
|
|
62
|
-
/>
|
|
63
167
|
</Box>
|
|
64
168
|
{meta ? <Text fg={colors.textDim}>{truncateChannelLabel(meta, popoverWidth - 2)}</Text> : null}
|
|
65
169
|
{bio ? (
|