gloomberb 0.9.7 → 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/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 +51 -1
- package/src/plugins/builtin/portfolio-list/pane/data.test.ts +54 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +50 -3
- package/src/plugins/builtin/portfolio-list/pane/index.test.tsx +314 -16
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +11 -4
- package/src/plugins/builtin/portfolio-list/pane/streaming.ts +83 -24
- 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/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
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { Box, Text, useUiCapabilities } from "../../../../ui";
|
|
2
2
|
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
|
3
3
|
import { type ScrollBoxRenderable, type TextareaRenderable } from "../../../../ui";
|
|
4
|
-
import { useAppDispatch } from "../../../../state/app/context";
|
|
4
|
+
import { useAppDispatch, useAppSelector } from "../../../../state/app/context";
|
|
5
5
|
import { useInlineTickers } from "../../../../state/hooks/inline-tickers";
|
|
6
6
|
import { blendHex, colors } from "../../../../theme/colors";
|
|
7
7
|
import { chatController } from "../controller";
|
|
@@ -32,10 +32,18 @@ import { buildChatUserByUsername } from "./user-map";
|
|
|
32
32
|
import { useChatComposerRuntime } from "./composer-runtime";
|
|
33
33
|
import { useChatMessageSelection } from "./selection-runtime";
|
|
34
34
|
import type { ChatMessage } from "../../../../api-client";
|
|
35
|
+
import { NewDmDialog } from "./new-dm-dialog";
|
|
35
36
|
import {
|
|
36
37
|
CHAT_MESSAGE_EDIT_WINDOW_MS,
|
|
37
38
|
findLatestEditableChatMessage,
|
|
38
39
|
} from "../edit-window";
|
|
40
|
+
import {
|
|
41
|
+
applyMentionSuggestion,
|
|
42
|
+
buildRecentMentionSuggestions,
|
|
43
|
+
detectChatMentionTrigger,
|
|
44
|
+
filterMentionSuggestions,
|
|
45
|
+
} from "./mentions";
|
|
46
|
+
import { getComposerCursorOffset } from "./composer-cursor";
|
|
39
47
|
|
|
40
48
|
interface ChatContentProps {
|
|
41
49
|
width: number;
|
|
@@ -57,6 +65,7 @@ export function ChatContent({
|
|
|
57
65
|
controller = chatController,
|
|
58
66
|
}: ChatContentProps) {
|
|
59
67
|
const dispatch = useAppDispatch();
|
|
68
|
+
const commandBarOpen = useAppSelector((state) => state.commandBarOpen);
|
|
60
69
|
const channelId = normalizeChannelId(rawChannelId);
|
|
61
70
|
const channelIdRef = useRef(channelId);
|
|
62
71
|
channelIdRef.current = channelId;
|
|
@@ -67,6 +76,7 @@ export function ChatContent({
|
|
|
67
76
|
const [hoveredIdx, setHoveredIdx] = useState<number | null>(null);
|
|
68
77
|
const [editingMessage, setEditingMessage] = useState<ChatMessage | null>(null);
|
|
69
78
|
const [followMessages, setFollowMessages] = useState(true);
|
|
79
|
+
const [newDmOpen, setNewDmOpen] = useState(false);
|
|
70
80
|
const inputRef = useRef<TextareaRenderable>(null);
|
|
71
81
|
const scrollRef = useRef<ScrollBoxRenderable>(null);
|
|
72
82
|
const messageElementsRef = useRef(new Map<string, unknown>());
|
|
@@ -82,6 +92,18 @@ export function ChatContent({
|
|
|
82
92
|
});
|
|
83
93
|
const composerTextWidthRef = useRef(initialWidthMetrics.composerTextWidth);
|
|
84
94
|
const inputValueRef = useRef(initialSnapshot.draft);
|
|
95
|
+
const [composerDraft, setComposerDraft] = useState(initialSnapshot.draft);
|
|
96
|
+
const [composerCursorOffset, setComposerCursorOffset] = useState(initialSnapshot.draft.length);
|
|
97
|
+
const [mentionSelectedIndex, setMentionSelectedIndex] = useState(0);
|
|
98
|
+
const [dismissedMentionKey, setDismissedMentionKey] = useState<string | null>(null);
|
|
99
|
+
const syncComposerState = useCallback((draft: string, cursorOffset = getComposerCursorOffset(inputRef.current, draft)) => {
|
|
100
|
+
setComposerDraft((current) => (current === draft ? current : draft));
|
|
101
|
+
setComposerCursorOffset((current) => (current === cursorOffset ? current : cursorOffset));
|
|
102
|
+
}, []);
|
|
103
|
+
const syncComposerCursor = useCallback(() => {
|
|
104
|
+
const draft = inputRef.current?.editBuffer.getText() ?? inputValueRef.current;
|
|
105
|
+
syncComposerState(draft, getComposerCursorOffset(inputRef.current, draft));
|
|
106
|
+
}, [syncComposerState]);
|
|
85
107
|
const [composerRows, setComposerRows] = useState(() => estimateComposerHeight(initialSnapshot.draft, initialWidthMetrics.composerTextWidth));
|
|
86
108
|
const updateComposerRows = useCallback((draft: string) => {
|
|
87
109
|
const nextRows = estimateComposerHeight(draft, composerTextWidthRef.current);
|
|
@@ -107,6 +129,7 @@ export function ChatContent({
|
|
|
107
129
|
initialSnapshot,
|
|
108
130
|
inputRef,
|
|
109
131
|
inputValueRef,
|
|
132
|
+
onComposerStateChange: syncComposerState,
|
|
110
133
|
prependAnchorRef,
|
|
111
134
|
setFollowMessages,
|
|
112
135
|
setSelectedIdx,
|
|
@@ -155,6 +178,41 @@ export function ChatContent({
|
|
|
155
178
|
const latestEditableMessageId = useMemo(() => {
|
|
156
179
|
return findLatestEditableChatMessage(messages, user?.id, editWindowNowMs)?.id ?? null;
|
|
157
180
|
}, [editWindowNowMs, messages, user?.id]);
|
|
181
|
+
useEffect(() => {
|
|
182
|
+
updateComposerRows(inputValueRef.current);
|
|
183
|
+
}, [updateComposerRows]);
|
|
184
|
+
|
|
185
|
+
const messageContents = useMemo(() => messages.map((message) => message.content), [messages]);
|
|
186
|
+
const { catalog, openTicker } = useInlineTickers(messageContents);
|
|
187
|
+
const userByUsername = useMemo(() => buildChatUserByUsername(channels, messages), [channels, messages]);
|
|
188
|
+
const activeChannel = useMemo(() => channels.find((channel) => channel.id === channelId), [channelId, channels]);
|
|
189
|
+
const activeChannelTitle = useMemo(() => formatChatPaneTitle(activeChannel, channelId), [activeChannel, channelId]);
|
|
190
|
+
const recentMentionSuggestions = useMemo(() => buildRecentMentionSuggestions({
|
|
191
|
+
activeChannel,
|
|
192
|
+
currentUserId: user?.id,
|
|
193
|
+
messages,
|
|
194
|
+
}), [activeChannel, messages, user?.id]);
|
|
195
|
+
const mentionDisabled = activeChannel?.kind === "direct" || (!activeChannel && channelId.startsWith("dm:"));
|
|
196
|
+
const mentionTrigger = useMemo(() => (
|
|
197
|
+
mentionDisabled ? null : detectChatMentionTrigger(composerDraft, composerCursorOffset)
|
|
198
|
+
), [
|
|
199
|
+
mentionDisabled,
|
|
200
|
+
composerCursorOffset,
|
|
201
|
+
composerDraft,
|
|
202
|
+
]);
|
|
203
|
+
const mentionTriggerKey = mentionTrigger
|
|
204
|
+
? `${channelId}:${mentionTrigger.start}:${mentionTrigger.end}:${mentionTrigger.query}`
|
|
205
|
+
: null;
|
|
206
|
+
const mentionSuggestions = useMemo(() => {
|
|
207
|
+
if (!mentionTrigger || mentionTriggerKey === dismissedMentionKey) return [];
|
|
208
|
+
return filterMentionSuggestions(recentMentionSuggestions, mentionTrigger.query);
|
|
209
|
+
}, [dismissedMentionKey, mentionTrigger, mentionTriggerKey, recentMentionSuggestions]);
|
|
210
|
+
const mentionSelectedIndexSafe = mentionSuggestions.length > 0
|
|
211
|
+
? Math.min(mentionSelectedIndex, mentionSuggestions.length - 1)
|
|
212
|
+
: 0;
|
|
213
|
+
useEffect(() => {
|
|
214
|
+
setMentionSelectedIndex(0);
|
|
215
|
+
}, [mentionTriggerKey]);
|
|
158
216
|
const {
|
|
159
217
|
composerHeight,
|
|
160
218
|
messageAreaHeight,
|
|
@@ -163,19 +221,10 @@ export function ChatContent({
|
|
|
163
221
|
composerRows,
|
|
164
222
|
editingMessage,
|
|
165
223
|
height,
|
|
224
|
+
mentionSuggestionCount: mentionSuggestions.length,
|
|
166
225
|
nativePaneChrome,
|
|
167
226
|
replyTo,
|
|
168
227
|
});
|
|
169
|
-
|
|
170
|
-
useEffect(() => {
|
|
171
|
-
updateComposerRows(inputValueRef.current);
|
|
172
|
-
}, [updateComposerRows]);
|
|
173
|
-
|
|
174
|
-
const messageContents = useMemo(() => messages.map((message) => message.content), [messages]);
|
|
175
|
-
const { catalog, openTicker } = useInlineTickers(messageContents);
|
|
176
|
-
const userByUsername = useMemo(() => buildChatUserByUsername(channels, messages), [channels, messages]);
|
|
177
|
-
const activeChannel = useMemo(() => channels.find((channel) => channel.id === channelId), [channelId, channels]);
|
|
178
|
-
const activeChannelTitle = useMemo(() => formatChatPaneTitle(activeChannel, channelId), [activeChannel, channelId]);
|
|
179
228
|
const {
|
|
180
229
|
cancelProfilePopoverClose,
|
|
181
230
|
closeProfilePopover,
|
|
@@ -217,7 +266,6 @@ export function ChatContent({
|
|
|
217
266
|
focusChannelSidebar,
|
|
218
267
|
focusChatContent,
|
|
219
268
|
moveSidebarChannelSelection,
|
|
220
|
-
openDirectMessage,
|
|
221
269
|
selectSidebarChannel,
|
|
222
270
|
setDirectExpanded,
|
|
223
271
|
setSidebarFocused,
|
|
@@ -230,8 +278,6 @@ export function ChatContent({
|
|
|
230
278
|
channelIdRef,
|
|
231
279
|
channels,
|
|
232
280
|
channelsLoading,
|
|
233
|
-
closeProfilePopover,
|
|
234
|
-
controller,
|
|
235
281
|
focused,
|
|
236
282
|
inputFocused,
|
|
237
283
|
onChannelChange,
|
|
@@ -240,12 +286,43 @@ export function ChatContent({
|
|
|
240
286
|
});
|
|
241
287
|
|
|
242
288
|
const focusInput = useCallback(() => {
|
|
289
|
+
setNewDmOpen(false);
|
|
243
290
|
setSidebarFocused(false);
|
|
244
291
|
setInputFocused(true);
|
|
245
292
|
dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
|
|
246
293
|
inputRef.current?.focus?.();
|
|
247
294
|
}, [dispatch, setSidebarFocused]);
|
|
248
295
|
|
|
296
|
+
const closeNewDmDialog = useCallback(() => {
|
|
297
|
+
setNewDmOpen(false);
|
|
298
|
+
dispatch({ type: "SET_INPUT_CAPTURED", captured: false });
|
|
299
|
+
}, [dispatch]);
|
|
300
|
+
|
|
301
|
+
const openNewDmDialog = useCallback(() => {
|
|
302
|
+
blurInput();
|
|
303
|
+
closeProfilePopover();
|
|
304
|
+
setSidebarFocused(false);
|
|
305
|
+
setNewDmOpen(true);
|
|
306
|
+
dispatch({ type: "SET_INPUT_CAPTURED", captured: true });
|
|
307
|
+
}, [blurInput, closeProfilePopover, dispatch, setSidebarFocused]);
|
|
308
|
+
|
|
309
|
+
const openConversationFromDialog = useCallback(async (usernames: string[]) => {
|
|
310
|
+
const channel = usernames.length === 1
|
|
311
|
+
? await controller.openDirectChannel({ username: usernames[0] })
|
|
312
|
+
: await controller.openGroupChannel({ usernames });
|
|
313
|
+
setDirectExpanded(true);
|
|
314
|
+
channelIdRef.current = channel.id;
|
|
315
|
+
selectSidebarChannel(channel.id);
|
|
316
|
+
setSidebarFocused(false);
|
|
317
|
+
closeNewDmDialog();
|
|
318
|
+
}, [channelIdRef, closeNewDmDialog, controller, selectSidebarChannel, setDirectExpanded, setSidebarFocused]);
|
|
319
|
+
|
|
320
|
+
useEffect(() => {
|
|
321
|
+
if (!focused && newDmOpen) {
|
|
322
|
+
closeNewDmDialog();
|
|
323
|
+
}
|
|
324
|
+
}, [closeNewDmDialog, focused, newDmOpen]);
|
|
325
|
+
|
|
249
326
|
const {
|
|
250
327
|
beginEditLatestMessage,
|
|
251
328
|
beginEditMessage,
|
|
@@ -257,6 +334,7 @@ export function ChatContent({
|
|
|
257
334
|
focusComposer,
|
|
258
335
|
inputPlaceholder,
|
|
259
336
|
replyPreview,
|
|
337
|
+
replaceComposerDraft,
|
|
260
338
|
returnToComposer,
|
|
261
339
|
sendMessage,
|
|
262
340
|
} = useChatComposerRuntime({
|
|
@@ -273,6 +351,7 @@ export function ChatContent({
|
|
|
273
351
|
inputRef,
|
|
274
352
|
inputValueRef,
|
|
275
353
|
messages,
|
|
354
|
+
onComposerStateChange: syncComposerState,
|
|
276
355
|
onChannelChange,
|
|
277
356
|
editingMessage,
|
|
278
357
|
latestEditableMessageId,
|
|
@@ -286,6 +365,44 @@ export function ChatContent({
|
|
|
286
365
|
useDefaultControllerChannel,
|
|
287
366
|
});
|
|
288
367
|
|
|
368
|
+
const moveMentionSelection = useCallback((direction: "up" | "down") => {
|
|
369
|
+
if (mentionSuggestions.length === 0) return false;
|
|
370
|
+
setMentionSelectedIndex((current) => {
|
|
371
|
+
const safeCurrent = Math.max(0, Math.min(current, mentionSuggestions.length - 1));
|
|
372
|
+
if (direction === "up") {
|
|
373
|
+
return safeCurrent <= 0 ? mentionSuggestions.length - 1 : safeCurrent - 1;
|
|
374
|
+
}
|
|
375
|
+
return safeCurrent >= mentionSuggestions.length - 1 ? 0 : safeCurrent + 1;
|
|
376
|
+
});
|
|
377
|
+
return true;
|
|
378
|
+
}, [mentionSuggestions.length]);
|
|
379
|
+
|
|
380
|
+
const dismissMentionSuggestions = useCallback(() => {
|
|
381
|
+
if (!mentionTriggerKey || mentionSuggestions.length === 0) return false;
|
|
382
|
+
setDismissedMentionKey(mentionTriggerKey);
|
|
383
|
+
return true;
|
|
384
|
+
}, [mentionSuggestions.length, mentionTriggerKey]);
|
|
385
|
+
|
|
386
|
+
const commitMentionSelection = useCallback((index = mentionSelectedIndexSafe) => {
|
|
387
|
+
if (!mentionTrigger || mentionSuggestions.length === 0) return false;
|
|
388
|
+
const suggestion = mentionSuggestions[index] ?? mentionSuggestions[0];
|
|
389
|
+
if (!suggestion) return false;
|
|
390
|
+
const replacement = applyMentionSuggestion(composerDraft, mentionTrigger, suggestion);
|
|
391
|
+
replaceComposerDraft(replacement.draft, replacement.cursorOffset);
|
|
392
|
+
setMentionSelectedIndex(0);
|
|
393
|
+
setDismissedMentionKey(mentionTriggerKey);
|
|
394
|
+
queueMicrotask(() => focusInput());
|
|
395
|
+
return true;
|
|
396
|
+
}, [
|
|
397
|
+
composerDraft,
|
|
398
|
+
focusInput,
|
|
399
|
+
mentionSelectedIndexSafe,
|
|
400
|
+
mentionSuggestions,
|
|
401
|
+
mentionTrigger,
|
|
402
|
+
mentionTriggerKey,
|
|
403
|
+
replaceComposerDraft,
|
|
404
|
+
]);
|
|
405
|
+
|
|
289
406
|
const {
|
|
290
407
|
handleTranscriptScrollActivity,
|
|
291
408
|
jumpToMessage,
|
|
@@ -322,17 +439,22 @@ export function ChatContent({
|
|
|
322
439
|
blurInput,
|
|
323
440
|
canSend,
|
|
324
441
|
cancelEditMessage,
|
|
442
|
+
commandBarOpen,
|
|
325
443
|
clearReplyTarget,
|
|
326
444
|
cycleChannel,
|
|
327
445
|
focusChannelSidebar,
|
|
328
446
|
focusChatContent,
|
|
329
447
|
focusComposer,
|
|
330
|
-
focused,
|
|
448
|
+
focused: focused && !newDmOpen,
|
|
331
449
|
hasOlderMessages,
|
|
332
450
|
inputFocused,
|
|
333
451
|
inputValueRef,
|
|
334
452
|
loadingOlderMessages,
|
|
335
453
|
messages,
|
|
454
|
+
mentionMenuOpen: mentionSuggestions.length > 0,
|
|
455
|
+
moveMentionSelection,
|
|
456
|
+
dismissMentionSuggestions,
|
|
457
|
+
commitMentionSelection,
|
|
336
458
|
moveMessageSelection,
|
|
337
459
|
moveSidebarChannelSelection,
|
|
338
460
|
nativePaneChrome,
|
|
@@ -376,9 +498,11 @@ export function ChatContent({
|
|
|
376
498
|
keyboardFocused={sidebarFocused}
|
|
377
499
|
loading={channelsLoading}
|
|
378
500
|
canManageNotifications={!!user?.emailVerified}
|
|
501
|
+
canCreateConversation={!!user?.emailVerified}
|
|
379
502
|
directExpanded={directExpanded}
|
|
380
503
|
onSelect={selectSidebarChannel}
|
|
381
504
|
onFocusRequest={() => setSidebarFocused(true)}
|
|
505
|
+
onCreateConversation={openNewDmDialog}
|
|
382
506
|
onToggleNotifications={(nextChannelId, enabled) => {
|
|
383
507
|
controller.setChannelNotificationsEnabled(nextChannelId, enabled);
|
|
384
508
|
}}
|
|
@@ -420,7 +544,6 @@ export function ChatContent({
|
|
|
420
544
|
messages={messages}
|
|
421
545
|
nativePaneChrome={nativePaneChrome}
|
|
422
546
|
latestEditableMessageId={latestEditableMessageId}
|
|
423
|
-
openDirectMessage={openDirectMessage}
|
|
424
547
|
openTicker={openTicker}
|
|
425
548
|
profilePopoverUser={profilePopoverUser}
|
|
426
549
|
registerMessageElement={registerMessageElement}
|
|
@@ -434,6 +557,17 @@ export function ChatContent({
|
|
|
434
557
|
userByUsername={userByUsername}
|
|
435
558
|
/>
|
|
436
559
|
|
|
560
|
+
{newDmOpen ? (
|
|
561
|
+
<NewDmDialog
|
|
562
|
+
width={chatWidth}
|
|
563
|
+
height={height}
|
|
564
|
+
userByUsername={userByUsername}
|
|
565
|
+
currentUserId={user?.id}
|
|
566
|
+
onCancel={closeNewDmDialog}
|
|
567
|
+
onSubmit={openConversationFromDialog}
|
|
568
|
+
/>
|
|
569
|
+
) : null}
|
|
570
|
+
|
|
437
571
|
{!nativePaneChrome && !canSend && (
|
|
438
572
|
<Box height={1} width={contentWidth}>
|
|
439
573
|
<Text fg={colors.border}>{"-".repeat(contentWidth)}</Text>
|
|
@@ -461,6 +595,10 @@ export function ChatContent({
|
|
|
461
595
|
replyPreview={replyPreview}
|
|
462
596
|
replyTo={replyTo}
|
|
463
597
|
sendMessage={sendMessage}
|
|
598
|
+
mentionSuggestions={mentionSuggestions}
|
|
599
|
+
mentionSelectedIndex={mentionSelectedIndexSafe}
|
|
600
|
+
onMentionCursorChange={syncComposerCursor}
|
|
601
|
+
onMentionSelect={commitMentionSelection}
|
|
464
602
|
user={user}
|
|
465
603
|
/>
|
|
466
604
|
</Box>
|
|
@@ -44,6 +44,7 @@ export function resolveChatContentHeightMetrics({
|
|
|
44
44
|
composerRows,
|
|
45
45
|
editingMessage,
|
|
46
46
|
height,
|
|
47
|
+
mentionSuggestionCount,
|
|
47
48
|
nativePaneChrome,
|
|
48
49
|
replyTo,
|
|
49
50
|
}: {
|
|
@@ -51,6 +52,7 @@ export function resolveChatContentHeightMetrics({
|
|
|
51
52
|
composerRows: number;
|
|
52
53
|
editingMessage: ChatMessage | null;
|
|
53
54
|
height: number;
|
|
55
|
+
mentionSuggestionCount?: number;
|
|
54
56
|
nativePaneChrome: boolean | undefined;
|
|
55
57
|
replyTo: ChatMessage | null;
|
|
56
58
|
}) {
|
|
@@ -59,7 +61,14 @@ export function resolveChatContentHeightMetrics({
|
|
|
59
61
|
? Math.min(CHAT_COMPOSER_MAX_ROWS + 1, Math.max(2, composerRows + 1))
|
|
60
62
|
: composerRows
|
|
61
63
|
: 0;
|
|
62
|
-
const inputAreaHeight = getChatComposerAreaHeight({
|
|
64
|
+
const inputAreaHeight = getChatComposerAreaHeight({
|
|
65
|
+
canSend,
|
|
66
|
+
composerHeight,
|
|
67
|
+
editingMessage,
|
|
68
|
+
mentionSuggestionCount,
|
|
69
|
+
nativePaneChrome,
|
|
70
|
+
replyTo,
|
|
71
|
+
});
|
|
63
72
|
const topSeparatorHeight = nativePaneChrome ? 0 : 1;
|
|
64
73
|
const footerSeparatorHeight = !nativePaneChrome && !canSend ? 1 : 0;
|
|
65
74
|
const messageAreaHeight = Math.max(1, height - topSeparatorHeight - footerSeparatorHeight - inputAreaHeight);
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import type { ChatChannel, ChatMessage } from "../../../../api-client";
|
|
3
|
+
import {
|
|
4
|
+
applyMentionSuggestion,
|
|
5
|
+
buildRecentMentionSuggestions,
|
|
6
|
+
detectChatMentionTrigger,
|
|
7
|
+
filterMentionSuggestions,
|
|
8
|
+
} from "./mentions";
|
|
9
|
+
|
|
10
|
+
const message = (id: string, username: string): ChatMessage => ({
|
|
11
|
+
id,
|
|
12
|
+
channelId: "everyone",
|
|
13
|
+
content: id,
|
|
14
|
+
replyToId: null,
|
|
15
|
+
createdAt: `2026-06-30T00:00:0${id}.000Z`,
|
|
16
|
+
user: { id: `u-${username}`, username, displayName: username },
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
describe("chat mention suggestions", () => {
|
|
20
|
+
test("detects an active mention trigger at the caret", () => {
|
|
21
|
+
expect(detectChatMentionTrigger("watch @al", "watch @al".length)).toEqual({
|
|
22
|
+
start: 6,
|
|
23
|
+
end: 9,
|
|
24
|
+
query: "al",
|
|
25
|
+
});
|
|
26
|
+
expect(detectChatMentionTrigger("email a@b", "email a@b".length)).toBeNull();
|
|
27
|
+
expect(detectChatMentionTrigger("done @al now", "done @al now".length)).toBeNull();
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test("orders visible users by recent messages, filters by prefix, and skips direct channels", () => {
|
|
31
|
+
const activeChannel: ChatChannel = {
|
|
32
|
+
id: "everyone",
|
|
33
|
+
name: "everyone",
|
|
34
|
+
kind: "public",
|
|
35
|
+
created_at: "2026-06-30T00:00:00.000Z",
|
|
36
|
+
};
|
|
37
|
+
const suggestions = buildRecentMentionSuggestions({
|
|
38
|
+
activeChannel,
|
|
39
|
+
currentUserId: "u-me",
|
|
40
|
+
messages: [
|
|
41
|
+
message("1", "alpha"),
|
|
42
|
+
message("2", "bravo"),
|
|
43
|
+
{ ...message("3", "me"), user: { id: "u-me", username: "me", displayName: "me" } },
|
|
44
|
+
message("4", "alpha"),
|
|
45
|
+
message("5", "charlie"),
|
|
46
|
+
],
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
expect(suggestions.map((suggestion) => suggestion.username)).toEqual(["charlie", "alpha", "bravo"]);
|
|
50
|
+
expect(filterMentionSuggestions(suggestions, "a").map((suggestion) => suggestion.username)).toEqual(["alpha"]);
|
|
51
|
+
expect(buildRecentMentionSuggestions({
|
|
52
|
+
activeChannel: {
|
|
53
|
+
id: "dm:alpha",
|
|
54
|
+
name: "alpha",
|
|
55
|
+
kind: "direct",
|
|
56
|
+
created_at: "2026-06-30T00:00:00.000Z",
|
|
57
|
+
},
|
|
58
|
+
currentUserId: "u-me",
|
|
59
|
+
messages: [message("1", "alpha")],
|
|
60
|
+
})).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("replaces only the active mention fragment", () => {
|
|
64
|
+
const trigger = detectChatMentionTrigger("check @al", "check @al".length);
|
|
65
|
+
expect(trigger).not.toBeNull();
|
|
66
|
+
|
|
67
|
+
expect(applyMentionSuggestion("check @al", trigger!, {
|
|
68
|
+
username: "alpha",
|
|
69
|
+
})).toEqual({
|
|
70
|
+
draft: "check @alpha ",
|
|
71
|
+
cursorOffset: "check @alpha ".length,
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { ChatChannel, ChatMessage } from "../../../../api-client";
|
|
2
|
+
|
|
3
|
+
export interface ChatMentionSuggestion {
|
|
4
|
+
username: string;
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
interface ChatMentionTrigger {
|
|
8
|
+
start: number;
|
|
9
|
+
end: number;
|
|
10
|
+
query: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const MENTION_TRIGGER_RE = /(^|[\s([{<,.;:!?])@([A-Za-z0-9_]{0,30})$/;
|
|
14
|
+
const MAX_MENTION_SUGGESTIONS = 5;
|
|
15
|
+
|
|
16
|
+
function normalizeUsername(username: string | null | undefined): string | null {
|
|
17
|
+
const normalized = username?.trim().replace(/^@+/, "");
|
|
18
|
+
return normalized ? normalized : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function detectChatMentionTrigger(
|
|
22
|
+
draft: string,
|
|
23
|
+
cursorOffset: number,
|
|
24
|
+
): ChatMentionTrigger | null {
|
|
25
|
+
const cursor = Math.max(0, Math.min(cursorOffset, draft.length));
|
|
26
|
+
const beforeCursor = draft.slice(0, cursor);
|
|
27
|
+
const match = beforeCursor.match(MENTION_TRIGGER_RE);
|
|
28
|
+
if (!match) return null;
|
|
29
|
+
const query = match[2] ?? "";
|
|
30
|
+
const start = beforeCursor.length - query.length - 1;
|
|
31
|
+
return { start, end: cursor, query };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function buildRecentMentionSuggestions({
|
|
35
|
+
activeChannel,
|
|
36
|
+
currentUserId,
|
|
37
|
+
messages,
|
|
38
|
+
limit = MAX_MENTION_SUGGESTIONS,
|
|
39
|
+
}: {
|
|
40
|
+
activeChannel: ChatChannel | undefined;
|
|
41
|
+
currentUserId: string | null | undefined;
|
|
42
|
+
messages: ChatMessage[];
|
|
43
|
+
limit?: number;
|
|
44
|
+
}): ChatMentionSuggestion[] {
|
|
45
|
+
if (activeChannel?.kind === "direct") return [];
|
|
46
|
+
const seen = new Set<string>();
|
|
47
|
+
const suggestions: ChatMentionSuggestion[] = [];
|
|
48
|
+
|
|
49
|
+
for (let index = messages.length - 1; index >= 0; index -= 1) {
|
|
50
|
+
const user = messages[index]?.user;
|
|
51
|
+
const username = normalizeUsername(user?.username);
|
|
52
|
+
if (!user || !username || user.id === currentUserId) continue;
|
|
53
|
+
const key = username.toLowerCase();
|
|
54
|
+
if (seen.has(key)) continue;
|
|
55
|
+
seen.add(key);
|
|
56
|
+
suggestions.push({ username });
|
|
57
|
+
if (suggestions.length >= limit) break;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return suggestions;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function filterMentionSuggestions(
|
|
64
|
+
suggestions: ChatMentionSuggestion[],
|
|
65
|
+
query: string,
|
|
66
|
+
limit = MAX_MENTION_SUGGESTIONS,
|
|
67
|
+
): ChatMentionSuggestion[] {
|
|
68
|
+
const normalizedQuery = normalizeUsername(query)?.toLowerCase() ?? "";
|
|
69
|
+
return suggestions
|
|
70
|
+
.filter((suggestion) => !normalizedQuery || suggestion.username.toLowerCase().startsWith(normalizedQuery))
|
|
71
|
+
.slice(0, limit);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function applyMentionSuggestion(
|
|
75
|
+
draft: string,
|
|
76
|
+
trigger: ChatMentionTrigger,
|
|
77
|
+
suggestion: ChatMentionSuggestion,
|
|
78
|
+
): { draft: string; cursorOffset: number } {
|
|
79
|
+
const before = draft.slice(0, trigger.start);
|
|
80
|
+
const after = draft.slice(trigger.end);
|
|
81
|
+
const mention = `@${suggestion.username}`;
|
|
82
|
+
const suffix = after.length === 0 || !/^\s/.test(after) ? " " : "";
|
|
83
|
+
const nextDraft = `${before}${mention}${suffix}${after}`;
|
|
84
|
+
return {
|
|
85
|
+
draft: nextDraft,
|
|
86
|
+
cursorOffset: before.length + mention.length + suffix.length,
|
|
87
|
+
};
|
|
88
|
+
}
|