}> = ({
+ unsavedDocs,
+}) => (
+
+ {[...unsavedDocs.entries()].map(
+ ([docId, seconds]) =>
+ seconds > 8 && (
+
+ )
+ )}
+
+)
+
+const UnsavedDocAlert: FC<{ docId: string; seconds: number }> = ({
+ docId,
+ seconds,
+}) => {
+ const { pathInFolder, findEntityByPath } = useFileTreePathContext()
+ const { t } = useTranslation()
+
+ const doc = useMemo(() => {
+ const path = pathInFolder(docId)
+ return path ? findEntityByPath(path) : null
+ }, [docId, findEntityByPath, pathInFolder])
+
+ if (!doc) {
+ return null
+ }
+
+ return (
+
+ {t('saving_notification_with_seconds', {
+ docname: doc.entity.name,
+ seconds,
+ })}
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs-locked-modal.tsx b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs-locked-modal.tsx
new file mode 100644
index 0000000000..c136ec8d30
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs-locked-modal.tsx
@@ -0,0 +1,22 @@
+import { FC } from 'react'
+import { Modal } from 'react-bootstrap'
+import { useTranslation } from 'react-i18next'
+import AccessibleModal from '@/shared/components/accessible-modal'
+
+export const UnsavedDocsLockedModal: FC = () => {
+ const { t } = useTranslation()
+
+ return (
+ {}} // It's not possible to hide this modal, but it's a required prop
+ className="lock-editor-modal"
+ backdrop={false}
+ keyboard={false}
+ >
+
+ {t('connection_lost')}
+
+ {t('sorry_the_connection_to_the_server_is_down')}
+
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx
new file mode 100644
index 0000000000..f0ce93cf11
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/unsaved-docs/unsaved-docs.tsx
@@ -0,0 +1,105 @@
+import { useEditorManagerContext } from '@/features/ide-react/context/editor-manager-context'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { FC, useCallback, useEffect, useRef, useState } from 'react'
+import { PermissionsLevel } from '@/features/ide-react/types/permissions'
+import { UnsavedDocsLockedModal } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-locked-modal'
+import { UnsavedDocsAlert } from '@/features/ide-react/components/unsaved-docs/unsaved-docs-alert'
+import useEventListener from '@/shared/hooks/use-event-listener'
+
+const MAX_UNSAVED_SECONDS = 15 // lock the editor after this time if unsaved
+
+export const UnsavedDocs: FC = () => {
+ const { openDocs } = useEditorManagerContext()
+ const { permissionsLevel, setPermissionsLevel } = useEditorContext()
+ const [isLocked, setIsLocked] = useState(false)
+ const [unsavedDocs, setUnsavedDocs] = useState(new Map())
+
+ // always contains the latest value
+ const previousUnsavedDocsRef = useRef(unsavedDocs)
+ useEffect(() => {
+ previousUnsavedDocsRef.current = unsavedDocs
+ }, [unsavedDocs])
+
+ // always contains the latest value
+ const permissionsLevelRef = useRef(permissionsLevel)
+ useEffect(() => {
+ permissionsLevelRef.current = permissionsLevel
+ }, [permissionsLevel])
+
+ // warn if the window is being closed with unsaved changes
+ useEventListener(
+ 'beforeunload',
+ useCallback(
+ event => {
+ if (openDocs.hasUnsavedChanges()) {
+ // https://developer.mozilla.org/en-US/docs/Web/API/Window/beforeunload_event
+ event.preventDefault()
+ }
+ },
+ [openDocs]
+ )
+ )
+
+ // keep track of which docs are currently unsaved, and how long they've been unsaved for
+ // NOTE: openDocs should never change, so it's safe to use as a dependency here
+ useEffect(() => {
+ const interval = window.setInterval(() => {
+ const unsavedDocs = new Map()
+
+ const unsavedDocIds = openDocs.unsavedDocIds()
+
+ for (const docId of unsavedDocIds) {
+ const unsavedSeconds =
+ (previousUnsavedDocsRef.current.get(docId) ?? 0) + 1
+ unsavedDocs.set(docId, unsavedSeconds)
+ }
+
+ // avoid setting the unsavedDocs state to a new empty Map every second
+ if (unsavedDocs.size > 0 || previousUnsavedDocsRef.current.size > 0) {
+ previousUnsavedDocsRef.current = unsavedDocs
+ setUnsavedDocs(unsavedDocs)
+ }
+ }, 1000)
+
+ return () => {
+ window.clearInterval(interval)
+ }
+ }, [openDocs])
+
+ const maxUnsavedSeconds = Math.max(0, ...unsavedDocs.values())
+
+ // lock the editor if at least one doc has been unsaved for too long
+ useEffect(() => {
+ setIsLocked(maxUnsavedSeconds > MAX_UNSAVED_SECONDS)
+ }, [maxUnsavedSeconds])
+
+ // display a modal and set the permissions level to readOnly if docs have been unsaved for too long
+ const originalPermissionsLevelRef = useRef(null)
+ useEffect(() => {
+ if (isLocked) {
+ originalPermissionsLevelRef.current = permissionsLevelRef.current
+ // TODO: what if the real permissions level changes in the meantime?
+ // TODO: perhaps the "locked" state should be stored in the editor context instead?
+ setPermissionsLevel('readOnly')
+ setIsLocked(true)
+ } else {
+ if (originalPermissionsLevelRef.current) {
+ setPermissionsLevel(originalPermissionsLevelRef.current)
+ }
+ }
+ }, [isLocked, setPermissionsLevel])
+
+ // remove the modal (and unlock the page) if the connection has been re-established and all the docs have been saved
+ useEffect(() => {
+ if (unsavedDocs.size === 0 && permissionsLevelRef.current === 'readOnly') {
+ setIsLocked(false)
+ }
+ }, [unsavedDocs])
+
+ return (
+ <>
+ {isLocked && }
+ {unsavedDocs.size > 0 && }
+ >
+ )
+}
diff --git a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
index c2b96473e4..ded2cfcdfc 100644
--- a/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/editor-manager-context.tsx
@@ -53,6 +53,7 @@ type EditorManager = {
stopIgnoringExternalUpdates: () => void
openDocId: (docId: string, options?: OpenDocOptions) => void
openDoc: (document: Doc, options?: OpenDocOptions) => void
+ openDocs: OpenDocuments
openInitialDoc: (docId: string) => void
jumpToLine: (options: GotoLineOptions) => void
}
@@ -594,6 +595,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
stopIgnoringExternalUpdates,
openDocId: openDocWithId,
openDoc,
+ openDocs,
openInitialDoc,
jumpToLine,
}),
@@ -608,6 +610,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
stopIgnoringExternalUpdates,
openDocWithId,
openDoc,
+ openDocs,
openInitialDoc,
jumpToLine,
]
diff --git a/services/web/frontend/js/features/ide-react/context/modals-context.tsx b/services/web/frontend/js/features/ide-react/context/modals-context.tsx
index 1e96be9c5c..b1291c4ed9 100644
--- a/services/web/frontend/js/features/ide-react/context/modals-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/modals-context.tsx
@@ -12,7 +12,6 @@ import GenericMessageModal, {
import OutOfSyncModal, {
OutOfSyncModalProps,
} from '@/features/ide-react/components/modals/out-of-sync-modal'
-import LockEditorMessageModal from '@/features/ide-react/components/modals/lock-editor-message-modal'
type ModalsContextValue = {
genericModalVisible: boolean
@@ -23,7 +22,6 @@ type ModalsContextValue = {
showOutOfSyncModal: (
editorContent: OutOfSyncModalProps['editorContent']
) => void
- showLockEditorMessageModal: (delay: number) => void
}
const ModalsContext = createContext(undefined)
@@ -39,12 +37,6 @@ export const ModalsContextProvider: FC = ({ children }) => {
editorContent: '',
})
- const [shouldShowLockEditorModal, setShouldShowLockEditorModal] =
- useState(false)
- const [lockEditorModalData, setLockEditorModalData] = useState({
- delay: 0,
- })
-
const handleHideGenericModal = useCallback(() => {
setShowGenericModal(false)
}, [])
@@ -69,24 +61,13 @@ export const ModalsContextProvider: FC = ({ children }) => {
setShouldShowOutOfSyncModal(true)
}, [])
- const showLockEditorMessageModal = useCallback((delay: number) => {
- setLockEditorModalData({ delay })
- setShouldShowLockEditorModal(true)
- }, [])
-
const value = useMemo(
() => ({
showGenericMessageModal,
genericModalVisible: showGenericModal,
showOutOfSyncModal,
- showLockEditorMessageModal,
}),
- [
- showGenericMessageModal,
- showGenericModal,
- showOutOfSyncModal,
- showLockEditorMessageModal,
- ]
+ [showGenericMessageModal, showGenericModal, showOutOfSyncModal]
)
return (
@@ -102,10 +83,6 @@ export const ModalsContextProvider: FC = ({ children }) => {
show={shouldShowOutOfSyncModal}
onHide={handleHideOutOfSyncModal}
/>
-
)
}
diff --git a/services/web/frontend/js/features/ide-react/editor/open-documents.ts b/services/web/frontend/js/features/ide-react/editor/open-documents.ts
index eef90b5d82..3efbbe514b 100644
--- a/services/web/frontend/js/features/ide-react/editor/open-documents.ts
+++ b/services/web/frontend/js/features/ide-react/editor/open-documents.ts
@@ -61,15 +61,28 @@ export class OpenDocuments {
})
}
- private docsArray() {
- return Array.from(this.openDocs.values())
- }
-
hasUnsavedChanges() {
- return this.docsArray().some(doc => doc.hasBufferedOps())
+ for (const doc of this.openDocs.values()) {
+ if (doc.hasBufferedOps()) {
+ return true
+ }
+ }
+ return false
}
flushAll() {
- return this.docsArray().map(doc => doc.flush())
+ for (const doc of this.openDocs.values()) {
+ doc.flush()
+ }
+ }
+
+ unsavedDocIds() {
+ const ids = []
+ for (const [docId, doc] of this.openDocs) {
+ if (!doc.pollSavedStatus()) {
+ ids.push(docId)
+ }
+ }
+ return ids
}
}
diff --git a/services/web/frontend/js/features/ide-react/hooks/use-connection-state.ts b/services/web/frontend/js/features/ide-react/hooks/use-connection-state.ts
deleted file mode 100644
index 386a919bdd..0000000000
--- a/services/web/frontend/js/features/ide-react/hooks/use-connection-state.ts
+++ /dev/null
@@ -1,19 +0,0 @@
-import { useEffect } from 'react'
-import { useConnectionContext } from '@/features/ide-react/context/connection-context'
-import { useModalsContext } from '@/features/ide-react/context/modals-context'
-
-export const useConnectionState = () => {
- const { connectionState } = useConnectionContext()
- const { showLockEditorMessageModal } = useModalsContext()
-
- // Show modal when editor is forcefully disconnected
- useEffect(() => {
- if (connectionState.forceDisconnected) {
- showLockEditorMessageModal(connectionState.forcedDisconnectDelay)
- }
- }, [
- connectionState.forceDisconnected,
- connectionState.forcedDisconnectDelay,
- showLockEditorMessageModal,
- ])
-}
diff --git a/services/web/frontend/js/features/project-list/components/project-list-root.tsx b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
index ac2561c0e1..0f70ed4b22 100644
--- a/services/web/frontend/js/features/project-list/components/project-list-root.tsx
+++ b/services/web/frontend/js/features/project-list/components/project-list-root.tsx
@@ -13,7 +13,7 @@ import ProjectListTable from './table/project-list-table'
import SurveyWidget from './survey-widget'
import WelcomeMessage from './welcome-message'
import LoadingBranded from '../../../shared/components/loading-branded'
-import SystemMessages from './notifications/system-messages'
+import SystemMessages from '../../../shared/components/system-messages'
import UserNotifications from './notifications/user-notifications'
import SearchForm from './search-form'
import ProjectsDropdown from './dropdown/projects-dropdown'
diff --git a/services/web/frontend/js/features/project-list/components/notifications/system-message.tsx b/services/web/frontend/js/shared/components/system-message.tsx
similarity index 77%
rename from services/web/frontend/js/features/project-list/components/notifications/system-message.tsx
rename to services/web/frontend/js/shared/components/system-message.tsx
index 5829ea23f3..8a59c42921 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/system-message.tsx
+++ b/services/web/frontend/js/shared/components/system-message.tsx
@@ -1,5 +1,5 @@
-import Close from '../../../../shared/components/close'
-import usePersistedState from '../../../../shared/hooks/use-persisted-state'
+import Close from './close'
+import usePersistedState from '../hooks/use-persisted-state'
type SystemMessageProps = {
id: string
diff --git a/services/web/frontend/js/features/project-list/components/notifications/system-messages.tsx b/services/web/frontend/js/shared/components/system-messages.tsx
similarity index 84%
rename from services/web/frontend/js/features/project-list/components/notifications/system-messages.tsx
rename to services/web/frontend/js/shared/components/system-messages.tsx
index 6133349862..8302d15a60 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/system-messages.tsx
+++ b/services/web/frontend/js/shared/components/system-messages.tsx
@@ -1,13 +1,13 @@
import { useEffect } from 'react'
import SystemMessage from './system-message'
import TranslationMessage from './translation-message'
-import useAsync from '../../../../shared/hooks/use-async'
-import { getJSON } from '../../../../infrastructure/fetch-json'
-import getMeta from '../../../../utils/meta'
+import useAsync from '../hooks/use-async'
+import { getJSON } from '@/infrastructure/fetch-json'
+import getMeta from '../../utils/meta'
import {
SystemMessage as TSystemMessage,
SuggestedLanguage,
-} from '../../../../../../types/project/dashboard/system-message'
+} from '../../../../types/system-message'
import { debugConsole } from '@/utils/debugging'
const MESSAGE_POLL_INTERVAL = 15 * 60 * 1000
diff --git a/services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx b/services/web/frontend/js/shared/components/translation-message.tsx
similarity index 80%
rename from services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx
rename to services/web/frontend/js/shared/components/translation-message.tsx
index 6aab72c809..d669f807db 100644
--- a/services/web/frontend/js/features/project-list/components/notifications/translation-message.tsx
+++ b/services/web/frontend/js/shared/components/translation-message.tsx
@@ -1,8 +1,8 @@
import { Trans, useTranslation } from 'react-i18next'
-import Close from '../../../../shared/components/close'
-import usePersistedState from '../../../../shared/hooks/use-persisted-state'
-import getMeta from '../../../../utils/meta'
-import { SuggestedLanguage } from '../../../../../../types/project/dashboard/system-message'
+import Close from './close'
+import usePersistedState from '../hooks/use-persisted-state'
+import getMeta from '../../utils/meta'
+import { SuggestedLanguage } from '../../../../types/system-message'
function TranslationMessage() {
const { t } = useTranslation()
diff --git a/services/web/frontend/js/shared/context/editor-context.jsx b/services/web/frontend/js/shared/context/editor-context.jsx
index 57f4892ca0..58c131a007 100644
--- a/services/web/frontend/js/shared/context/editor-context.jsx
+++ b/services/web/frontend/js/shared/context/editor-context.jsx
@@ -82,7 +82,8 @@ export function EditorProvider({ children }) {
const [loading] = useScopeValue('state.loading')
const [projectName, setProjectName] = useScopeValue('project.name')
- const [permissionsLevel] = useScopeValue('permissionsLevel')
+ const [permissionsLevel, setPermissionsLevel] =
+ useScopeValue('permissionsLevel')
const [showSymbolPalette] = useScopeValue('editor.showSymbolPalette')
const [toggleSymbolPalette] = useScopeValue('editor.toggleSymbolPalette')
@@ -164,6 +165,7 @@ export function EditorProvider({ children }) {
loading,
renameProject,
permissionsLevel,
+ setPermissionsLevel,
isProjectOwner: owner?._id === userId,
isRestrictedTokenMember: getMeta('ol-isRestrictedTokenMember'),
showSymbolPalette,
@@ -180,6 +182,7 @@ export function EditorProvider({ children }) {
loading,
renameProject,
permissionsLevel,
+ setPermissionsLevel,
showSymbolPalette,
toggleSymbolPalette,
insertSymbol,
diff --git a/services/web/frontend/stories/project-list/system-messages.stories.tsx b/services/web/frontend/stories/project-list/system-messages.stories.tsx
index 5280ef40b0..990f0a0856 100644
--- a/services/web/frontend/stories/project-list/system-messages.stories.tsx
+++ b/services/web/frontend/stories/project-list/system-messages.stories.tsx
@@ -1,4 +1,4 @@
-import SystemMessages from '../../js/features/project-list/components/notifications/system-messages'
+import SystemMessages from '@/shared/components/system-messages'
import useFetchMock from '../hooks/use-fetch-mock'
import { FetchMockStatic } from 'fetch-mock'
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index 851e7bfa56..706cb30f4b 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -305,6 +305,7 @@
"congratulations_youve_successfully_join_group": "Congratulations! You‘ve successfully joined the group subscription.",
"connected_users": "Connected Users",
"connecting": "Connecting",
+ "connection_lost": "Connection lost",
"contact": "Contact",
"contact_group_admin": "Please contact your group administrator.",
"contact_message_label": "Message",
@@ -1688,6 +1689,7 @@
"somthing_went_wrong_compiling": "Sorry, something went wrong and your project could not be compiled. Please try again in a few moments.",
"sorry_detected_sales_restricted_region": "Sorry, we’ve detected that you are in a region from which we cannot presently accept payments. If you think you’ve received this message in error, please contact us with details of your location, and we will look into this for you. We apologize for the inconvenience.",
"sorry_something_went_wrong_opening_the_document_please_try_again": "Sorry, an unexpected error occurred when trying to open this content on Overleaf. Please try again.",
+ "sorry_the_connection_to_the_server_is_down": "Sorry, the connection to the server is down.",
"sorry_your_table_cant_be_displayed_at_the_moment": "Sorry, your table can’t be displayed at the moment.",
"sorry_your_token_expired": "Sorry, your token expired",
"sort_by": "Sort by",
diff --git a/services/web/test/frontend/features/project-list/components/system-messages.test.tsx b/services/web/test/frontend/features/project-list/components/system-messages.test.tsx
index 73cb8667b7..52c7945474 100644
--- a/services/web/test/frontend/features/project-list/components/system-messages.test.tsx
+++ b/services/web/test/frontend/features/project-list/components/system-messages.test.tsx
@@ -1,7 +1,7 @@
import { expect } from 'chai'
import { render, screen, fireEvent } from '@testing-library/react'
import fetchMock from 'fetch-mock'
-import SystemMessages from '../../../../../frontend/js/features/project-list/components/notifications/system-messages'
+import SystemMessages from '@/shared/components/system-messages'
describe('', function () {
beforeEach(function () {
diff --git a/services/web/types/project/dashboard/system-message.ts b/services/web/types/system-message.ts
similarity index 100%
rename from services/web/types/project/dashboard/system-message.ts
rename to services/web/types/system-message.ts