diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json
index 965be282ba..948258b0f1 100644
--- a/services/web/frontend/extracted-translations.json
+++ b/services/web/frontend/extracted-translations.json
@@ -466,6 +466,7 @@
"helps_us_tailor_your_experience": "",
"hide_configuration": "",
"hide_document_preamble": "",
+ "hide_local_file_contents": "",
"hide_outline": "",
"history": "",
"history_add_label": "",
@@ -790,6 +791,8 @@
"organize_projects": "",
"other_logs_and_files": "",
"other_output_files": "",
+ "out_of_sync": "",
+ "out_of_sync_detail": "",
"output_file": "",
"overall_theme": "",
"overleaf": "",
@@ -843,6 +846,7 @@
"please_select_a_project": "",
"please_select_an_output_file": "",
"please_set_main_file": "",
+ "please_wait": "",
"plus_additional_collaborators_document_history_track_changes_and_more": "",
"plus_more": "",
"plus_upgraded_accounts_receive": "",
@@ -875,6 +879,8 @@
"project_not_linked_to_github": "",
"project_ownership_transfer_confirmation_1": "",
"project_ownership_transfer_confirmation_2": "",
+ "project_renamed_or_deleted": "",
+ "project_renamed_or_deleted_detail": "",
"project_synced_with_git_repo_at": "",
"project_synchronisation": "",
"project_timed_out_enable_stop_on_first_error": "",
@@ -936,6 +942,7 @@
"reject": "",
"reject_all": "",
"relink_your_account": "",
+ "reload_editor": "",
"remote_service_error": "",
"remove": "",
"remove_collaborator": "",
@@ -1068,6 +1075,7 @@
"show_in_code": "",
"show_in_pdf": "",
"show_less": "",
+ "show_local_file_contents": "",
"show_outline": "",
"show_x_more": "",
"show_x_more_projects": "",
@@ -1362,6 +1370,7 @@
"welcome_to_sl": "",
"were_in_the_process_of_reducing_compile_timeout_which_may_affect_this_project": "",
"were_in_the_process_of_reducing_compile_timeout_which_may_affect_your_project": "",
+ "were_performing_maintenance": "",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "",
"what_does_this_mean": "",
diff --git a/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx b/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx
index c0fb7394ae..c4ed9d8f23 100644
--- a/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx
+++ b/services/web/frontend/js/features/ide-react/components/alerts/alerts.tsx
@@ -21,7 +21,7 @@ export function Alerts() {
return (
{connectionState.forceDisconnected ? (
-
+
{t('disconnected')}
) : null}
diff --git a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
index d739d37046..37139c2ef8 100644
--- a/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
+++ b/services/web/frontend/js/features/ide-react/components/layout/ide-page.tsx
@@ -10,6 +10,7 @@ import EditorNavigationToolbar from '@/features/ide-react/components/editor-navi
import ChatPane from '@/features/chat/components/chat-pane'
import { useLayoutEventTracking } from '@/features/ide-react/hooks/use-layout-event-tracking'
import useSocketListeners from '@/features/ide-react/hooks/use-socket-listeners'
+import { useModalsContext } from '@/features/ide-react/context/modals-context'
import { useOpenFile } from '@/features/ide-react/hooks/use-open-file'
// This is filled with placeholder content while the real content is migrated
@@ -25,7 +26,19 @@ export default function IdePage() {
useOpenFile()
const [leftColumnDefaultSize, setLeftColumnDefaultSize] = useState(20)
- const { registerUserActivity } = useConnectionContext()
+ const { connectionState, registerUserActivity } = useConnectionContext()
+ const { showLockEditorMessageModal } = useModalsContext()
+
+ // Show modal when editor is forcefully disconnected
+ useEffect(() => {
+ if (connectionState.forceDisconnected) {
+ showLockEditorMessageModal(connectionState.forcedDisconnectDelay)
+ }
+ }, [
+ connectionState.forceDisconnected,
+ connectionState.forcedDisconnectDelay,
+ showLockEditorMessageModal,
+ ])
// Inform the connection manager when the user is active
const listener = useCallback(
diff --git a/services/web/frontend/js/features/ide-react/components/modals/lock-editor-message-modal.tsx b/services/web/frontend/js/features/ide-react/components/modals/lock-editor-message-modal.tsx
new file mode 100644
index 0000000000..6dbee9fe72
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/modals/lock-editor-message-modal.tsx
@@ -0,0 +1,48 @@
+import { useTranslation } from 'react-i18next'
+import { Modal } from 'react-bootstrap'
+import AccessibleModal from '@/shared/components/accessible-modal'
+import { useEffect, useState } from 'react'
+
+export type LockEditorMessageModalProps = {
+ delay: number // In seconds
+ show: boolean
+}
+
+function LockEditorMessageModal({ delay, show }: LockEditorMessageModalProps) {
+ const { t } = useTranslation()
+ const [secondsUntilRefresh, setSecondsUntilRefresh] = useState(0)
+
+ useEffect(() => {
+ if (show) {
+ setSecondsUntilRefresh(delay)
+
+ const timer = window.setInterval(() => {
+ setSecondsUntilRefresh(seconds => Math.max(0, seconds - 1))
+ }, 1000)
+
+ return () => {
+ window.clearInterval(timer)
+ }
+ }
+ }, [show, delay])
+
+ return (
+ {}}
+ className="lock-editor-modal"
+ backdrop={false}
+ keyboard={false}
+ >
+
+ {t('please_wait')}
+
+
+ {t('were_performing_maintenance', { seconds: secondsUntilRefresh })}
+
+
+ )
+}
+
+export default LockEditorMessageModal
diff --git a/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx b/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx
new file mode 100644
index 0000000000..f009df1c86
--- /dev/null
+++ b/services/web/frontend/js/features/ide-react/components/modals/out-of-sync-modal.tsx
@@ -0,0 +1,81 @@
+import { Trans, useTranslation } from 'react-i18next'
+import { Button, Modal } from 'react-bootstrap'
+import AccessibleModal from '@/shared/components/accessible-modal'
+import { useState } from 'react'
+import { useLocation } from '@/shared/hooks/use-location'
+
+export type OutOfSyncModalProps = {
+ editorContent: string
+ show: boolean
+ onHide: () => void
+}
+
+function OutOfSyncModal({ editorContent, show, onHide }: OutOfSyncModalProps) {
+ const { t } = useTranslation()
+ const location = useLocation()
+ const [editorContentShown, setEditorContentShown] = useState(false)
+ const editorContentRows = (editorContent.match(/\n/g)?.length || 0) + 1
+
+ // Reload the page to avoid staying in an inconsistent state.
+ // https://github.com/overleaf/issues/issues/3694
+ function done() {
+ onHide()
+ location.reload()
+ }
+
+ return (
+
+
+ {t('out_of_sync')}
+
+
+ ,
+ // eslint-disable-next-line jsx-a11y/anchor-has-content,react/jsx-key
+ ,
+ ]}
+ />
+
+
+
+ {editorContentShown ? (
+
+
+
+ ) : null}
+
+
+
+
+
+ )
+}
+
+export default OutOfSyncModal
diff --git a/services/web/frontend/js/features/ide-react/connection/connection-manager.ts b/services/web/frontend/js/features/ide-react/connection/connection-manager.ts
index 80bdd3ef5b..d0f4c4f883 100644
--- a/services/web/frontend/js/features/ide-react/connection/connection-manager.ts
+++ b/services/web/frontend/js/features/ide-react/connection/connection-manager.ts
@@ -24,6 +24,7 @@ const initialState: ConnectionState = {
inactiveDisconnect: false,
lastConnectionAttempt: 0,
reconnectAt: null,
+ forcedDisconnectDelay: 0,
error: '',
}
@@ -181,9 +182,10 @@ export class ConnectionManager extends Emitter {
this.changeState({
...this.state,
forceDisconnected: true,
+ forcedDisconnectDelay: delay,
error,
})
- setTimeout(() => this.disconnect(), delay * 1000)
+ setTimeout(() => this.disconnect(), 1000)
}
private onJoinProjectResponse({
diff --git a/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts b/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts
index f9d2e1f853..fc0501a3a1 100644
--- a/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts
+++ b/services/web/frontend/js/features/ide-react/connection/types/connection-state.ts
@@ -13,6 +13,7 @@ export type ConnectionState = {
forceDisconnected: boolean
inactiveDisconnect: boolean
reconnectAt: number | null
+ forcedDisconnectDelay: number
lastConnectionAttempt: number
error: '' | ConnectionError
}
diff --git a/services/web/frontend/js/features/ide-react/context/connection-context.tsx b/services/web/frontend/js/features/ide-react/context/connection-context.tsx
index 8ad7c25396..7326e97c79 100644
--- a/services/web/frontend/js/features/ide-react/context/connection-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/connection-context.tsx
@@ -11,6 +11,7 @@ import { ConnectionState } from '../connection/types/connection-state'
import { ConnectionManager } from '@/features/ide-react/connection/connection-manager'
import { Socket } from '@/features/ide-react/connection/types/socket'
import { secondsUntil } from '@/features/ide-react/connection/utils'
+import { useLocation } from '@/shared/hooks/use-location'
type ConnectionContextValue = {
socket: Socket
@@ -28,6 +29,8 @@ const ConnectionContext = createContext(
)
export const ConnectionProvider: FC = ({ children }) => {
+ const location = useLocation()
+
const [connectionManager] = useState(() => new ConnectionManager())
const [connectionState, setConnectionState] = useState(
connectionManager.state
@@ -69,6 +72,24 @@ export const ConnectionProvider: FC = ({ children }) => {
connectionManager.disconnect()
}, [connectionManager])
+ // Reload the page on force disconnect. Doing this in React-land means that we
+ // can use useLocation(), which provides mockable location methods
+ useEffect(() => {
+ if (connectionState.forceDisconnected) {
+ const timer = window.setTimeout(
+ () => location.reload(),
+ connectionState.forcedDisconnectDelay * 1000
+ )
+ return () => {
+ window.clearTimeout(timer)
+ }
+ }
+ }, [
+ connectionState.forceDisconnected,
+ connectionState.forcedDisconnectDelay,
+ location,
+ ])
+
const value = useMemo(
() => ({
socket: connectionManager.socket,
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 d17130c7b9..8426f2e007 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
@@ -115,7 +115,8 @@ export const EditorManagerProvider: FC = ({ children }) => {
const { reportError, eventEmitter, eventLog } = useIdeReactContext()
const { socket, disconnect } = useConnectionContext()
const { view, setView } = useLayoutContext()
- const { showGenericMessageModal, genericModalVisible } = useModalsContext()
+ const { showGenericMessageModal, genericModalVisible, showOutOfSyncModal } =
+ useModalsContext()
const [showSymbolPalette, setShowSymbolPalette] = useScopeValue(
'editor.showSymbolPalette'
@@ -550,13 +551,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
// Tell the user about the error state.
setIsInErrorState(true)
-
- // TODO: MIGRATION: Show out-of-sync modal
- // this.ide.showOutOfSyncModal(
- // 'Out of sync',
- // "Sorry, this file has gone out of sync and we need to do a full refresh.
Please see this help guide for more information",
- // editorContent
- // )
+ showOutOfSyncModal(editorContent || '')
// Do not forceReopen the document.
return
@@ -581,6 +576,7 @@ export const EditorManagerProvider: FC = ({ children }) => {
reportError,
setIsInErrorState,
showGenericMessageModal,
+ showOutOfSyncModal,
t,
])
diff --git a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
index 2f464e703a..d4a9e66ab5 100644
--- a/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/ide-react-context.tsx
@@ -104,9 +104,9 @@ export const IdeReactProvider: FC = ({ children }) => {
user_id: getMeta('ol-user_id'),
project_id: projectId,
// @ts-ignore
- client_id: socket.socket.sessionid,
+ client_id: socket.socket?.sessionid,
// @ts-ignore
- transport: socket.socket.transport,
+ transport: socket.socket?.transport?.name,
client_now: new Date(),
}
diff --git a/services/web/frontend/js/features/ide-react/context/metadata-context.tsx b/services/web/frontend/js/features/ide-react/context/metadata-context.tsx
index bc6ff8f4ce..a3d3bca3b8 100644
--- a/services/web/frontend/js/features/ide-react/context/metadata-context.tsx
+++ b/services/web/frontend/js/features/ide-react/context/metadata-context.tsx
@@ -20,6 +20,8 @@ import useSocketListener from '@/features/ide-react/hooks/use-socket-listener'
import useEventListener from '@/shared/hooks/use-event-listener'
import { FileTreeFindResult } from '@/features/ide-react/types/file-tree'
import { Project } from '../../../../../types/project'
+import { useModalsContext } from '@/features/ide-react/context/modals-context'
+import { useTranslation } from 'react-i18next'
type DocumentMetadata = {
labels: string[]
@@ -45,12 +47,14 @@ const MetadataContext = createContext(
)
export const MetadataProvider: FC = ({ children }) => {
+ const { t } = useTranslation()
const ide = useIdeContext()
const { eventEmitter, projectId } = useIdeReactContext()
const { socket } = useConnectionContext()
const { onlineUsersCount } = useOnlineUsersContext()
const { permissionsLevel } = useEditorContext()
const { currentDocument } = useEditorManagerContext()
+ const { showGenericMessageModal } = useModalsContext()
const [documents, setDocuments] = useState({})
@@ -180,17 +184,10 @@ export const MetadataProvider: FC = ({ children }) => {
useEffect(() => {
const handleProjectJoined = ({ project }: { project: Project }) => {
if (project.deletedByExternalDataSource) {
- // TODO: MIGRATION: Show generic message modal here
- /*
- ide.showGenericMessageModal(
- 'Project Renamed or Deleted',
- `\
-This project has either been renamed or deleted by an external data source such as Dropbox.
-We don't want to delete your data on Overleaf, so this project still contains your history and collaborators.
-If the project has been renamed please look in your project list for a new project under the new name.\
-`
+ showGenericMessageModal(
+ t('project_renamed_or_deleted'),
+ t('project_renamed_or_deleted_detail')
)
-*/
}
window.setTimeout(() => {
if (permissionsLevel !== 'readOnly') {
@@ -204,7 +201,13 @@ If the project has been renamed please look in your project list for a new proje
return () => {
eventEmitter.off('project:joined', handleProjectJoined)
}
- }, [eventEmitter, loadProjectMetaFromServer, permissionsLevel])
+ }, [
+ eventEmitter,
+ loadProjectMetaFromServer,
+ permissionsLevel,
+ showGenericMessageModal,
+ t,
+ ])
const value = useMemo(
() => ({
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 0aad78be9a..1e96be9c5c 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
@@ -9,6 +9,10 @@ import {
import GenericMessageModal, {
GenericMessageModalOwnProps,
} from '@/features/ide-react/components/modals/generic-message-modal'
+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
@@ -16,6 +20,10 @@ type ModalsContextValue = {
title: GenericMessageModalOwnProps['title'],
message: GenericMessageModalOwnProps['message']
) => void
+ showOutOfSyncModal: (
+ editorContent: OutOfSyncModalProps['editorContent']
+ ) => void
+ showLockEditorMessageModal: (delay: number) => void
}
const ModalsContext = createContext(undefined)
@@ -25,6 +33,18 @@ export const ModalsContextProvider: FC = ({ children }) => {
const [genericMessageModalData, setGenericMessageModalData] =
useState({ title: '', message: '' })
+ const [shouldShowOutOfSyncModal, setShouldShowOutOfSyncModal] =
+ useState(false)
+ const [outOfSyncModalData, setOutOfSyncModalData] = useState({
+ editorContent: '',
+ })
+
+ const [shouldShowLockEditorModal, setShouldShowLockEditorModal] =
+ useState(false)
+ const [lockEditorModalData, setLockEditorModalData] = useState({
+ delay: 0,
+ })
+
const handleHideGenericModal = useCallback(() => {
setShowGenericModal(false)
}, [])
@@ -40,12 +60,33 @@ export const ModalsContextProvider: FC = ({ children }) => {
[]
)
+ const handleHideOutOfSyncModal = useCallback(() => {
+ setShouldShowOutOfSyncModal(false)
+ }, [])
+
+ const showOutOfSyncModal = useCallback((editorContent: string) => {
+ setOutOfSyncModalData({ editorContent })
+ setShouldShowOutOfSyncModal(true)
+ }, [])
+
+ const showLockEditorMessageModal = useCallback((delay: number) => {
+ setLockEditorModalData({ delay })
+ setShouldShowLockEditorModal(true)
+ }, [])
+
const value = useMemo(
() => ({
showGenericMessageModal,
genericModalVisible: showGenericModal,
+ showOutOfSyncModal,
+ showLockEditorMessageModal,
}),
- [showGenericMessageModal, showGenericModal]
+ [
+ showGenericMessageModal,
+ showGenericModal,
+ showOutOfSyncModal,
+ showLockEditorMessageModal,
+ ]
)
return (
@@ -56,6 +97,15 @@ export const ModalsContextProvider: FC = ({ children }) => {
onHide={handleHideGenericModal}
{...genericMessageModalData}
/>
+
+
)
}
diff --git a/services/web/locales/en.json b/services/web/locales/en.json
index fba44c4b5c..a2cce8b691 100644
--- a/services/web/locales/en.json
+++ b/services/web/locales/en.json
@@ -740,6 +740,7 @@
"helps_us_tailor_your_experience": "This helps us tailor your Overleaf experience.",
"hide_configuration": "Hide configuration",
"hide_document_preamble": "Hide document preamble",
+ "hide_local_file_contents": "Hide Local File Contents",
"hide_outline": "Hide File outline",
"history": "History",
"history_add_label": "Add label",
@@ -1227,6 +1228,8 @@
"other_output_files": "Download other output files",
"other_sessions": "Other Sessions",
"our_values": "Our values",
+ "out_of_sync": "Out of sync",
+ "out_of_sync_detail": "Sorry, this file has gone out of sync and we need to do a full refresh.<0 /><1>Please see this help guide for more information1>",
"output_file": "Output file",
"over": "over",
"over_x_templates_easy_getting_started": "There are thousands of __templates__ in our template gallery, so it’s really easy to get started, whether you’re writing a journal article, thesis, CV or something else.",
@@ -1309,6 +1312,7 @@
"please_select_an_output_file": "Please Select an Output File",
"please_set_a_password": "Please set a password",
"please_set_main_file": "Please choose the main file for this project in the project menu. ",
+ "please_wait": "Please wait",
"plus_additional_collaborators_document_history_track_changes_and_more": "(plus additional collaborators, document history, track changes, and more).",
"plus_more": "plus more",
"plus_upgraded_accounts_receive": "Plus with an upgraded account you get",
@@ -1360,6 +1364,8 @@
"project_owner_plus_10": "Project author + 10",
"project_ownership_transfer_confirmation_1": "Are you sure you want to make <0>__user__0> the owner of <1>__project__1>?",
"project_ownership_transfer_confirmation_2": "This action cannot be undone. The new owner will be notified and will be able to change project access settings (including removing your own access).",
+ "project_renamed_or_deleted": "Project Renamed or Deleted",
+ "project_renamed_or_deleted_detail": "This project has either been renamed or deleted by an external data source such as Dropbox. We don’t want to delete your data on Overleaf, so this project still contains your history and collaborators. If the project has been renamed please look in your project list for a new project under the new name.",
"project_synced_with_git_repo_at": "This project is synced with the GitHub repository at",
"project_synchronisation": "Project Synchronisation",
"project_timed_out_enable_stop_on_first_error": "<0>Enable “Stop on first error”0> to help you find and fix errors right away.",
@@ -1625,6 +1631,7 @@
"show_in_code": "Show in code",
"show_in_pdf": "Show in PDF",
"show_less": "show less",
+ "show_local_file_contents": "Show Local File Contents",
"show_outline": "Show File outline",
"show_x_more": "Show __x__ more",
"show_x_more_projects": "Show __x__ more projects",
@@ -2027,6 +2034,7 @@
"welcome_to_sl": "Welcome to __appName__!",
"were_in_the_process_of_reducing_compile_timeout_which_may_affect_this_project": "We’re in the process of <0>reducing the compile timeout limit0> on our free plan, which may affect this project in future.",
"were_in_the_process_of_reducing_compile_timeout_which_may_affect_your_project": "We’re in the process of <0>reducing the compile timeout limit0> on our free plan, which may affect your project in future.",
+ "were_performing_maintenance": "We’re performing maintenance on Overleaf and you need to wait a moment. Sorry for any inconvenience. The editor will refresh automatically in __seconds__ seconds.",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_this_project": "We’ve recently <0>reduced the compile timeout limit0> on our free plan, which may have affected this project.",
"weve_recently_reduced_the_compile_timeout_limit_which_may_have_affected_your_project": "We’ve recently <0>reduced the compile timeout limit0> on our free plan, which may have affected your project.",
"what_does_this_mean": "What does this mean?",