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