Przeglądaj źródła

Merge pull request #29821 from overleaf/dp-editor-redesign-opt-out

Prepare editor redesign for opt-out phase

GitOrigin-RevId: 5831970ff27e5c20f22c68b83471b6b832c2b2b4
David 8 miesięcy temu
rodzic
commit
5982eed3fa

+ 1 - 0
services/web/app/src/Features/Project/ProjectController.mjs

@@ -458,6 +458,7 @@ const _ProjectController = {
       'writefull-figure-generator',
       'writefull-figure-generator',
       'writefull-asymetric-queue-size-per-model',
       'writefull-asymetric-queue-size-per-model',
       'pdf-dark-mode',
       'pdf-dark-mode',
+      'editor-redesign-opt-out',
     ].filter(Boolean)
     ].filter(Boolean)
 
 
     const getUserValues = async userId =>
     const getUserValues = async userId =>

+ 21 - 3
services/web/app/src/Features/Project/UserSettingsHelper.mjs

@@ -4,7 +4,7 @@ import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
 const SPLIT_TEST_USER_CUTOFF_DATE = new Date(Date.UTC(2025, 8, 23, 13, 0, 0)) // 2pm British Summer Time on September 23, 2025
 const SPLIT_TEST_USER_CUTOFF_DATE = new Date(Date.UTC(2025, 8, 23, 13, 0, 0)) // 2pm British Summer Time on September 23, 2025
 const NEW_USER_CUTOFF_DATE = new Date(Date.UTC(2025, 10, 12, 12, 0, 0)) // 12pm GMT on November 12, 2025
 const NEW_USER_CUTOFF_DATE = new Date(Date.UTC(2025, 10, 12, 12, 0, 0)) // 12pm GMT on November 12, 2025
 
 
