Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/app/actions.ts
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 { Actions, redux } from "@cocalc/frontend/app-framework";
7
import { set_window_title } from "@cocalc/frontend/browser";
8
import { set_url, update_params } from "@cocalc/frontend/history";
9
import { labels } from "@cocalc/frontend/i18n";
10
import { getIntl } from "@cocalc/frontend/i18n/get-intl";
11
import {
12
exitFullscreen,
13
isFullscreen,
14
requestFullscreen,
15
} from "@cocalc/frontend/misc/fullscreen";
16
import { disconnect_from_project } from "@cocalc/frontend/project/websocket/connect";
17
import { session_manager } from "@cocalc/frontend/session";
18
import { once } from "@cocalc/util/async-utils";
19
import { PageState } from "./store";
20
21
export class PageActions extends Actions<PageState> {
22
private session_manager?: any;
23
private active_key_handler?: any;
24
private suppress_key_handlers: boolean = false;
25
private popconfirmIsOpen: boolean = false;
26
private settingsModalIsOpen: boolean = false;
27
28
/* Expects a func which takes a browser keydown event
29
Only allows one keyhandler to be active at a time.
30
FUTURE: Develop more general way to make key mappings for editors
31
HACK: __suppress_key_handlers is for file_use. See FUTURE above.
32
Adding even a single suppressor leads to spaghetti code.
33
Don't do it. -- J3
34
35
ws: added logic with project_id/path so that
36
only the currently focused editor can set/unset
37
the keyboard handler -- see https://github.com/sagemathinc/cocalc/issues/2826
38
This feels a bit brittle though, but obviously something like this is needed,
39
due to slightly async calls to set_active_key_handler, and expecting editors
40
to do this is silly.
41
*/
42
public set_active_key_handler(
43
handler?: (e) => void,
44
project_id?: string,
45
path?: string, // IMPORTANT: This is the path for the tab! E.g., if setting keyboard handler for a frame, make sure to pass path for the tab. This is a terrible and confusing design and needs to be redone, probably via a hook!
46
): void {
47
if (project_id != null) {
48
if (
49
this.redux.getStore("page").get("active_top_tab") !== project_id ||
50
this.redux.getProjectStore(project_id)?.get("active_project_tab") !==
51
"editor-" + path
52
) {
53
return;
54
}
55
}
56
57
if (handler != null) {
58
$(window).off("keydown", this.active_key_handler);
59
this.active_key_handler = handler;
60
}
61
62
if (this.active_key_handler != null && !this.suppress_key_handlers) {
63
$(window).on("keydown", this.active_key_handler);
64
}
65
}
66
67
// Only clears it from the window
68
public unattach_active_key_handler() {
69
$(window).off("keydown", this.active_key_handler);
70
}
71
72
// Actually removes the handler from active memory
73
// takes a handler to only remove if it's the active one
74
public erase_active_key_handler(handler?) {
75
if (handler == null || handler === this.active_key_handler) {
76
$(window).off("keydown", this.active_key_handler);
77
this.active_key_handler = undefined;
78
}
79
}
80
81
// FUTURE: Will also clear all click handlers.
82
// Right now there aren't even any ways (other than manually)
83
// of adding click handlers that the app knows about.
84
public clear_all_handlers() {
85
$(window).off("keydown", this.active_key_handler);
86
this.active_key_handler = undefined;
87
}
88
89
private add_a_ghost_tab(): void {
90
const current_num = redux.getStore("page").get("num_ghost_tabs");
91
this.setState({ num_ghost_tabs: current_num + 1 });
92
}
93
94
public clear_ghost_tabs(): void {
95
this.setState({ num_ghost_tabs: 0 });
96
}
97
98
public close_project_tab(project_id: string): void {
99
const page_store = redux.getStore("page");
100
const projects_store = redux.getStore("projects");
101
102
const open_projects = projects_store.get("open_projects");
103
const active_top_tab = page_store.get("active_top_tab");
104
105
const index = open_projects.indexOf(project_id);
106
if (index === -1) {
107
return;
108
}
109
110
if (this.session_manager != null) {
111
this.session_manager.close_project(project_id);
112
} // remembers what files are open
113
114
const { size } = open_projects;
115
if (project_id === active_top_tab) {
116
let next_active_tab;
117
if (index === -1 || size <= 1) {
118
next_active_tab = "projects";
119
} else if (index === size - 1) {
120
next_active_tab = open_projects.get(index - 1);
121
} else {
122
next_active_tab = open_projects.get(index + 1);
123
}
124
this.set_active_tab(next_active_tab);
125
}
126
127
// The point of these "ghost tabs" is to make it so you can quickly close several
128
// open tabs, like in Chrome.
129
if (index === size - 1) {
130
this.clear_ghost_tabs();
131
} else {
132
this.add_a_ghost_tab();
133
}
134
135
redux.getActions("projects").set_project_closed(project_id);
136
this.save_session();
137
138
// if there happens to be a websocket to this project, get rid of it.
139
// Nothing will be using it when the project is closed.
140
disconnect_from_project(project_id);
141
}
142
143
async set_active_tab(key, change_history = true): Promise<void> {
144
const prev_key = this.redux.getStore("page").get("active_top_tab");
145
this.setState({ active_top_tab: key });
146
147
if (prev_key !== key && prev_key?.length == 36) {
148
// fire hide action on project we are switching from.
149
redux.getProjectActions(prev_key)?.hide();
150
}
151
if (key?.length == 36) {
152
// fire show action on project we are switching to
153
redux.getProjectActions(key)?.show();
154
}
155
156
const intl = await getIntl();
157
158
switch (key) {
159
case "projects":
160
if (change_history) {
161
set_url("/projects");
162
}
163
set_window_title(intl.formatMessage(labels.projects));
164
return;
165
case "account":
166
case "settings":
167
if (change_history) {
168
redux.getActions("account").push_state();
169
}
170
set_window_title(intl.formatMessage(labels.account));
171
return;
172
case "file-use": // this doesn't actually get used currently
173
if (change_history) {
174
set_url("/file-use");
175
}
176
set_window_title("File Usage");
177
return;
178
case "admin":
179
if (change_history) {
180
set_url("/admin");
181
}
182
set_window_title(intl.formatMessage(labels.admin));
183
return;
184
case "notifications":
185
if (change_history) {
186
set_url("/notifications");
187
}
188
set_window_title(intl.formatMessage(labels.messages_title));
189
return;
190
case undefined:
191
return;
192
default:
193
if (change_history) {
194
redux.getProjectActions(key)?.push_state();
195
}
196
set_window_title("Loading Project");
197
var projects_store = redux.getStore("projects");
198
199
if (projects_store.date_when_course_payment_required(key)) {
200
redux
201
.getActions("projects")
202
.apply_default_upgrades({ project_id: key });
203
}
204
205
try {
206
const title: string = await projects_store.async_wait({
207
until: (store): string | undefined => {
208
let title: string | undefined = store.getIn([
209
"project_map",
210
key,
211
"title",
212
]);
213
if (title == null) {
214
title = store.getIn(["public_project_titles", key]);
215
}
216
if (title === "") {
217
return "Untitled Project";
218
}
219
if (title == null) {
220
redux.getActions("projects").fetch_public_project_title(key);
221
}
222
return title;
223
},
224
timeout: 15,
225
});
226
set_window_title(title);
227
} catch (err) {
228
set_window_title("");
229
}
230
}
231
}
232
233
show_connection(show_connection) {
234
this.setState({ show_connection });
235
}
236
237
// Suppress the activation of any new key handlers
238
disableGlobalKeyHandler = () => {
239
this.suppress_key_handlers = true;
240
this.unattach_active_key_handler();
241
};
242
// Enable whatever the current key handler should be
243
enableGlobalKeyHandler = () => {
244
this.suppress_key_handlers = false;
245
this.set_active_key_handler();
246
};
247
248
// Toggles visibility of file use widget
249
// Temporarily disables window key handlers until closed
250
// FUTURE: Develop more general way to make key mappings
251
toggle_show_file_use() {
252
const currently_shown = redux.getStore("page").get("show_file_use");
253
if (currently_shown) {
254
this.enableGlobalKeyHandler(); // HACK: Terrible way to do this.
255
} else {
256
// Suppress the activation of any new key handlers until file_use closes
257
this.disableGlobalKeyHandler(); // HACK: Terrible way to do this.
258
}
259
260
this.setState({ show_file_use: !currently_shown });
261
}
262
263
set_ping(ping, avgping) {
264
this.setState({ ping, avgping });
265
}
266
267
set_connection_status = (connection_status, time: Date) => {
268
this.setState({ connection_status, last_status_time: time });
269
};
270
271
set_connection_quality(connection_quality) {
272
this.setState({ connection_quality });
273
}
274
275
set_new_version(new_version) {
276
this.setState({ new_version });
277
}
278
279
async set_fullscreen(
280
fullscreen?: "default" | "kiosk" | "project" | undefined,
281
) {
282
// val = 'default', 'kiosk', 'project', undefined
283
// if kiosk is ever set, disable toggling back
284
if (redux.getStore("page").get("fullscreen") === "kiosk") {
285
return;
286
}
287
this.setState({ fullscreen });
288
if (fullscreen == "project") {
289
// this removes top row for embedding purposes and thus doesn't need
290
// full browser fullscreen.
291
return;
292
}
293
if (fullscreen) {
294
try {
295
await requestFullscreen();
296
} catch (err) {
297
// gives an error if not initiated explicitly by user action,
298
// or not available (e.g., iphone)
299
console.log(err);
300
}
301
} else {
302
if (isFullscreen()) {
303
exitFullscreen();
304
}
305
}
306
}
307
308
set_get_api_key(val) {
309
this.setState({ get_api_key: val });
310
update_params();
311
}
312
313
toggle_fullscreen() {
314
this.set_fullscreen(
315
redux.getStore("page").get("fullscreen") != null ? undefined : "default",
316
);
317
}
318
319
set_session(session) {
320
// If existing different session, close it.
321
if (session !== redux.getStore("page").get("session")) {
322
if (this.session_manager != null) {
323
this.session_manager.close();
324
}
325
delete this.session_manager;
326
}
327
328
// Save state and update URL.
329
this.setState({ session });
330
331
// Make new session manager, but only register it if we have
332
// an actual session name!
333
if (!this.session_manager) {
334
const sm = session_manager(session, redux);
335
if (session) {
336
this.session_manager = sm;
337
}
338
}
339
}
340
341
save_session() {
342
this.session_manager?.save();
343
}
344
345
restore_session(project_id) {
346
this.session_manager?.restore(project_id);
347
}
348
349
show_cookie_warning() {
350
this.setState({ cookie_warning: true });
351
}
352
353
show_local_storage_warning() {
354
this.setState({ local_storage_warning: true });
355
}
356
357
check_unload(_) {
358
if (redux.getStore("page").get("get_api_key")) {
359
// never confirm close if get_api_key is set.
360
return;
361
}
362
const fullscreen = redux.getStore("page").get("fullscreen");
363
if (fullscreen == "kiosk" || fullscreen == "project") {
364
// never confirm close in kiosk or project embed mode, since that should be
365
// responsibility of containing page, and it's confusing where
366
// the dialog is even coming from.
367
return;
368
}
369
// Returns a defined string if the user should confirm exiting the site.
370
const s = redux.getStore("account");
371
if (
372
(s != null ? s.get_user_type() : undefined) === "signed_in" &&
373
(s != null ? s.get_confirm_close() : undefined)
374
) {
375
return "Changes you make may not have been saved.";
376
} else {
377
return;
378
}
379
}
380
381
set_sign_in_func(func) {
382
this.sign_in = func;
383
}
384
385
remove_sign_in_func() {
386
this.sign_in = () => false;
387
}
388
389
// Expected to be overridden by functions above
390
sign_in() {
391
return false;
392
}
393
394
// The code below is complicated and tricky because multiple parts of our codebase could
395
// call it at the "same time". This happens, e.g., when opening several Jupyter notebooks
396
// on a compute server from the terminal using the open command.
397
// By "same time", I mean a second call to popconfirm comes in while the first is async
398
// awaiting to finish. We handle that below by locking while waiting. Since only one
399
// thing actually happens at a time in Javascript, the below should always work with
400
// no deadlocks. It's tricky looking code, but MUCH simpler than alternatives I considered.
401
popconfirm = async (opts): Promise<boolean> => {
402
const store = redux.getStore("page");
403
// wait for any currently open modal to be done.
404
while (this.popconfirmIsOpen) {
405
await once(store, "change");
406
}
407
// we got it, so let's take the lock
408
try {
409
this.popconfirmIsOpen = true;
410
// now we do it -- this causes the modal to appear
411
this.setState({ popconfirm: { open: true, ...opts } });
412
// wait for our to be done
413
while (store.getIn(["popconfirm", "open"])) {
414
await once(store, "change");
415
}
416
// report result of ours.
417
return !!store.getIn(["popconfirm", "ok"]);
418
} finally {
419
// give up the lock
420
this.popconfirmIsOpen = false;
421
// trigger a change, so other code has a chance to get the lock
422
this.setState({ popconfirm: { open: false } });
423
}
424
};
425
426
settings = async (name) => {
427
if (!name) {
428
this.setState({ settingsModal: "" });
429
this.settingsModalIsOpen = false;
430
return;
431
}
432
const store = redux.getStore("page");
433
while (this.settingsModalIsOpen) {
434
await once(store, "change");
435
}
436
try {
437
this.settingsModalIsOpen = true;
438
this.setState({ settingsModal: name });
439
while (store.get("settingsModal")) {
440
await once(store, "change");
441
}
442
} finally {
443
this.settingsModalIsOpen = false;
444
}
445
};
446
}
447
448
export function init_actions() {
449
redux.createActions("page", PageActions);
450
}
451
452