Path: blob/master/src/packages/frontend/app/actions.ts
1496 views
/*1* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.2* License: MS-RSL – see LICENSE.md for details3*/45import { Actions, redux } from "@cocalc/frontend/app-framework";6import { set_window_title } from "@cocalc/frontend/browser";7import { set_url, update_params } from "@cocalc/frontend/history";8import { labels } from "@cocalc/frontend/i18n";9import { getIntl } from "@cocalc/frontend/i18n/get-intl";10import {11exitFullscreen,12isFullscreen,13requestFullscreen,14} from "@cocalc/frontend/misc/fullscreen";15import { disconnect_from_project } from "@cocalc/frontend/project/websocket/connect";16import { session_manager } from "@cocalc/frontend/session";17import { once } from "@cocalc/util/async-utils";18import { PageState } from "./store";1920export class PageActions extends Actions<PageState> {21private session_manager?: any;22private active_key_handler?: any;23private suppress_key_handlers: boolean = false;24private popconfirmIsOpen: boolean = false;25private settingsModalIsOpen: boolean = false;2627/* Expects a func which takes a browser keydown event28Only allows one keyhandler to be active at a time.29FUTURE: Develop more general way to make key mappings for editors30HACK: __suppress_key_handlers is for file_use. See FUTURE above.31Adding even a single suppressor leads to spaghetti code.32Don't do it. -- J33334ws: added logic with project_id/path so that35only the currently focused editor can set/unset36the keyboard handler -- see https://github.com/sagemathinc/cocalc/issues/282637This feels a bit brittle though, but obviously something like this is needed,38due to slightly async calls to set_active_key_handler, and expecting editors39to do this is silly.40*/41public set_active_key_handler(42handler?: (e) => void,43project_id?: string,44path?: 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!45): void {46if (project_id != null) {47if (48this.redux.getStore("page").get("active_top_tab") !== project_id ||49this.redux.getProjectStore(project_id)?.get("active_project_tab") !==50"editor-" + path51) {52return;53}54}5556if (handler != null) {57$(window).off("keydown", this.active_key_handler);58this.active_key_handler = handler;59}6061if (this.active_key_handler != null && !this.suppress_key_handlers) {62$(window).on("keydown", this.active_key_handler);63}64}6566// Only clears it from the window67public unattach_active_key_handler() {68$(window).off("keydown", this.active_key_handler);69}7071// Actually removes the handler from active memory72// takes a handler to only remove if it's the active one73public erase_active_key_handler(handler?) {74if (handler == null || handler === this.active_key_handler) {75$(window).off("keydown", this.active_key_handler);76this.active_key_handler = undefined;77}78}7980// FUTURE: Will also clear all click handlers.81// Right now there aren't even any ways (other than manually)82// of adding click handlers that the app knows about.83public clear_all_handlers() {84$(window).off("keydown", this.active_key_handler);85this.active_key_handler = undefined;86}8788private add_a_ghost_tab(): void {89const current_num = redux.getStore("page").get("num_ghost_tabs");90this.setState({ num_ghost_tabs: current_num + 1 });91}9293public clear_ghost_tabs(): void {94this.setState({ num_ghost_tabs: 0 });95}9697public close_project_tab(project_id: string): void {98const page_store = redux.getStore("page");99const projects_store = redux.getStore("projects");100101const open_projects = projects_store.get("open_projects");102const active_top_tab = page_store.get("active_top_tab");103104const index = open_projects.indexOf(project_id);105if (index === -1) {106return;107}108109if (this.session_manager != null) {110this.session_manager.close_project(project_id);111} // remembers what files are open112113const { size } = open_projects;114if (project_id === active_top_tab) {115let next_active_tab;116if (index === -1 || size <= 1) {117next_active_tab = "projects";118} else if (index === size - 1) {119next_active_tab = open_projects.get(index - 1);120} else {121next_active_tab = open_projects.get(index + 1);122}123this.set_active_tab(next_active_tab);124}125126// The point of these "ghost tabs" is to make it so you can quickly close several127// open tabs, like in Chrome.128if (index === size - 1) {129this.clear_ghost_tabs();130} else {131this.add_a_ghost_tab();132}133134redux.getActions("projects").set_project_closed(project_id);135this.save_session();136137// if there happens to be a websocket to this project, get rid of it.138// Nothing will be using it when the project is closed.139disconnect_from_project(project_id);140}141142async set_active_tab(key, change_history = true): Promise<void> {143const prev_key = this.redux.getStore("page").get("active_top_tab");144this.setState({ active_top_tab: key });145146if (prev_key !== key && prev_key?.length == 36) {147// fire hide action on project we are switching from.148redux.getProjectActions(prev_key)?.hide();149}150if (key?.length == 36) {151// fire show action on project we are switching to152redux.getProjectActions(key)?.show();153}154155const intl = await getIntl();156157switch (key) {158case "projects":159if (change_history) {160set_url("/projects");161}162set_window_title(intl.formatMessage(labels.projects));163return;164case "account":165case "settings":166if (change_history) {167redux.getActions("account").push_state();168}169set_window_title(intl.formatMessage(labels.account));170return;171case "file-use": // this doesn't actually get used currently172if (change_history) {173set_url("/file-use");174}175set_window_title("File Usage");176return;177case "admin":178if (change_history) {179set_url("/admin");180}181set_window_title(intl.formatMessage(labels.admin));182return;183case "notifications":184if (change_history) {185set_url("/notifications");186}187set_window_title(intl.formatMessage(labels.messages_title));188return;189case undefined:190return;191default:192if (change_history) {193redux.getProjectActions(key)?.push_state();194}195set_window_title("Loading Project");196var projects_store = redux.getStore("projects");197198if (projects_store.date_when_course_payment_required(key)) {199redux200.getActions("projects")201.apply_default_upgrades({ project_id: key });202}203204try {205const title: string = await projects_store.async_wait({206until: (store): string | undefined => {207let title: string | undefined = store.getIn([208"project_map",209key,210"title",211]);212if (title == null) {213title = store.getIn(["public_project_titles", key]);214}215if (title === "") {216return "Untitled Project";217}218if (title == null) {219redux.getActions("projects").fetch_public_project_title(key);220}221return title;222},223timeout: 15,224});225set_window_title(title);226} catch (err) {227set_window_title("");228}229}230}231232show_connection(show_connection) {233this.setState({ show_connection });234}235236// Suppress the activation of any new key handlers237disableGlobalKeyHandler = () => {238this.suppress_key_handlers = true;239this.unattach_active_key_handler();240};241// Enable whatever the current key handler should be242enableGlobalKeyHandler = () => {243this.suppress_key_handlers = false;244this.set_active_key_handler();245};246247// Toggles visibility of file use widget248// Temporarily disables window key handlers until closed249// FUTURE: Develop more general way to make key mappings250toggle_show_file_use() {251const currently_shown = redux.getStore("page").get("show_file_use");252if (currently_shown) {253this.enableGlobalKeyHandler(); // HACK: Terrible way to do this.254} else {255// Suppress the activation of any new key handlers until file_use closes256this.disableGlobalKeyHandler(); // HACK: Terrible way to do this.257}258259this.setState({ show_file_use: !currently_shown });260}261262set_ping(ping, avgping) {263this.setState({ ping, avgping });264}265266set_connection_status = (connection_status, time: Date) => {267this.setState({ connection_status, last_status_time: time });268};269270set_connection_quality(connection_quality) {271this.setState({ connection_quality });272}273274set_new_version(new_version) {275this.setState({ new_version });276}277278async set_fullscreen(279fullscreen?: "default" | "kiosk" | "project" | undefined,280) {281// val = 'default', 'kiosk', 'project', undefined282// if kiosk is ever set, disable toggling back283if (redux.getStore("page").get("fullscreen") === "kiosk") {284return;285}286this.setState({ fullscreen });287if (fullscreen == "project") {288// this removes top row for embedding purposes and thus doesn't need289// full browser fullscreen.290return;291}292if (fullscreen) {293try {294await requestFullscreen();295} catch (err) {296// gives an error if not initiated explicitly by user action,297// or not available (e.g., iphone)298console.log(err);299}300} else {301if (isFullscreen()) {302exitFullscreen();303}304}305}306307set_get_api_key(val) {308this.setState({ get_api_key: val });309update_params();310}311312toggle_fullscreen() {313this.set_fullscreen(314redux.getStore("page").get("fullscreen") != null ? undefined : "default",315);316}317318set_session(session) {319// If existing different session, close it.320if (session !== redux.getStore("page").get("session")) {321if (this.session_manager != null) {322this.session_manager.close();323}324delete this.session_manager;325}326327// Save state and update URL.328this.setState({ session });329330// Make new session manager, but only register it if we have331// an actual session name!332if (!this.session_manager) {333const sm = session_manager(session, redux);334if (session) {335this.session_manager = sm;336}337}338}339340save_session() {341this.session_manager?.save();342}343344restore_session(project_id) {345this.session_manager?.restore(project_id);346}347348show_cookie_warning() {349this.setState({ cookie_warning: true });350}351352show_local_storage_warning() {353this.setState({ local_storage_warning: true });354}355356check_unload(_) {357if (redux.getStore("page").get("get_api_key")) {358// never confirm close if get_api_key is set.359return;360}361const fullscreen = redux.getStore("page").get("fullscreen");362if (fullscreen == "kiosk" || fullscreen == "project") {363// never confirm close in kiosk or project embed mode, since that should be364// responsibility of containing page, and it's confusing where365// the dialog is even coming from.366return;367}368// Returns a defined string if the user should confirm exiting the site.369const s = redux.getStore("account");370if (371(s != null ? s.get_user_type() : undefined) === "signed_in" &&372(s != null ? s.get_confirm_close() : undefined)373) {374return "Changes you make may not have been saved.";375} else {376return;377}378}379380set_sign_in_func(func) {381this.sign_in = func;382}383384remove_sign_in_func() {385this.sign_in = () => false;386}387388// Expected to be overridden by functions above389sign_in() {390return false;391}392393// The code below is complicated and tricky because multiple parts of our codebase could394// call it at the "same time". This happens, e.g., when opening several Jupyter notebooks395// on a compute server from the terminal using the open command.396// By "same time", I mean a second call to popconfirm comes in while the first is async397// awaiting to finish. We handle that below by locking while waiting. Since only one398// thing actually happens at a time in Javascript, the below should always work with399// no deadlocks. It's tricky looking code, but MUCH simpler than alternatives I considered.400popconfirm = async (opts): Promise<boolean> => {401const store = redux.getStore("page");402// wait for any currently open modal to be done.403while (this.popconfirmIsOpen) {404await once(store, "change");405}406// we got it, so let's take the lock407try {408this.popconfirmIsOpen = true;409// now we do it -- this causes the modal to appear410this.setState({ popconfirm: { open: true, ...opts } });411// wait for our to be done412while (store.getIn(["popconfirm", "open"])) {413await once(store, "change");414}415// report result of ours.416return !!store.getIn(["popconfirm", "ok"]);417} finally {418// give up the lock419this.popconfirmIsOpen = false;420// trigger a change, so other code has a chance to get the lock421this.setState({ popconfirm: { open: false } });422}423};424425settings = async (name) => {426if (!name) {427this.setState({ settingsModal: "" });428this.settingsModalIsOpen = false;429return;430}431const store = redux.getStore("page");432while (this.settingsModalIsOpen) {433await once(store, "change");434}435try {436this.settingsModalIsOpen = true;437this.setState({ settingsModal: name });438while (store.get("settingsModal")) {439await once(store, "change");440}441} finally {442this.settingsModalIsOpen = false;443}444};445}446447export function init_actions() {448redux.createActions("page", PageActions);449}450451452