CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutSign UpSign In
sagemathinc

Real-time collaboration for Jupyter Notebooks, Linux Terminals, LaTeX, VS Code, R IDE, and more,
all in one place.

GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/chat/chat-log.tsx
Views: 687
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Render all the messages in the chat.
8
*/
9
10
import { Alert, Button } from "antd";
11
import { Set as immutableSet } from "immutable";
12
import { MutableRefObject, useEffect, useMemo, useRef } from "react";
13
import { Virtuoso, VirtuosoHandle } from "react-virtuoso";
14
import { chatBotName, isChatBot } from "@cocalc/frontend/account/chatbot";
15
import { useRedux, useTypedRedux } from "@cocalc/frontend/app-framework";
16
import { Icon } from "@cocalc/frontend/components";
17
import useVirtuosoScrollHook from "@cocalc/frontend/components/virtuoso-scroll-hook";
18
import { HashtagBar } from "@cocalc/frontend/editors/task-editor/hashtag-bar";
19
import {
20
cmp,
21
hoursToTimeIntervalHuman,
22
parse_hashtags,
23
plural,
24
} from "@cocalc/util/misc";
25
import type { ChatActions } from "./actions";
26
import Composing from "./composing";
27
import Message from "./message";
28
import type { ChatMessageTyped, ChatMessages, Mode } from "./types";
29
import { getSelectedHashtagsSearch, newest_content } from "./utils";
30
import { getRootMessage, getThreadRootDate } from "./utils";
31
import { DivTempHeight } from "@cocalc/frontend/jupyter/cell-list";
32
import { filterMessages } from "./filter-messages";
33
34
interface Props {
35
project_id: string; // used to render links more effectively
36
path: string;
37
mode: Mode;
38
scrollToBottomRef?: MutableRefObject<(force?: boolean) => void>;
39
setLastVisible?: (x: Date | null) => void;
40
fontSize?: number;
41
actions: ChatActions;
42
search;
43
filterRecentH?;
44
selectedHashtags;
45
disableFilters?: boolean;
46
scrollToIndex?: null | number | undefined;
47
// scrollToDate = string ms from epoch
48
scrollToDate?: null | undefined | string;
49
selectedDate?: string;
50
costEstimate?;
51
}
52
53
export function ChatLog({
54
project_id,
55
path,
56
scrollToBottomRef,
57
mode,
58
setLastVisible,
59
fontSize,
60
actions,
61
search: search0,
62
filterRecentH,
63
selectedHashtags: selectedHashtags0,
64
disableFilters,
65
scrollToIndex,
66
scrollToDate,
67
selectedDate,
68
costEstimate,
69
}: Props) {
70
const messages = useRedux(["messages"], project_id, path) as ChatMessages;
71
// see similar code in task list:
72
const { selectedHashtags, selectedHashtagsSearch } = useMemo(() => {
73
return getSelectedHashtagsSearch(selectedHashtags0);
74
}, [selectedHashtags0]);
75
const search = (search0 + " " + selectedHashtagsSearch).trim();
76
77
const user_map = useTypedRedux("users", "user_map");
78
const account_id = useTypedRedux("account", "account_id");
79
const {
80
dates: sortedDates,
81
numFolded,
82
numChildren,
83
} = useMemo<{
84
dates: string[];
85
numFolded: number;
86
numChildren;
87
}>(() => {
88
const { dates, numFolded, numChildren } = getSortedDates(
89
messages,
90
search,
91
account_id!,
92
filterRecentH,
93
);
94
// TODO: This is an ugly hack because I'm tired and need to finish this.
95
// The right solution would be to move this filtering to the store.
96
// The timeout is because you can't update a component while rendering another one.
97
setTimeout(() => {
98
setLastVisible?.(
99
dates.length == 0
100
? null
101
: new Date(parseFloat(dates[dates.length - 1])),
102
);
103
}, 1);
104
return { dates, numFolded, numChildren };
105
}, [messages, search, project_id, path, filterRecentH]);
106
107
useEffect(() => {
108
scrollToBottomRef?.current?.(true);
109
}, [search]);
110
111
useEffect(() => {
112
if (scrollToIndex == null) {
113
return;
114
}
115
if (scrollToIndex == -1) {
116
scrollToBottomRef?.current?.(true);
117
} else {
118
virtuosoRef.current?.scrollToIndex({ index: scrollToIndex });
119
}
120
actions.clearScrollRequest();
121
}, [scrollToIndex]);
122
123
useEffect(() => {
124
if (scrollToDate == null) {
125
return;
126
}
127
// linear search, which should be fine given that this is not a tight inner loop
128
const index = sortedDates.indexOf(scrollToDate);
129
if (index == -1) {
130
// didn't find it?
131
const message = messages.get(scrollToDate);
132
if (message == null) {
133
// the message really doesn't exist. Weird. Give up.
134
actions.clearScrollRequest();
135
return;
136
}
137
let tryAgain = false;
138
// we clear all filters and ALSO make sure
139
// if message is in a folded thread, then that thread is not folded.
140
if (account_id && isFolded(messages, message, account_id)) {
141
// this actually unfolds it, since it was folded.
142
const date = new Date(
143
getThreadRootDate({ date: parseFloat(scrollToDate), messages }),
144
);
145
actions.toggleFoldThread(date);
146
tryAgain = true;
147
}
148
if (messages.size > sortedDates.length && (search || filterRecentH)) {
149
// there was a search, so clear it just to be sure -- it could still hide
150
// the folded threaded
151
actions.clearAllFilters();
152
tryAgain = true;
153
}
154
if (tryAgain) {
155
// we have to wait a while for full re-render to happen
156
setTimeout(() => {
157
actions.scrollToDate(parseFloat(scrollToDate));
158
}, 10);
159
} else {
160
// totally give up
161
actions.clearScrollRequest();
162
}
163
return;
164
}
165
virtuosoRef.current?.scrollToIndex({ index });
166
actions.clearScrollRequest();
167
}, [scrollToDate]);
168
169
const visibleHashtags = useMemo(() => {
170
let X = immutableSet<string>([]);
171
if (disableFilters) {
172
return X;
173
}
174
for (const date of sortedDates) {
175
const message = messages.get(date);
176
const value = newest_content(message);
177
for (const x of parse_hashtags(value)) {
178
const tag = value.slice(x[0] + 1, x[1]).toLowerCase();
179
X = X.add(tag);
180
}
181
}
182
return X;
183
}, [messages, sortedDates]);
184
185
const virtuosoRef = useRef<VirtuosoHandle>(null);
186
const manualScrollRef = useRef<boolean>(false);
187
188
useEffect(() => {
189
if (scrollToBottomRef == null) return;
190
scrollToBottomRef.current = (force?: boolean) => {
191
if (manualScrollRef.current && !force) return;
192
manualScrollRef.current = false;
193
const doScroll = () =>
194
virtuosoRef.current?.scrollToIndex({ index: Number.MAX_SAFE_INTEGER });
195
196
doScroll();
197
// sometimes scrolling to bottom is requested before last entry added,
198
// so we do it again in the next render loop. This seems needed mainly
199
// for side chat when there is little vertical space.
200
setTimeout(doScroll, 1);
201
};
202
}, [scrollToBottomRef != null]);
203
204
return (
205
<>
206
{visibleHashtags.size > 0 && (
207
<HashtagBar
208
style={{ margin: "3px 0" }}
209
actions={{
210
set_hashtag_state: (tag, state) => {
211
actions.setHashtagState(tag, state);
212
},
213
}}
214
selected_hashtags={selectedHashtags0}
215
hashtags={visibleHashtags}
216
/>
217
)}
218
{messages != null && (
219
<NotShowing
220
num={messages.size - numFolded - sortedDates.length}
221
showing={sortedDates.length}
222
search={search}
223
filterRecentH={filterRecentH}
224
actions={actions}
225
/>
226
)}
227
<MessageList
228
{...{
229
virtuosoRef,
230
sortedDates,
231
messages,
232
search,
233
account_id,
234
user_map,
235
project_id,
236
path,
237
fontSize,
238
selectedHashtags,
239
actions,
240
costEstimate,
241
manualScrollRef,
242
mode,
243
selectedDate,
244
numChildren,
245
}}
246
/>
247
<Composing
248
projectId={project_id}
249
path={path}
250
accountId={account_id}
251
userMap={user_map}
252
/>
253
</>
254
);
255
}
256
257
function isNextMessageSender(
258
index: number,
259
dates: string[],
260
messages: ChatMessages,
261
): boolean {
262
if (index + 1 === dates.length) {
263
return false;
264
}
265
const currentMessage = messages.get(dates[index]);
266
const nextMessage = messages.get(dates[index + 1]);
267
return (
268
currentMessage != null &&
269
nextMessage != null &&
270
currentMessage.get("sender_id") === nextMessage.get("sender_id")
271
);
272
}
273
274
function isPrevMessageSender(
275
index: number,
276
dates: string[],
277
messages: ChatMessages,
278
): boolean {
279
if (index === 0) {
280
return false;
281
}
282
const currentMessage = messages.get(dates[index]);
283
const prevMessage = messages.get(dates[index - 1]);
284
return (
285
currentMessage != null &&
286
prevMessage != null &&
287
currentMessage.get("sender_id") === prevMessage.get("sender_id")
288
);
289
}
290
291
function isThread(
292
message: ChatMessageTyped,
293
numChildren: { [date: number]: number },
294
) {
295
if (message.get("reply_to") != null) {
296
return true;
297
}
298
return (numChildren[message.get("date").valueOf()] ?? 0) > 0;
299
}
300
301
function isFolded(
302
messages: ChatMessages,
303
message: ChatMessageTyped,
304
account_id: string,
305
) {
306
if (account_id == null) {
307
return false;
308
}
309
const rootMsg = getRootMessage({ message: message.toJS(), messages });
310
return rootMsg?.get("folding")?.includes(account_id) ?? false;
311
}
312
313
// messages is an immutablejs map from
314
// - timestamps (ms since epoch as string)
315
// to
316
// - message objects {date: , event:, history, sender_id, reply_to}
317
//
318
// It was very easy to sort these before reply_to, which complicates things.
319
export function getSortedDates(
320
messages: ChatMessages,
321
search: string | undefined,
322
account_id: string,
323
filterRecentH?: number,
324
): {
325
dates: string[];
326
numFolded: number;
327
numChildren: { [date: number]: number };
328
} {
329
let numFolded = 0;
330
let m = messages;
331
if (m == null) {
332
return {
333
dates: [],
334
numFolded: 0,
335
numChildren: {},
336
};
337
}
338
339
// we assume filterMessages contains complete threads. It does
340
// right now, but that's an assumption in this function.
341
m = filterMessages({ messages: m, filter: search, filterRecentH });
342
343
// Do a linear pass through all messages to divide into threads, so that
344
// getSortedDates is O(n) instead of O(n^2) !
345
const numChildren: { [date: number]: number } = {};
346
for (const [_, message] of m) {
347
const parent = message.get("reply_to");
348
if (parent != null) {
349
const d = new Date(parent).valueOf();
350
numChildren[d] = (numChildren[d] ?? 0) + 1;
351
}
352
}
353
354
const v: [date: number, reply_to: number | undefined][] = [];
355
for (const [date, message] of m) {
356
if (message == null) continue;
357
358
// If we search for a message, we treat all threads as unfolded
359
if (!search) {
360
const is_thread = isThread(message, numChildren);
361
const is_folded = is_thread && isFolded(messages, message, account_id);
362
const is_thread_body = is_thread && message.get("reply_to") != null;
363
const folded = is_thread && is_folded && is_thread_body;
364
if (folded) {
365
numFolded++;
366
continue;
367
}
368
}
369
370
const reply_to = message.get("reply_to");
371
v.push([
372
typeof date === "string" ? parseInt(date) : date,
373
reply_to != null ? new Date(reply_to).valueOf() : undefined,
374
]);
375
}
376
v.sort(cmpMessages);
377
const dates = v.map((z) => `${z[0]}`);
378
return { dates, numFolded, numChildren };
379
}
380
381
/*
382
Compare messages as follows:
383
- if message has a parent it is a reply, so we use the parent instead for the
384
compare
385
- except in special cases:
386
- one of them is the parent and other is a child of that parent
387
- both have same parent
388
*/
389
function cmpMessages([a_time, a_parent], [b_time, b_parent]): number {
390
// special case:
391
// same parent:
392
if (a_parent !== undefined && a_parent == b_parent) {
393
return cmp(a_time, b_time);
394
}
395
// one of them is the parent and other is a child of that parent
396
if (a_parent == b_time) {
397
// b is the parent of a, so b is first.
398
return 1;
399
}
400
if (b_parent == a_time) {
401
// a is the parent of b, so a is first.
402
return -1;
403
}
404
// general case.
405
return cmp(a_parent ?? a_time, b_parent ?? b_time);
406
}
407
408
export function getUserName(userMap, accountId: string): string {
409
if (isChatBot(accountId)) {
410
return chatBotName(accountId);
411
}
412
if (userMap == null) return "Unknown";
413
const account = userMap.get(accountId);
414
if (account == null) return "Unknown";
415
return account.get("first_name", "") + " " + account.get("last_name", "");
416
}
417
418
interface NotShowingProps {
419
num: number;
420
search: string;
421
filterRecentH: number;
422
actions;
423
showing;
424
}
425
426
function NotShowing({
427
num,
428
search,
429
filterRecentH,
430
actions,
431
showing,
432
}: NotShowingProps) {
433
if (num <= 0) return null;
434
435
const timespan =
436
filterRecentH > 0 ? hoursToTimeIntervalHuman(filterRecentH) : null;
437
438
return (
439
<Alert
440
style={{ margin: "5px" }}
441
showIcon
442
type="warning"
443
message={
444
<div style={{ display: "flex", alignItems: "center" }}>
445
<b style={{ flex: 1 }}>
446
WARNING: Hiding {num} {plural(num, "message")} in threads
447
{search.trim()
448
? ` that ${
449
num != 1 ? "do" : "does"
450
} not match search for '${search.trim()}'`
451
: ""}
452
{timespan
453
? ` ${
454
search.trim() ? "and" : "that"
455
} were not sent in the past ${timespan}`
456
: ""}
457
. Showing {showing} {plural(showing, "message")}.
458
</b>
459
<Button
460
onClick={() => {
461
actions.clearAllFilters();
462
}}
463
>
464
<Icon name="close-circle-filled" style={{ color: "#888" }} /> Clear
465
</Button>
466
</div>
467
}
468
/>
469
);
470
}
471
472
export function MessageList({
473
messages,
474
account_id,
475
virtuosoRef,
476
sortedDates,
477
user_map,
478
project_id,
479
path,
480
fontSize,
481
selectedHashtags,
482
actions,
483
costEstimate,
484
manualScrollRef,
485
mode,
486
selectedDate,
487
numChildren,
488
}: {
489
messages;
490
account_id;
491
user_map;
492
mode;
493
sortedDates;
494
virtuosoRef?;
495
search?;
496
project_id?;
497
path?;
498
fontSize?;
499
selectedHashtags?;
500
actions?;
501
costEstimate?;
502
manualScrollRef?;
503
selectedDate?: string;
504
numChildren?;
505
}) {
506
const virtuosoHeightsRef = useRef<{ [index: number]: number }>({});
507
const virtuosoScroll = useVirtuosoScrollHook({
508
cacheId: `${project_id}${path}`,
509
initialState: { index: Math.max(sortedDates.length - 1, 0), offset: 0 }, // starts scrolled to the newest message.
510
});
511
512
return (
513
<Virtuoso
514
ref={virtuosoRef}
515
totalCount={sortedDates.length}
516
itemSize={(el) => {
517
// see comment in jupyter/cell-list.tsx
518
const h = el.getBoundingClientRect().height;
519
const data = el.getAttribute("data-item-index");
520
if (data != null) {
521
const index = parseInt(data);
522
virtuosoHeightsRef.current[index] = h;
523
}
524
return h;
525
}}
526
itemContent={(index) => {
527
const date = sortedDates[index];
528
const message: ChatMessageTyped | undefined = messages.get(date);
529
if (message == null) {
530
// shouldn't happen, but make code robust to such a possibility.
531
// if it happens, fix it.
532
console.warn("empty message", { date, index, sortedDates });
533
return <div style={{ height: "30px" }} />;
534
}
535
536
// only do threading if numChildren is defined. It's not defined,
537
// e.g., when viewing past versions via TimeTravel.
538
const is_thread = numChildren != null && isThread(message, numChildren);
539
// optimization: only threads can be folded, so don't waste time
540
// checking on folding state if it isn't a thread.
541
const is_folded = is_thread && isFolded(messages, message, account_id);
542
const is_thread_body = is_thread && message.get("reply_to") != null;
543
const h = virtuosoHeightsRef.current?.[index];
544
545
return (
546
<div
547
style={{
548
overflow: "hidden",
549
paddingTop: index == 0 ? "20px" : undefined,
550
}}
551
>
552
<DivTempHeight height={h ? `${h}px` : undefined}>
553
<Message
554
messages={messages}
555
numChildren={numChildren?.[message.get("date").valueOf()]}
556
key={date}
557
index={index}
558
account_id={account_id}
559
user_map={user_map}
560
message={message}
561
selected={date == selectedDate}
562
project_id={project_id}
563
path={path}
564
font_size={fontSize}
565
selectedHashtags={selectedHashtags}
566
actions={actions}
567
is_thread={is_thread}
568
is_folded={is_folded}
569
is_thread_body={is_thread_body}
570
is_prev_sender={isPrevMessageSender(
571
index,
572
sortedDates,
573
messages,
574
)}
575
show_avatar={!isNextMessageSender(index, sortedDates, messages)}
576
mode={mode}
577
get_user_name={(account_id: string | undefined) =>
578
// ATTN: this also works for LLM chat bot IDs, not just account UUIDs
579
typeof account_id === "string"
580
? getUserName(user_map, account_id)
581
: "Unknown name"
582
}
583
scroll_into_view={
584
virtuosoRef
585
? () => virtuosoRef.current?.scrollIntoView({ index })
586
: undefined
587
}
588
allowReply={
589
messages.getIn([sortedDates[index + 1], "reply_to"]) == null
590
}
591
costEstimate={costEstimate}
592
/>
593
</DivTempHeight>
594
</div>
595
);
596
}}
597
rangeChanged={
598
manualScrollRef
599
? ({ endIndex }) => {
600
// manually scrolling if NOT at the bottom.
601
manualScrollRef.current = endIndex < sortedDates.length - 1;
602
}
603
: undefined
604
}
605
{...virtuosoScroll}
606
/>
607
);
608
}
609
610