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/chatroom.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
import { Button, Divider, Input, Select, Space, Tooltip } from "antd";
7
import { debounce } from "lodash";
8
import { ButtonGroup, Col, Row, Well } from "@cocalc/frontend/antd-bootstrap";
9
import {
10
React,
11
redux,
12
useEditorRedux,
13
useEffect,
14
useRef,
15
useState,
16
} from "@cocalc/frontend/app-framework";
17
import { Icon, Loading } from "@cocalc/frontend/components";
18
import StaticMarkdown from "@cocalc/frontend/editors/slate/static-markdown";
19
import { FrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
20
import { hoursToTimeIntervalHuman } from "@cocalc/util/misc";
21
import { FormattedMessage } from "react-intl";
22
import type { ChatActions } from "./actions";
23
import type { ChatState } from "./store";
24
import { ChatLog } from "./chat-log";
25
import ChatInput from "./input";
26
import { LLMCostEstimationChat } from "./llm-cost-estimation";
27
import { SubmitMentionsFn } from "./types";
28
import { INPUT_HEIGHT, markChatAsReadIfUnseen } from "./utils";
29
import VideoChatButton from "./video/launch-button";
30
import Filter from "./filter";
31
32
const FILTER_RECENT_NONE = {
33
value: 0,
34
label: (
35
<>
36
<Icon name="clock" />
37
</>
38
),
39
} as const;
40
41
const PREVIEW_STYLE: React.CSSProperties = {
42
background: "#f5f5f5",
43
fontSize: "14px",
44
borderRadius: "10px 10px 10px 10px",
45
boxShadow: "#666 3px 3px 3px",
46
paddingBottom: "20px",
47
maxHeight: "40vh",
48
overflowY: "auto",
49
} as const;
50
51
const GRID_STYLE: React.CSSProperties = {
52
maxWidth: "1200px",
53
display: "flex",
54
flexDirection: "column",
55
width: "100%",
56
margin: "auto",
57
} as const;
58
59
const CHAT_LOG_STYLE: React.CSSProperties = {
60
padding: "0",
61
background: "white",
62
flex: "1 0 auto",
63
position: "relative",
64
} as const;
65
66
interface Props {
67
actions: ChatActions;
68
project_id: string;
69
path: string;
70
is_visible?: boolean;
71
font_size: number;
72
desc?;
73
}
74
75
export function ChatRoom({
76
actions,
77
project_id,
78
path,
79
is_visible,
80
font_size,
81
desc,
82
}: Props) {
83
const useEditor = useEditorRedux<ChatState>({ project_id, path });
84
const [input, setInput] = useState("");
85
const search = desc?.get("data-search") ?? "";
86
const filterRecentH: number = desc?.get("data-filterRecentH") ?? 0;
87
const selectedHashtags = desc?.get("data-selectedHashtags");
88
const scrollToIndex = desc?.get("data-scrollToIndex") ?? null;
89
const scrollToDate = desc?.get("data-scrollToDate") ?? null;
90
const fragmentId = desc?.get("data-fragmentId") ?? null;
91
const showPreview = desc?.get("data-showPreview") ?? null;
92
const costEstimate = desc?.get("data-costEstimate");
93
const messages = useEditor("messages");
94
const [filterRecentHCustom, setFilterRecentHCustom] = useState<string>("");
95
const [filterRecentOpen, setFilterRecentOpen] = useState<boolean>(false);
96
97
const submitMentionsRef = useRef<SubmitMentionsFn>();
98
const scrollToBottomRef = useRef<any>(null);
99
100
// The act of opening/displaying the chat marks it as seen...
101
useEffect(() => {
102
mark_as_read();
103
}, []);
104
105
function mark_as_read() {
106
markChatAsReadIfUnseen(project_id, path);
107
}
108
109
function on_send_button_click(e): void {
110
e.preventDefault();
111
on_send();
112
}
113
114
function render_preview_message(): JSX.Element | undefined {
115
if (!showPreview) {
116
return;
117
}
118
if (input.length === 0) {
119
return;
120
}
121
122
return (
123
<Row style={{ position: "absolute", bottom: "0px", width: "100%" }}>
124
<Col xs={0} sm={2} />
125
126
<Col xs={10} sm={9}>
127
<Well style={PREVIEW_STYLE}>
128
<div
129
className="pull-right lighten"
130
style={{
131
marginRight: "-8px",
132
marginTop: "-10px",
133
cursor: "pointer",
134
fontSize: "13pt",
135
}}
136
onClick={() => actions.setShowPreview(false)}
137
>
138
<Icon name="times" />
139
</div>
140
<StaticMarkdown value={input} />
141
<div className="small lighten" style={{ marginTop: "15px" }}>
142
Preview (press Shift+Enter to send)
143
</div>
144
</Well>
145
</Col>
146
147
<Col sm={1} />
148
</Row>
149
);
150
}
151
152
function render_video_chat_button() {
153
if (project_id == null || path == null) return;
154
return <VideoChatButton actions={actions} />;
155
}
156
157
function isValidFilterRecentCustom(): boolean {
158
const v = parseFloat(filterRecentHCustom);
159
return isFinite(v) && v >= 0;
160
}
161
162
function renderFilterRecent() {
163
return (
164
<Tooltip title="Only show recent threads.">
165
<Select
166
open={filterRecentOpen}
167
onDropdownVisibleChange={(v) => setFilterRecentOpen(v)}
168
value={filterRecentH}
169
status={filterRecentH > 0 ? "warning" : undefined}
170
allowClear
171
onClear={() => {
172
actions.setFilterRecentH(0);
173
setFilterRecentHCustom("");
174
}}
175
popupMatchSelectWidth={false}
176
onSelect={(val: number) => actions.setFilterRecentH(val)}
177
options={[
178
FILTER_RECENT_NONE,
179
...[1, 6, 12, 24, 48, 24 * 7, 14 * 24, 28 * 24].map((value) => {
180
const label = hoursToTimeIntervalHuman(value);
181
return { value, label };
182
}),
183
]}
184
labelRender={({ label, value }) => {
185
if (!label) {
186
if (isValidFilterRecentCustom()) {
187
value = parseFloat(filterRecentHCustom);
188
label = hoursToTimeIntervalHuman(value);
189
} else {
190
({ label, value } = FILTER_RECENT_NONE);
191
}
192
}
193
return (
194
<Tooltip
195
title={
196
value === 0
197
? undefined
198
: `Only threads with messages sent in the past ${label}.`
199
}
200
>
201
{label}
202
</Tooltip>
203
);
204
}}
205
dropdownRender={(menu) => (
206
<>
207
{menu}
208
<Divider style={{ margin: "8px 0" }} />
209
<Input
210
placeholder="Number of hours"
211
allowClear
212
value={filterRecentHCustom}
213
status={
214
filterRecentHCustom == "" || isValidFilterRecentCustom()
215
? undefined
216
: "error"
217
}
218
onChange={debounce(
219
(e: React.ChangeEvent<HTMLInputElement>) => {
220
const v = e.target.value;
221
setFilterRecentHCustom(v);
222
const val = parseFloat(v);
223
if (isFinite(val) && val >= 0) {
224
actions.setFilterRecentH(val);
225
} else if (v == "") {
226
actions.setFilterRecentH(FILTER_RECENT_NONE.value);
227
}
228
},
229
150,
230
{ leading: true, trailing: true },
231
)}
232
onKeyDown={(e) => e.stopPropagation()}
233
onPressEnter={() => setFilterRecentOpen(false)}
234
addonAfter={<span style={{ paddingLeft: "5px" }}>hours</span>}
235
/>
236
</>
237
)}
238
/>
239
</Tooltip>
240
);
241
}
242
243
function render_button_row() {
244
if (messages == null) {
245
return null;
246
}
247
return (
248
<Space style={{ width: "100%", marginTop: "3px" }} wrap>
249
<Filter
250
actions={actions}
251
search={search}
252
style={{
253
margin: 0,
254
width: "100%",
255
...(messages.size >= 2
256
? undefined
257
: { visibility: "hidden", height: 0 }),
258
}}
259
/>
260
{renderFilterRecent()}
261
<ButtonGroup style={{ marginLeft: "5px" }}>
262
{render_video_chat_button()}
263
</ButtonGroup>
264
</Space>
265
);
266
}
267
268
function on_send(): void {
269
scrollToBottomRef.current?.(true);
270
actions.sendChat({ submitMentionsRef });
271
setTimeout(() => {
272
scrollToBottomRef.current?.(true);
273
}, 100);
274
setInput("");
275
}
276
277
function render_body(): JSX.Element {
278
return (
279
<div className="smc-vfill" style={GRID_STYLE}>
280
{render_button_row()}
281
<div className="smc-vfill" style={CHAT_LOG_STYLE}>
282
<ChatLog
283
actions={actions}
284
project_id={project_id}
285
path={path}
286
scrollToBottomRef={scrollToBottomRef}
287
mode={"standalone"}
288
fontSize={font_size}
289
search={search}
290
filterRecentH={filterRecentH}
291
selectedHashtags={selectedHashtags}
292
scrollToIndex={scrollToIndex}
293
scrollToDate={scrollToDate}
294
selectedDate={fragmentId}
295
costEstimate={costEstimate}
296
/>
297
{render_preview_message()}
298
</div>
299
<div style={{ display: "flex", marginBottom: "5px", overflow: "auto" }}>
300
<div
301
style={{
302
flex: "1",
303
padding: "0px 5px 0px 2px",
304
}}
305
>
306
<ChatInput
307
fontSize={font_size}
308
autoFocus
309
cacheId={`${path}${project_id}-new`}
310
input={input}
311
on_send={on_send}
312
height={INPUT_HEIGHT}
313
onChange={(value) => {
314
setInput(value);
315
// submitMentionsRef will not actually submit mentions; we're only interested in the reply value
316
const input =
317
submitMentionsRef.current?.(undefined, true) ?? value;
318
actions?.llmEstimateCost({ date: 0, input });
319
}}
320
submitMentionsRef={submitMentionsRef}
321
syncdb={actions.syncdb}
322
date={0}
323
editBarStyle={{ overflow: "auto" }}
324
/>
325
</div>
326
<div
327
style={{
328
display: "flex",
329
flexDirection: "column",
330
padding: "0",
331
marginBottom: "0",
332
}}
333
>
334
<div style={{ flex: 1 }} />
335
{costEstimate?.get("date") == 0 && (
336
<LLMCostEstimationChat
337
costEstimate={costEstimate?.toJS()}
338
compact
339
style={{
340
flex: 0,
341
fontSize: "85%",
342
textAlign: "center",
343
margin: "0 0 5px 0",
344
}}
345
/>
346
)}
347
<Tooltip
348
title={
349
<FormattedMessage
350
id="chatroom.chat_input.send_button.tooltip"
351
defaultMessage={"Send message (shift+enter)"}
352
/>
353
}
354
>
355
<Button
356
onClick={on_send_button_click}
357
disabled={input.trim() === ""}
358
type="primary"
359
style={{ height: "47.5px" }}
360
icon={<Icon name="paper-plane" />}
361
>
362
<FormattedMessage
363
id="chatroom.chat_input.send_button.label"
364
defaultMessage={"Send"}
365
/>
366
</Button>
367
</Tooltip>
368
<div style={{ height: "5px" }} />
369
<Button
370
type={showPreview ? "dashed" : undefined}
371
onClick={() => actions.setShowPreview(!showPreview)}
372
style={{ height: "47.5px" }}
373
>
374
<FormattedMessage
375
id="chatroom.chat_input.preview_button.label"
376
defaultMessage={"Preview"}
377
/>
378
</Button>
379
</div>
380
</div>
381
</div>
382
);
383
}
384
385
if (messages == null || input == null) {
386
return <Loading theme={"medium"} />;
387
}
388
// remove frameContext once the chatroom is part of a frame tree.
389
// we need this now, e.g., since some markdown editing components
390
// for input assume in a frame tree, e.g., to fix
391
// https://github.com/sagemathinc/cocalc/issues/7554
392
return (
393
<FrameContext.Provider
394
value={
395
{
396
project_id,
397
path,
398
isVisible: !!is_visible,
399
redux,
400
} as any
401
}
402
>
403
<div
404
onMouseMove={mark_as_read}
405
onClick={mark_as_read}
406
className="smc-vfill"
407
>
408
{render_body()}
409
</div>
410
</FrameContext.Provider>
411
);
412
}
413
414