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