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