-async function getEnableNewEditorDefault(req, res, user) {
+async function getEnableNewEditorLegacyDefault(req, res, user) {
   if (req.query['existing-user-override'] === 'true') {
   if (req.query['existing-user-override'] === 'true') {
     return false
     return false
   }
   }
@@ -31,7 +31,22 @@ async function getEnableNewEditorDefault(req, res, user) {
 }
 }
 
 
 async function buildUserSettings(req, res, user) {
 async function buildUserSettings(req, res, user) {
-  const defaultEnableNewEditor = await getEnableNewEditorDefault(req, res, user)
+  const defaultLegacyEnableNewEditor = await getEnableNewEditorLegacyDefault(
+    req,
+    res,
+    user
+  )
+
+  const enableNewEditorStageFour = user.ace.enableNewEditorStageFour ?? true
+  const enableNewEditorLegacy =
+    user.ace.enableNewEditor ?? defaultLegacyEnableNewEditor
+
+  const assignment = await SplitTestHandler.promises.getAssignment(
+    req,
+    res,
+    'editor-redesign-opt-out'
+  )
+  const isOptOutEnabled = assignment.variant === 'enabled'
 
 
   return {
   return {
     mode: user.ace.mode,
     mode: user.ace.mode,
@@ -47,7 +62,10 @@ async function buildUserSettings(req, res, user) {
     mathPreview: user.ace.mathPreview,
     mathPreview: user.ace.mathPreview,
     breadcrumbs: user.ace.breadcrumbs,
     breadcrumbs: user.ace.breadcrumbs,
     referencesSearchMode: user.ace.referencesSearchMode,
     referencesSearchMode: user.ace.referencesSearchMode,
-    enableNewEditor: user.ace.enableNewEditor ?? defaultEnableNewEditor,
+    enableNewEditor: isOptOutEnabled
+      ? enableNewEditorStageFour
+      : enableNewEditorLegacy,
+    enableNewEditorLegacy,
     darkModePdf: user.ace.darkModePdf ?? false,
     darkModePdf: user.ace.darkModePdf ?? false,
   }
   }
 }
 }

+ 2 - 0
services/web/app/src/Features/Tutorial/TutorialController.mjs

@@ -26,6 +26,8 @@ const VALID_KEYS = [
   'groups-enterprise-banner-repeat',
   'groups-enterprise-banner-repeat',
   'new-editor-opt-in',
   'new-editor-opt-in',
   'new-editor-intro',
   'new-editor-intro',
+  'new-editor-intro-2',
+  'old-editor-warning-tooltip',
 ]
 ]
 
 
 async function completeTutorial(req, res, next) {
 async function completeTutorial(req, res, next) {

+ 13 - 1
services/web/app/src/Features/User/UserController.mjs

@@ -22,6 +22,7 @@ import { expressify } from '@overleaf/promise-utils'
 import { acceptsJson } from '../../infrastructure/RequestContentTypeDetection.mjs'
 import { acceptsJson } from '../../infrastructure/RequestContentTypeDetection.mjs'
 import Modules from '../../infrastructure/Modules.mjs'
 import Modules from '../../infrastructure/Modules.mjs'
 import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.mjs'
 import OneTimeTokenHandler from '../Security/OneTimeTokenHandler.mjs'
+import SplitTestHandler from '../SplitTests/SplitTestHandler.mjs'
 
 
 async function _sendSecurityAlertClearedSessions(user) {
 async function _sendSecurityAlertClearedSessions(user) {
   const emailOptions = {
   const emailOptions = {
@@ -405,7 +406,18 @@ async function updateUserSettings(req, res, next) {
     user.ace.referencesSearchMode = mode
     user.ace.referencesSearchMode = mode
   }
   }
   if (body.enableNewEditor != null) {
   if (body.enableNewEditor != null) {
-    user.ace.enableNewEditor = Boolean(body.enableNewEditor)
+    const assignment = await SplitTestHandler.promises.getAssignment(
+      req,
+      res,
+      'editor-redesign-opt-out'
+    )
+    const isOptOutStageEnabled = assignment.variant === 'enabled'
+
+    if (isOptOutStageEnabled) {
+      user.ace.enableNewEditorStageFour = Boolean(body.enableNewEditor)
+    } else {
+      user.ace.enableNewEditor = Boolean(body.enableNewEditor)
+    }
   }
   }
   if (body.darkModePdf != null) {
   if (body.darkModePdf != null) {
     user.ace.darkModePdf = Boolean(body.darkModePdf)
     user.ace.darkModePdf = Boolean(body.darkModePdf)

+ 4 - 0
services/web/app/src/models/User.mjs

@@ -100,7 +100,11 @@ export const UserSchema = new Schema(
       mathPreview: { type: Boolean, default: true },
       mathPreview: { type: Boolean, default: true },
       breadcrumbs: { type: Boolean, default: true },
       breadcrumbs: { type: Boolean, default: true },
       referencesSearchMode: { type: String, default: 'advanced' }, // 'advanced' or 'simple'
       referencesSearchMode: { type: String, default: 'advanced' }, // 'advanced' or 'simple'
+      // enableNewEditor is being phased out in favor of enableNewEditorStageFour
+      // when moving the new editor to opt out (stage 4). However, we need to keep the
+      // old field for determining whether to show promotional material to users.
       enableNewEditor: { type: Boolean },
       enableNewEditor: { type: Boolean },
+      enableNewEditorStageFour: { type: Boolean },
       darkModePdf: { type: Boolean, default: false },
       darkModePdf: { type: Boolean, default: false },
     },
     },
     features: {
     features: {

+ 5 - 0
services/web/frontend/extracted-translations.json

@@ -1777,6 +1777,7 @@
   "suggested_fix_for_error_in_path": "",
   "suggested_fix_for_error_in_path": "",
   "suggestion_applied": "",
   "suggestion_applied": "",
   "suggests_code_completions_while_typing": "",
   "suggests_code_completions_while_typing": "",
+  "support_for_the_old_editor_is_ending_soon": "",
   "support_for_your_browser_is_ending_soon": "",
   "support_for_your_browser_is_ending_soon": "",
   "supports_up_to_x_licenses": "",
   "supports_up_to_x_licenses": "",
   "sure_you_want_to_cancel_plan_change": "",
   "sure_you_want_to_cancel_plan_change": "",
@@ -1787,6 +1788,8 @@
   "switch_compile_mode_for_faster_draft_compilation": "",
   "switch_compile_mode_for_faster_draft_compilation": "",
   "switch_easily_between_your_files_comments_track_changes_and_more": "",
   "switch_easily_between_your_files_comments_track_changes_and_more": "",
   "switch_to_editor": "",
   "switch_to_editor": "",
+  "switch_to_new_editor_design": "",
+  "switch_to_new_look": "",
   "switch_to_pdf": "",
   "switch_to_pdf": "",
   "switch_to_personal_email_to_keep_your_accounts_separate": "",
   "switch_to_personal_email_to_keep_your_accounts_separate": "",
   "switch_to_standard_plan": "",
   "switch_to_standard_plan": "",
@@ -1844,6 +1847,7 @@
   "the_following_folder_already_exists_in_this_project": "",
   "the_following_folder_already_exists_in_this_project": "",
   "the_following_folder_already_exists_in_this_project_plural": "",
   "the_following_folder_already_exists_in_this_project_plural": "",
   "the_latex_engine_used_for_compiling": "",
   "the_latex_engine_used_for_compiling": "",
+  "the_new_and_improved_overleaf_editor_design": "",
   "the_new_overleaf_editor_info": "",
   "the_new_overleaf_editor_info": "",
   "the_next_payment_will_be_collected_on": "",
   "the_next_payment_will_be_collected_on": "",
   "the_original_text_has_changed": "",
   "the_original_text_has_changed": "",
@@ -2156,6 +2160,7 @@
   "we_do_not_share_personal_information": "",
   "we_do_not_share_personal_information": "",
   "we_got_your_request": "",
   "we_got_your_request": "",
   "we_logged_you_in": "",
   "we_logged_you_in": "",
+  "we_recommend_switching_to_the_new_editor_design_now_so_you_have_time_to_get_to_know_it": "",
   "we_sent_code": "",
   "we_sent_code": "",
   "we_sent_new_code": "",
   "we_sent_new_code": "",
   "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "",
   "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "",

+ 15 - 2
services/web/frontend/js/features/editor-navigation-toolbar/try-new-editor-button.tsx

@@ -1,14 +1,23 @@
-import { useCallback } from 'react'
+import { useCallback, useState } from 'react'
 import OLButton from '../../shared/components/ol/ol-button'
 import OLButton from '../../shared/components/ol/ol-button'
 import { useTranslation } from 'react-i18next'
 import { useTranslation } from 'react-i18next'
 import { useSwitchEnableNewEditorState } from '../ide-redesign/hooks/use-switch-enable-new-editor-state'
 import { useSwitchEnableNewEditorState } from '../ide-redesign/hooks/use-switch-enable-new-editor-state'
 import MaterialIcon from '@/shared/components/material-icon'
 import MaterialIcon from '@/shared/components/material-icon'
 import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
 import { useEditorAnalytics } from '@/shared/hooks/use-editor-analytics'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
+import OldEditorWarningTooltip from '../ide-redesign/components/old-editor-warning-tooltip'
 
 
 const TryNewEditorButton = () => {
 const TryNewEditorButton = () => {
   const { t } = useTranslation()
   const { t } = useTranslation()
   const { loading, setEditorRedesignStatus } = useSwitchEnableNewEditorState()
   const { loading, setEditorRedesignStatus } = useSwitchEnableNewEditorState()
   const { sendEvent } = useEditorAnalytics()
   const { sendEvent } = useEditorAnalytics()
+  const isNewEditorOptOutStage = useFeatureFlag('editor-redesign-opt-out')
+  const [buttonElt, setButtonElt] = useState<HTMLButtonElement | null>(null)
+  const buttonRef = useCallback((node: HTMLButtonElement) => {
+    if (node !== null) {
+      setButtonElt(node)
+    }
+  }, [])
 
 
   const onClick = useCallback(() => {
   const onClick = useCallback(() => {
     sendEvent('switch-to-new-editor', {
     sendEvent('switch-to-new-editor', {
@@ -25,10 +34,14 @@ const TryNewEditorButton = () => {
         size="sm"
         size="sm"
         variant="secondary"
         variant="secondary"
         isLoading={loading}
         isLoading={loading}
+        ref={buttonRef}
       >
       >
         <MaterialIcon type="fiber_new" />
         <MaterialIcon type="fiber_new" />
-        {t('try_the_new_editor_design')}
+        {isNewEditorOptOutStage
+          ? t('switch_to_new_look')
+          : t('try_the_new_editor_design')}
       </OLButton>
       </OLButton>
+      {isNewEditorOptOutStage && <OldEditorWarningTooltip target={buttonElt} />}
     </div>
     </div>
   )
   )
 }
 }

+ 12 - 2
services/web/frontend/js/features/ide-react/components/modals/modals.tsx

@@ -4,15 +4,25 @@ import { UnsavedDocs } from '@/features/ide-react/components/unsaved-docs/unsave
 import SystemMessages from '@/shared/components/system-messages'
 import SystemMessages from '@/shared/components/system-messages'
 import NewEditorPromoModal from '@/features/ide-redesign/components/new-editor-promo-modal'
 import NewEditorPromoModal from '@/features/ide-redesign/components/new-editor-promo-modal'
 import NewEditorIntroModal from '@/features/ide-redesign/components/new-editor-intro-modal'
 import NewEditorIntroModal from '@/features/ide-redesign/components/new-editor-intro-modal'
+import NewEditorOptOutIntroModal from '@/features/ide-redesign/components/new-editor-opt-out-intro-modal'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 
 
 export const Modals = memo(() => {
 export const Modals = memo(() => {
+  const isNewEditorOptOutStage = useFeatureFlag('editor-redesign-opt-out')
+
   return (
   return (
     <>
     <>
       <ForceDisconnected />
       <ForceDisconnected />
       <UnsavedDocs />
       <UnsavedDocs />
       <SystemMessages />
       <SystemMessages />
-      <NewEditorPromoModal />
-      <NewEditorIntroModal />
+      {isNewEditorOptOutStage ? (
+        <NewEditorOptOutIntroModal />
+      ) : (
+        <>
+          <NewEditorPromoModal />
+          <NewEditorIntroModal />
+        </>
+      )}
     </>
     </>
   )
   )
 })
 })

+ 81 - 0
services/web/frontend/js/features/ide-redesign/components/new-editor-opt-out-intro-modal.tsx

@@ -0,0 +1,81 @@
+import {
+  OLModal,
+  OLModalBody,
+  OLModalFooter,
+  OLModalHeader,
+  OLModalTitle,
+} from '@/shared/components/ol/ol-modal'
+import { useCallback, useEffect, useState } from 'react'
+import useTutorial from '@/shared/hooks/promotions/use-tutorial'
+import OLButton from '@/shared/components/ol/ol-button'
+import { useTranslation } from 'react-i18next'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { useIsNewToNewEditor } from '../utils/new-editor-utils'
+import { useNewEditorTourContext } from '../contexts/new-editor-tour-context'
+import promoVideo from './new-editor-promo-video.mp4'
+
+const TUTORIAL_KEY = 'new-editor-intro-2'
+
+export default function NewEditorOptOutIntroModal() {
+  const { inactiveTutorials } = useEditorContext()
+  const {
+    tryShowingPopup,
+    showPopup: showModal,
+    dismissTutorial,
+    completeTutorial,
+    clearPopup,
+  } = useTutorial(TUTORIAL_KEY, {
+    name: TUTORIAL_KEY,
+  })
+  const { startTour } = useNewEditorTourContext()
+
+  const { t } = useTranslation()
+
+  const [hasShown, setHasShown] = useState(false)
+  const isNewToNewEditor = useIsNewToNewEditor()
+
+  useEffect(() => {
+    if (
+      isNewToNewEditor &&
+      !hasShown &&
+      !inactiveTutorials.includes(TUTORIAL_KEY)
+    ) {
+      tryShowingPopup('notification-prompt')
+      setHasShown(true)
+    }
+  }, [tryShowingPopup, inactiveTutorials, isNewToNewEditor, hasShown])
+
+  const startProductTour = useCallback(() => {
+    completeTutorial({ event: 'notification-click', action: 'complete' })
+    startTour()
+    clearPopup()
+  }, [completeTutorial, startTour, clearPopup])
+
+  const closeModal = useCallback(() => {
+    dismissTutorial('notification-dismiss')
+    clearPopup()
+  }, [dismissTutorial, clearPopup])
+
+  return (
+    <OLModal show={showModal} onHide={closeModal}>
+      <OLModalHeader>
+        <OLModalTitle>{t('introducing_overleafs_new_look')}</OLModalTitle>
+      </OLModalHeader>
+      <OLModalBody className="new-editor-intro-modal-body">
+        <div>{t('the_new_and_improved_overleaf_editor_design')}</div>
+        {/* eslint-disable-next-line jsx-a11y/media-has-caption */}
+        <video autoPlay loop muted>
+          <source src={promoVideo} type="video/mp4" />
+        </video>
+        <div>
+          {t('weve_made_it_easier_to_find_and_use_the_tools_you_need_today')}
+        </div>
+      </OLModalBody>
+      <OLModalFooter>
+        <OLButton onClick={startProductTour} variant="primary">
+          {t('explore_what_s_new')}
+        </OLButton>
+      </OLModalFooter>
+    </OLModal>
+  )
+}

+ 82 - 0
services/web/frontend/js/features/ide-redesign/components/old-editor-warning-tooltip.tsx

@@ -0,0 +1,82 @@
+import { Overlay, Popover } from 'react-bootstrap'
+import Close from '@/shared/components/close'
+import OLButton from '@/shared/components/ol/ol-button'
+import { useTranslation } from 'react-i18next'
+import useTutorial from '@/shared/hooks/promotions/use-tutorial'
+import { useCallback, useEffect, useState } from 'react'
+import { useSwitchEnableNewEditorState } from '../hooks/use-switch-enable-new-editor-state'
+import { useEditorContext } from '@/shared/context/editor-context'
+import { canUseNewEditor } from '../utils/new-editor-utils'
+
+const TUTORIAL_KEY = 'old-editor-warning-tooltip'
+
+export default function OldEditorWarningTooltip({
+  target,
+}: {
+  target: HTMLElement | null
+}) {
+  const { inactiveTutorials } = useEditorContext()
+  const { t } = useTranslation()
+  const { loading, setEditorRedesignStatus } = useSwitchEnableNewEditorState()
+
+  const {
+    tryShowingPopup,
+    showPopup,
+    dismissTutorial,
+    completeTutorial,
+    clearPopup,
+  } = useTutorial(TUTORIAL_KEY, {
+    name: TUTORIAL_KEY,
+  })
+  const [hasShown, setHasShown] = useState(false)
+  const canShow = canUseNewEditor()
+
+  useEffect(() => {
+    if (canShow && !hasShown && !inactiveTutorials.includes(TUTORIAL_KEY)) {
+      tryShowingPopup('notification-prompt')
+      setHasShown(true)
+    }
+  }, [tryShowingPopup, inactiveTutorials, hasShown, canShow])
+
+  const onSwitch = useCallback(() => {
+    completeTutorial({ event: 'notification-click', action: 'complete' })
+    setEditorRedesignStatus(true)
+  }, [setEditorRedesignStatus, completeTutorial])
+
+  const closePopup = useCallback(() => {
+    dismissTutorial('notification-dismiss')
+    clearPopup()
+  }, [dismissTutorial, clearPopup])
+
+  if (!showPopup) {
+    return null
+  }
+
+  return (
+    <Overlay show placement="bottom" target={target} onHide={closePopup}>
+      <Popover className="old-editor-warning-tooltip">
+        <Popover.Header>
+          {t('support_for_the_old_editor_is_ending_soon')}
+          <Close variant="dark" onDismiss={closePopup} />
+        </Popover.Header>
+        <Popover.Body>
+          <div>
+            {t(
+              'we_recommend_switching_to_the_new_editor_design_now_so_you_have_time_to_get_to_know_it'
+            )}
+          </div>
+          <OLButton
+            className="old-editor-warning-tooltip-switch-button"
+            isLoading={loading}
+            loadingLabel={t('loading')}
+            onClick={onSwitch}
+            variant="primary"
+            size="sm"
+          >
+            {t('switch_to_new_editor_design')}
+          </OLButton>
+        </Popover.Body>
+      </Popover>
+    </Overlay>
+  )
+}

+ 11 - 2
services/web/frontend/js/features/ide-redesign/hooks/use-switch-enable-new-editor-state.ts

@@ -1,4 +1,5 @@
 import { postJSON } from '@/infrastructure/fetch-json'
 import { postJSON } from '@/infrastructure/fetch-json'
+import { useFeatureFlag } from '@/shared/context/split-test-context'
 import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { useUserSettingsContext } from '@/shared/context/user-settings-context'
 import { useCallback, useState } from 'react'
 import { useCallback, useState } from 'react'
 
 
@@ -6,12 +7,20 @@ export const useSwitchEnableNewEditorState = () => {
   const [loading, setLoading] = useState(false)
   const [loading, setLoading] = useState(false)
   const [error, setError] = useState('')
   const [error, setError] = useState('')
   const { setUserSettings } = useUserSettingsContext()
   const { setUserSettings } = useUserSettingsContext()
+  const isNewEditorOptOutStage = useFeatureFlag('editor-redesign-opt-out')
+
   const setEditorRedesignStatus = useCallback(
   const setEditorRedesignStatus = useCallback(
     (status: boolean): Promise<void> => {
     (status: boolean): Promise<void> => {
       setLoading(true)
       setLoading(true)
       setError('')
       setError('')
       return new Promise((resolve, reject) => {
       return new Promise((resolve, reject) => {
-        postJSON('/user/settings', { body: { enableNewEditor: status } })
+        postJSON(
+          // Ensure that feature flag overrides are preserved in the request
+          `/user/settings?editor-redesign-opt-out=${isNewEditorOptOutStage ? 'enabled' : 'default'}`,
+          {
+            body: { enableNewEditor: status },
+          }
+        )
           .then(() => {
           .then(() => {
             setUserSettings(current => ({
             setUserSettings(current => ({
               ...current,
               ...current,
@@ -28,7 +37,7 @@ export const useSwitchEnableNewEditorState = () => {
           })
           })
       })
       })
     },
     },
-    [setUserSettings]
+    [setUserSettings, isNewEditorOptOutStage]
   )
   )
   return { loading, error, setEditorRedesignStatus }
   return { loading, error, setEditorRedesignStatus }
 }
 }

+ 7 - 0
services/web/frontend/js/features/ide-redesign/utils/new-editor-utils.ts

@@ -70,3 +70,10 @@ export const useIsNewEditorEnabled = () => {
   const enabled = userSettings.enableNewEditor
   const enabled = userSettings.enableNewEditor
   return hasAccess && enabled
   return hasAccess && enabled
 }
 }
+
+export const useIsNewToNewEditor = () => {
+  const { userSettings } = useUserSettingsContext()
+  const newEditor = useIsNewEditorEnabled()
+
+  return newEditor && !userSettings.enableNewEditorLegacy
+}

+ 1 - 0
services/web/frontend/js/shared/context/user-settings-context.tsx

@@ -24,6 +24,7 @@ const defaultSettings: UserSettings = {
   mathPreview: true,
   mathPreview: true,
   referencesSearchMode: 'advanced',
   referencesSearchMode: 'advanced',
   enableNewEditor: true,
   enableNewEditor: true,
+  enableNewEditorLegacy: true,
   breadcrumbs: true,
   breadcrumbs: true,
   darkModePdf: false,
   darkModePdf: false,
 }
 }

+ 1 - 0
services/web/frontend/stylesheets/pages/all.scss

@@ -37,6 +37,7 @@
 @import 'editor/editor-survey';
 @import 'editor/editor-survey';
 @import 'editor/editor-tour-tooltip';
 @import 'editor/editor-tour-tooltip';
 @import 'editor/new-editor-promo-modal';
 @import 'editor/new-editor-promo-modal';
+@import 'editor/old-editor-warning-tooltip';
 @import 'error-pages';
 @import 'error-pages';
 @import 'website-redesign';
 @import 'website-redesign';
 @import 'group-settings';
 @import 'group-settings';

+ 15 - 0
services/web/frontend/stylesheets/pages/editor/old-editor-warning-tooltip.scss

@@ -0,0 +1,15 @@
+.old-editor-warning-tooltip {
+  .popover-header {
+    display: flex;
+  }
+
+  .popover-body {
+    display: flex;
+    flex-direction: column;
+    gap: var(--spacing-06);
+  }
+}
+
+.old-editor-warning-tooltip-switch-button {
+  align-self: flex-end;
+}

+ 5 - 0
services/web/locales/en.json

@@ -2271,6 +2271,7 @@
   "suggestion_applied": "Suggestion applied",
   "suggestion_applied": "Suggestion applied",
   "suggests_code_completions_while_typing": "Suggests code completions while typing",
   "suggests_code_completions_while_typing": "Suggests code completions while typing",
   "support": "Support",
   "support": "Support",
+  "support_for_the_old_editor_is_ending_soon": "Support for the old editor is ending soon",
   "support_for_your_browser_is_ending_soon": "Support for your browser is ending soon",
   "support_for_your_browser_is_ending_soon": "Support for your browser is ending soon",
   "supports_up_to_x_licenses": "Supports up to <0>__count__ licenses</0>",
   "supports_up_to_x_licenses": "Supports up to <0>__count__ licenses</0>",
   "sure_you_want_to_cancel_plan_change": "Are you sure you want to revert your scheduled plan change? You will remain subscribed to the <0>__planName__</0> plan.",
   "sure_you_want_to_cancel_plan_change": "Are you sure you want to revert your scheduled plan change? You will remain subscribed to the <0>__planName__</0> plan.",
@@ -2282,6 +2283,8 @@
   "switch_compile_mode_for_faster_draft_compilation": "Switch compile mode for faster draft compilation",
   "switch_compile_mode_for_faster_draft_compilation": "Switch compile mode for faster draft compilation",
   "switch_easily_between_your_files_comments_track_changes_and_more": "Switch easily between your files, comments, track changes, and more in the new left-hand menu.",
   "switch_easily_between_your_files_comments_track_changes_and_more": "Switch easily between your files, comments, track changes, and more in the new left-hand menu.",
   "switch_to_editor": "Switch to editor",
   "switch_to_editor": "Switch to editor",
+  "switch_to_new_editor_design": "Switch to new editor design",
+  "switch_to_new_look": "Switch to new look",
   "switch_to_pdf": "Switch to PDF",
   "switch_to_pdf": "Switch to PDF",
   "switch_to_personal_email_to_keep_your_accounts_separate": "Switch to a personal email to keep your accounts separate.",
   "switch_to_personal_email_to_keep_your_accounts_separate": "Switch to a personal email to keep your accounts separate.",
   "switch_to_standard_plan": "Switch to Standard plan",
   "switch_to_standard_plan": "Switch to Standard plan",
@@ -2357,6 +2360,7 @@
   "the_following_folder_already_exists_in_this_project": "The following folder already exists in this project:",
   "the_following_folder_already_exists_in_this_project": "The following folder already exists in this project:",
   "the_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:",
   "the_following_folder_already_exists_in_this_project_plural": "The following folders already exist in this project:",
   "the_latex_engine_used_for_compiling": "The LaTeX engine used for compiling",
   "the_latex_engine_used_for_compiling": "The LaTeX engine used for compiling",
+  "the_new_and_improved_overleaf_editor_design": "The new and improved __appName__ editor design brings you a cleaner, less cluttered interface to help you focus on what matters—your work.",
   "the_new_overleaf_editor_info": "__appName__’s new look is here. Disabling this option will switch you back to the old editor design.",
   "the_new_overleaf_editor_info": "__appName__’s new look is here. Disabling this option will switch you back to the old editor design.",
   "the_next_payment_will_be_collected_on": "The next payment will be collected on <strong>__date__</strong>.",
   "the_next_payment_will_be_collected_on": "The next payment will be collected on <strong>__date__</strong>.",
   "the_original_text_has_changed": "The original text has changed, so this suggestion can’t be applied",
   "the_original_text_has_changed": "The original text has changed, so this suggestion can’t be applied",
@@ -2703,6 +2707,7 @@
   "we_got_your_request": "We’ve got your request",
   "we_got_your_request": "We’ve got your request",
   "we_logged_you_in": "We have logged you in.",
   "we_logged_you_in": "We have logged you in.",
   "we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you</0> from time to time by email with a survey, or to see if you would like to participate in other user research initiatives",
   "we_may_also_contact_you_from_time_to_time_by_email_with_a_survey": "<0>We may also contact you</0> from time to time by email with a survey, or to see if you would like to participate in other user research initiatives",
+  "we_recommend_switching_to_the_new_editor_design_now_so_you_have_time_to_get_to_know_it": "We recommend switching to the new editor design now, so you have time to get to know it.",
   "we_sent_code": "We’ve sent you a confirmation code",
   "we_sent_code": "We’ve sent you a confirmation code",
   "we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
   "we_sent_new_code": "We’ve sent a new code. If it doesn’t arrive, make sure to check your spam and any promotions folders.",
   "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "We’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription.",
   "we_will_charge_you_now_for_the_cost_of_your_additional_licenses_based_on_remaining_months": "We’ll charge you now for the cost of your additional licenses based on the remaining months of your current subscription.",

+ 95 - 25
services/web/test/unit/src/User/UserController.test.mjs

@@ -8,6 +8,14 @@ vi.mock('../../../../app/src/Features/Errors/Errors.js', () => {
   return vi.importActual('../../../../app/src/Features/Errors/Errors.js')
   return vi.importActual('../../../../app/src/Features/Errors/Errors.js')
 })
 })
 
 
+vi.mock('../../../../app/src/infrastructure/Metrics.js', () => ({
+  default: {
+    analyticsQueue: {
+      inc: vi.fn(),
+    },
+  },
+}))
+
 describe('UserController', function () {
 describe('UserController', function () {
   beforeEach(async function (ctx) {
   beforeEach(async function (ctx) {
     ctx.user_id = '323123'
     ctx.user_id = '323123'
@@ -143,6 +151,12 @@ describe('UserController', function () {
       },
       },
     }
     }
 
 
+    ctx.SplitTestHandler = {
+      promises: {
+        getAssignment: sinon.stub().resolves({ variant: 'default' }),
+      },
+    }
+
     vi.doMock('../../../../app/src/Features/Helpers/UrlHelper', () => ({
     vi.doMock('../../../../app/src/Features/Helpers/UrlHelper', () => ({
       default: ctx.UrlHelper,
       default: ctx.UrlHelper,
     }))
     }))
@@ -239,6 +253,13 @@ describe('UserController', function () {
       default: ctx.Modules,
       default: ctx.Modules,
     }))
     }))
 
 
+    vi.doMock(
+      '../../../../app/src/Features/SplitTests/SplitTestHandler.mjs',
+      () => ({
+        default: ctx.SplitTestHandler,
+      })
+    )
+
     ctx.UserController = (await import(modulePath)).default
     ctx.UserController = (await import(modulePath)).default
 
 
     ctx.res = {
     ctx.res = {
@@ -565,36 +586,85 @@ describe('UserController', function () {
       })
       })
     })
     })
 
 
-    it('should set enableNewEditor to true', function (ctx) {
-      return new Promise(resolve => {
-        ctx.req.body = { enableNewEditor: true }
-        ctx.res.sendStatus = code => {
-          ctx.user.ace.enableNewEditor.should.equal(true)
-          resolve()
-        }
-        ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+    describe('when editor-redesign-opt-out is set to default', function () {
+      beforeEach(function (ctx) {
+        ctx.SplitTestHandler.promises.getAssignment.resolves({
+          variant: 'default',
+        })
       })
       })
-    })
 
 
-    it('should set enableNewEditor to false', function (ctx) {
-      return new Promise(resolve => {
-        ctx.req.body = { enableNewEditor: false }
-        ctx.res.sendStatus = code => {
-          ctx.user.ace.enableNewEditor.should.equal(false)
-          resolve()
-        }
-        ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+      it('should set enableNewEditor to true', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.body = { enableNewEditor: true }
+          ctx.res.sendStatus = code => {
+            ctx.user.ace.enableNewEditor.should.equal(true)
+            resolve()
+          }
+          ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+        })
+      })
+
+      it('should set enableNewEditor to false', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.body = { enableNewEditor: false }
+          ctx.res.sendStatus = code => {
+            ctx.user.ace.enableNewEditor.should.equal(false)
+            resolve()
+          }
+          ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+        })
+      })
+
+      it('should keep enableNewEditor a boolean', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.body = { enableNewEditor: 'foobar' }
+          ctx.res.sendStatus = code => {
+            ctx.user.ace.enableNewEditor.should.equal(true)
+            resolve()
+          }
+          ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+        })
       })
       })
     })
     })
 
 
-    it('should keep enableNewEditor a boolean', function (ctx) {
-      return new Promise(resolve => {
-        ctx.req.body = { enableNewEditor: 'foobar' }
-        ctx.res.sendStatus = code => {
-          ctx.user.ace.enableNewEditor.should.equal(true)
-          resolve()
-        }
-        ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+    describe('when editor-redesign-opt-out is set to enabled', function () {
+      beforeEach(function (ctx) {
+        ctx.SplitTestHandler.promises.getAssignment.resolves({
+          variant: 'enabled',
+        })
+      })
+
+      it('should set enableNewEditorStageFour to true', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.body = { enableNewEditor: true }
+          ctx.res.sendStatus = code => {
+            ctx.user.ace.enableNewEditorStageFour.should.equal(true)
+            resolve()
+          }
+          ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+        })
+      })
+
+      it('should set enableNewEditorStageFour to false', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.body = { enableNewEditor: false }
+          ctx.res.sendStatus = code => {
+            ctx.user.ace.enableNewEditorStageFour.should.equal(false)
+            resolve()
+          }
+          ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+        })
+      })
+
+      it('should keep enableNewEditorStageFour a boolean', function (ctx) {
+        return new Promise(resolve => {
+          ctx.req.body = { enableNewEditor: 'foobar' }
+          ctx.res.sendStatus = code => {
+            ctx.user.ace.enableNewEditorStageFour.should.equal(true)
+            resolve()
+          }
+          ctx.UserController.updateUserSettings(ctx.req, ctx.res)
+        })
       })
       })
     })
     })
 
 

+ 1 - 0
services/web/types/user-settings.ts

@@ -17,6 +17,7 @@ export type UserSettings = {
   mathPreview: boolean
   mathPreview: boolean
   referencesSearchMode: 'advanced' | 'simple'
   referencesSearchMode: 'advanced' | 'simple'
   enableNewEditor: boolean
   enableNewEditor: boolean
+  enableNewEditorLegacy: boolean
   breadcrumbs: boolean
   breadcrumbs: boolean
   darkModePdf: boolean
   darkModePdf: boolean
 }
 }