diff --git a/services/web/app/src/Features/OnboardingDataCollection/OnboardingDataCollectionManager.js b/services/web/app/src/Features/OnboardingDataCollection/OnboardingDataCollectionManager.js index 9eb9321765..fb4fa84590 100644 --- a/services/web/app/src/Features/OnboardingDataCollection/OnboardingDataCollectionManager.js +++ b/services/web/app/src/Features/OnboardingDataCollection/OnboardingDataCollectionManager.js @@ -1,16 +1,29 @@ const { OnboardingDataCollection, + OnboardingDataCollectionSchema, } = require('../../models/OnboardingDataCollection') const OError = require('@overleaf/o-error') -async function getOnboardingDataCollection(userId) { +async function getOnboardingDataCollection(userId, projection = {}) { try { - return await OnboardingDataCollection.findOne({ _id: userId }).exec() + return await OnboardingDataCollection.findOne( + { _id: userId }, + projection + ).exec() } catch (error) { throw OError.tag(error, 'Failed to get OnboardingDataCollection') } } +async function getOnboardingDataValue(userId, key) { + if (!OnboardingDataCollectionSchema.paths[key]) { + throw new Error(`${key} is not a valid onboarding data key`) + } + + const result = await getOnboardingDataCollection(userId, { [key]: 1 }) + return result ? result[key] : null +} + async function upsertOnboardingDataCollection({ userId, firstName, @@ -60,4 +73,5 @@ module.exports = { getOnboardingDataCollection, upsertOnboardingDataCollection, deleteOnboardingDataCollection, + getOnboardingDataValue, } diff --git a/services/web/app/src/Features/Project/ProjectController.js b/services/web/app/src/Features/Project/ProjectController.js index 94a8ee84dc..25da84cfa7 100644 --- a/services/web/app/src/Features/Project/ProjectController.js +++ b/services/web/app/src/Features/Project/ProjectController.js @@ -41,6 +41,7 @@ const ProjectAuditLogHandler = require('./ProjectAuditLogHandler') const PublicAccessLevels = require('../Authorization/PublicAccessLevels') const TagsHandler = require('../Tags/TagsHandler') const TutorialHandler = require('../Tutorial/TutorialHandler') +const OnboardingDataCollectionManager = require('../OnboardingDataCollection/OnboardingDataCollectionManager') const UserUpdater = require('../User/UserUpdater') const Modules = require('../../infrastructure/Modules') const UserGetter = require('../User/UserGetter') @@ -381,6 +382,13 @@ const _ProjectController = { userId, projectId ), + usedLatex: OnboardingDataCollectionManager.getOnboardingDataValue( + userId, + 'usedLatex' + ).catch(err => { + logger.error({ err, userId }) + return null + }), }) ) const splitTestAssignments = {} @@ -428,6 +436,7 @@ const _ProjectController = { subscription, isTokenMember, isInvitedMember, + usedLatex, } = userValues // check if a user is not in the writefull-oauth-promotion, in which case they may be part of the auto trial group @@ -724,6 +733,7 @@ const _ProjectController = { hasTrackChangesFeature: Features.hasFeature('track-changes'), projectTags, linkSharingWarning: linkSharingChanges.variant === 'active', + usedLatex, }) timer.done() } catch (err) { diff --git a/services/web/app/src/Features/Tutorial/TutorialController.js b/services/web/app/src/Features/Tutorial/TutorialController.js index a049ff3543..3cf1d6140f 100644 --- a/services/web/app/src/Features/Tutorial/TutorialController.js +++ b/services/web/app/src/Features/Tutorial/TutorialController.js @@ -8,6 +8,7 @@ const VALID_KEYS = [ 'writefull-oauth-promotion', 'bib-file-tpr-prompt', 'ai-error-assistant-consent', + 'code-editor-mode-prompt', ] async function completeTutorial(req, res, next) { diff --git a/services/web/app/views/project/editor/meta.pug b/services/web/app/views/project/editor/meta.pug index 37d7a06461..5df437d055 100644 --- a/services/web/app/views/project/editor/meta.pug +++ b/services/web/app/views/project/editor/meta.pug @@ -37,6 +37,7 @@ meta(name="ol-hasTrackChangesFeature", data-type="boolean" content=hasTrackChang meta(name="ol-inactiveTutorials", data-type="json" content=user.inactiveTutorials) meta(name="ol-projectTags" data-type="json" content=projectTags) meta(name="ol-linkSharingWarning" data-type="boolean" content=linkSharingWarning) +meta(name="ol-usedLatex" data-type="string" content=usedLatex) // translations for the loading page, before i18n has loaded in the client meta(name="ol-loadingText", data-type="string" content=translate("loading")) diff --git a/services/web/frontend/extracted-translations.json b/services/web/frontend/extracted-translations.json index 72a2e568f9..491c0b566a 100644 --- a/services/web/frontend/extracted-translations.json +++ b/services/web/frontend/extracted-translations.json @@ -191,6 +191,8 @@ "code_check_failed": "", "code_check_failed_explanation": "", "code_editor": "", + "code_editor_tooltip_message": "", + "code_editor_tooltip_title": "", "collaborate_online_and_offline": "", "collabs_per_proj": "", "collabs_per_proj_single": "", diff --git a/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts b/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts index 370198fc84..89bdc38c21 100644 --- a/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts +++ b/services/web/frontend/js/features/ide-react/scope-adapters/editor-manager-context-adapter.ts @@ -1,5 +1,6 @@ import { ReactScopeValueStore } from '@/features/ide-react/scope-value-store/react-scope-value-store' import customLocalStorage from '@/infrastructure/local-storage' +import getMeta from '@/utils/meta' export function populateEditorScope( store: ReactScopeValueStore, @@ -26,13 +27,24 @@ export function populateEditorScope( }) store.persisted( 'editor.showVisual', - showVisualFallbackValue(projectId), + getMeta('ol-usedLatex') === 'never' || showVisualFallbackValue(projectId), `editor.lastUsedMode`, { toPersisted: showVisual => (showVisual ? 'visual' : 'code'), fromPersisted: mode => mode === 'visual', } ) + + store.persisted( + 'editor.codeEditorOpened', + codeEditorOpenedFallbackValue(), + 'editor.codeEditorOpened' + ) + store.watch('editor.showVisual', showVisual => { + if (store.get('editor.codeEditorOpened') !== true && showVisual === false) { + store.set('editor.codeEditorOpened', true) + } + }) } function showVisualFallbackValue(projectId: string) { @@ -46,3 +58,16 @@ function showVisualFallbackValue(projectId: string) { return editorModeVal === 'rich-text' } + +function codeEditorOpenedFallbackValue() { + const signUpDate = getMeta('ol-user').signUpDate + if ( + typeof signUpDate === 'string' && + new Date(signUpDate) < new Date('2024-08-02') + ) { + // if signUpDate is before releasing "codeEditorOpened" value + // it is assumed that the user has opened the code editor at some point + return true + } + return false +} diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch-beginner-tooltip.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch-beginner-tooltip.tsx new file mode 100644 index 0000000000..02da586eef --- /dev/null +++ b/services/web/frontend/js/features/source-editor/components/editor-switch-beginner-tooltip.tsx @@ -0,0 +1,118 @@ +import { ReactElement, useCallback, useEffect, useRef, useState } from 'react' +import Tooltip from '../../../shared/components/tooltip' +import Close from '@/shared/components/close' +import { useEditorContext } from '@/shared/context/editor-context' +import useTutorial from '@/shared/hooks/promotions/use-tutorial' +import { useUserContext } from '@/shared/context/user-context' +import useScopeValue from '@/shared/hooks/use-scope-value' +import { useTranslation } from 'react-i18next' +import getMeta from '@/utils/meta' + +const CODE_EDITOR_TOOLTIP_TIMEOUT = 1000 +export const codeEditorModePrompt = 'code-editor-mode-prompt' + +export const EditorSwitchBeginnerTooltip = ({ + children, +}: { + children: ReactElement +}) => { + const toolbarRef = useRef(null) + const user = useUserContext() + const { inactiveTutorials } = useEditorContext() + const { t } = useTranslation() + const [codeEditorOpened] = useScopeValue('editor.codeEditorOpened') + const { completeTutorial } = useTutorial(codeEditorModePrompt, { + location: 'logs', + name: codeEditorModePrompt, + }) + const [tooltipShown, setTooltipShown] = useState(false) + + const shouldShowCodeEditorTooltip = useCallback(() => { + if (inactiveTutorials.includes(codeEditorModePrompt)) { + return false + } + + if (getMeta('ol-usedLatex') !== 'never') { + // only show tooltip to the users that never used LaTeX (submitted in onboarding data collection) + return false + } + + if (codeEditorOpened) { + // dont show tooltip if code editor was opened at some point + return false + } + + const msSinceSignedUp = + user.signUpDate && Date.now() - new Date(user.signUpDate).getTime() + + if (msSinceSignedUp && msSinceSignedUp < 24 * 60 * 60 * 1000) { + // dont show tooltip if user has signed up is less than 24 hours + return false + } + + return true + }, [codeEditorOpened, inactiveTutorials, user.signUpDate]) + + const showCodeEditorTooltip = useCallback(() => { + if (toolbarRef.current && 'show' in toolbarRef.current) { + toolbarRef.current.show() + setTooltipShown(true) + } + }, []) + + const hideCodeEditorTooltip = useCallback(() => { + if (toolbarRef.current && 'hide' in toolbarRef.current) { + toolbarRef.current.hide() + setTooltipShown(false) + } + }, []) + + useEffect(() => { + if (tooltipShown && codeEditorOpened) { + hideCodeEditorTooltip() + } + }, [codeEditorOpened, hideCodeEditorTooltip, tooltipShown]) + + useEffect(() => { + const timeout = setTimeout(() => { + if (shouldShowCodeEditorTooltip()) { + showCodeEditorTooltip() + } + }, CODE_EDITOR_TOOLTIP_TIMEOUT) + + return () => clearTimeout(timeout) + }, [showCodeEditorTooltip, shouldShowCodeEditorTooltip]) + + return ( + + { + hideCodeEditorTooltip() + completeTutorial({ event: 'promo-click', action: 'complete' }) + }} + /> +
{t('code_editor_tooltip_title')}
+
{t('code_editor_tooltip_message')}
+ + } + tooltipProps={{ + className: 'editor-switch-tooltip', + }} + overlayProps={{ + ref: toolbarRef, + placement: 'bottom', + shouldUpdatePosition: true, + // @ts-ignore + // trigger: null is used to prevent the tooltip from showing on hover + // but it is not allowed in the type definition + trigger: null, + }} + > + {children} +
+ ) +} diff --git a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx index ec4524e259..531da0d267 100644 --- a/services/web/frontend/js/features/source-editor/components/editor-switch.tsx +++ b/services/web/frontend/js/features/source-editor/components/editor-switch.tsx @@ -1,16 +1,26 @@ import { ChangeEvent, FC, memo, useCallback } from 'react' -import useScopeValue from '../../../shared/hooks/use-scope-value' -import Tooltip from '../../../shared/components/tooltip' +import useScopeValue from '@/shared/hooks/use-scope-value' +import Tooltip from '@/shared/components/tooltip' +import useTutorial from '@/shared/hooks/promotions/use-tutorial' import { sendMB } from '../../../infrastructure/event-tracking' import isValidTeXFile from '../../../main/is-valid-tex-file' import { useTranslation } from 'react-i18next' +import { + EditorSwitchBeginnerTooltip, + codeEditorModePrompt, +} from './editor-switch-beginner-tooltip' function EditorSwitch() { const { t } = useTranslation() const [visual, setVisual] = useScopeValue('editor.showVisual') const [docName] = useScopeValue('editor.open_doc_name') + const [codeEditorOpened] = useScopeValue('editor.codeEditorOpened') const richTextAvailable = isValidTeXFile(docName) + const { completeTutorial } = useTutorial(codeEditorModePrompt, { + location: 'logs', + name: codeEditorModePrompt, + }) const handleChange = useCallback( event => { @@ -19,6 +29,9 @@ function EditorSwitch() { switch (editorType) { case 'cm6': setVisual(false) + if (!codeEditorOpened) { + completeTutorial({ event: 'promo-click', action: 'complete' }) + } break case 'rich-text': @@ -28,7 +41,7 @@ function EditorSwitch() { sendMB('editor-switch-change', { editorType }) }, - [setVisual] + [codeEditorOpened, completeTutorial, setVisual] ) return ( @@ -45,9 +58,11 @@ function EditorSwitch() { checked={!richTextAvailable || !visual} onChange={handleChange} /> - + + +