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/input.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 { CSSProperties, useEffect, useMemo, useRef, useState } from "react";
7
import { useIntl } from "react-intl";
8
import { useDebouncedCallback } from "use-debounce";
9
import { CSS, redux, useIsMountedRef } from "@cocalc/frontend/app-framework";
10
import MarkdownInput from "@cocalc/frontend/editors/markdown-input/multimode";
11
import { IS_MOBILE } from "@cocalc/frontend/feature";
12
import { SAVE_DEBOUNCE_MS } from "@cocalc/frontend/frame-editors/code-editor/const";
13
import { useFrameContext } from "@cocalc/frontend/frame-editors/frame-tree/frame-context";
14
import type { SyncDB } from "@cocalc/sync/editor/db";
15
import { SubmitMentionsRef } from "./types";
16
17
interface Props {
18
on_send: (value: string) => void;
19
onChange: (string) => void;
20
syncdb: SyncDB | undefined;
21
// date:
22
// - ms since epoch of when this message was first sent
23
// - set to 0 for editing new message
24
// - set to -time (negative time) to respond to thread, where time is the time of ROOT message of the the thread.
25
date: number;
26
input?: string;
27
on_paste?: (e) => void;
28
height?: string;
29
submitMentionsRef?: SubmitMentionsRef;
30
fontSize?: number;
31
hideHelp?: boolean;
32
style?: CSSProperties;
33
cacheId?: string;
34
onFocus?: () => void;
35
onBlur?: () => void;
36
editBarStyle?: CSS;
37
placeholder?: string;
38
autoFocus?: boolean;
39
moveCursorToEndOfLine?: boolean;
40
}
41
42
export default function ChatInput({
43
autoFocus,
44
cacheId,
45
date,
46
editBarStyle,
47
fontSize,
48
height,
49
hideHelp,
50
input: propsInput,
51
on_send,
52
onBlur,
53
onChange,
54
onFocus,
55
placeholder,
56
style,
57
submitMentionsRef,
58
syncdb,
59
moveCursorToEndOfLine,
60
}: Props) {
61
const intl = useIntl();
62
const onSendRef = useRef<Function>(on_send);
63
useEffect(() => {
64
onSendRef.current = on_send;
65
}, [on_send]);
66
const { project_id } = useFrameContext();
67
const sender_id = useMemo(
68
() => redux.getStore("account").get_account_id(),
69
[],
70
);
71
const controlRef = useRef<any>(null);
72
const [input, setInput] = useState<string>("");
73
useEffect(() => {
74
const dbInput = syncdb
75
?.get_one({
76
event: "draft",
77
sender_id,
78
date,
79
})
80
?.get("input");
81
// take version from syncdb if it is there; otherwise, version from input prop.
82
// the db version is used when you refresh your browser while editing, or scroll up and down
83
// thus unmounting and remounting the currently editing message (due to virtualization).
84
// See https://github.com/sagemathinc/cocalc/issues/6415
85
const input = dbInput ?? propsInput;
86
setInput(input);
87
if (input?.trim() && moveCursorToEndOfLine) {
88
// have to wait until it's all rendered -- i hate code like this...
89
for (const n of [1, 10, 50]) {
90
setTimeout(() => {
91
controlRef.current?.moveCursorToEndOfLine();
92
}, n);
93
}
94
}
95
}, [date, sender_id, propsInput]);
96
97
const currentInputRef = useRef<string>(input);
98
const saveOnUnmountRef = useRef<boolean>(true);
99
const isMountedRef = useIsMountedRef();
100
const lastSavedRef = useRef<string>(input);
101
const saveChat = useDebouncedCallback(
102
(input) => {
103
if (
104
syncdb == null ||
105
(!isMountedRef.current && !saveOnUnmountRef.current)
106
) {
107
return;
108
}
109
onChange(input);
110
lastSavedRef.current = input;
111
// also save to syncdb, so we have undo, etc.
112
// but definitely don't save (thus updating active) if
113
// the input didn't really change, since we use active for
114
// showing that a user is writing to other users.
115
const input0 = syncdb
116
.get_one({
117
event: "draft",
118
sender_id,
119
date,
120
})
121
?.get("input");
122
if (input0 != input) {
123
if (input0 == null && !input) {
124
// DO NOT save if you haven't written a draft before, and
125
// the draft we would save here would be empty, since that
126
// would lead to what humans would consider false notifications.
127
return;
128
}
129
syncdb.set({
130
event: "draft",
131
sender_id,
132
input,
133
date, // it's a primary key so can't use this to represent when user last edited this; use other date for editing past chats.
134
active: Date.now(),
135
});
136
syncdb.commit();
137
}
138
},
139
SAVE_DEBOUNCE_MS,
140
{
141
leading: true,
142
},
143
);
144
145
useEffect(() => {
146
return () => {
147
if (!isMountedRef.current && !saveOnUnmountRef.current) {
148
return;
149
}
150
// save before unmounting. This is very important since if a new reply comes in,
151
// then the input component gets unmounted, then remounted BELOW the reply.
152
// Note: it is still slightly annoying, due to loss of focus... however, data
153
// loss is NOT ok, whereas loss of focus is.
154
const input = currentInputRef.current;
155
if (!input || syncdb == null) {
156
return;
157
}
158
if (
159
syncdb.get_one({
160
event: "draft",
161
sender_id,
162
date,
163
}) == null
164
) {
165
return;
166
}
167
syncdb.set({
168
event: "draft",
169
sender_id,
170
input,
171
date, // it's a primary key so can't use this to represent when user last edited this; use other date for editing past chats.
172
active: Date.now(),
173
});
174
syncdb.commit();
175
};
176
}, []);
177
178
useEffect(() => {
179
if (syncdb == null) return;
180
const onSyncdbChange = () => {
181
const sender_id = redux.getStore("account").get_account_id();
182
const x = syncdb.get_one({
183
event: "draft",
184
sender_id,
185
date,
186
});
187
const input = x?.get("input") ?? "";
188
if (input != lastSavedRef.current) {
189
setInput(input);
190
currentInputRef.current = input;
191
lastSavedRef.current = input;
192
}
193
};
194
syncdb.on("change", onSyncdbChange);
195
return () => {
196
syncdb.removeListener("change", onSyncdbChange);
197
};
198
}, [syncdb]);
199
200
function getPlaceholder(): string {
201
if (placeholder != null) return placeholder;
202
const have_llm = redux
203
.getStore("projects")
204
.hasLanguageModelEnabled(project_id);
205
return intl.formatMessage(
206
{
207
id: "chat.input.placeholder",
208
defaultMessage:
209
"Type a new message ({have_llm, select, true {chat with AI or } other {}}notify a collaborator by typing @)...",
210
},
211
{
212
have_llm,
213
},
214
);
215
}
216
217
return (
218
<MarkdownInput
219
autoFocus={autoFocus}
220
saveDebounceMs={0}
221
onFocus={onFocus}
222
onBlur={onBlur}
223
cacheId={cacheId}
224
value={input}
225
controlRef={controlRef}
226
enableUpload={true}
227
enableMentions={true}
228
submitMentionsRef={submitMentionsRef}
229
onChange={(input) => {
230
currentInputRef.current = input;
231
/* BUG: in Markdown mode this stops getting
232
called after you paste in an image. It works
233
fine in Slate/Text mode. See
234
https://github.com/sagemathinc/cocalc/issues/7728
235
*/
236
setInput(input);
237
saveChat(input);
238
}}
239
onShiftEnter={(input) => {
240
setInput("");
241
saveChat("");
242
on_send(input);
243
}}
244
height={height}
245
placeholder={getPlaceholder()}
246
extraHelp={
247
IS_MOBILE
248
? "Click the date to edit chats."
249
: "Double click to edit chats."
250
}
251
fontSize={fontSize}
252
hideHelp={hideHelp}
253
style={style}
254
onUndo={() => {
255
saveChat.cancel();
256
syncdb?.undo();
257
}}
258
onRedo={() => {
259
saveChat.cancel();
260
syncdb?.redo();
261
}}
262
editBarStyle={editBarStyle}
263
overflowEllipsis={true}
264
/>
265
);
266
}
267
268