diff --git a/services/web/app/views/project/editor.pug b/services/web/app/views/project/editor.pug
index 7f648aba3b..d13e60f6f0 100644
--- a/services/web/app/views/project/editor.pug
+++ b/services/web/app/views/project/editor.pug
@@ -65,6 +65,8 @@ block content
span.sr-only #{translate("close")}
.system-message-content(ng-bind-html="htmlContent")
+ grammarly-warning()
+
include ./editor/main
script(type="text/ng-template", id="genericMessageModalTemplate")
diff --git a/services/web/frontend/js/features/source-editor/components/grammarly-warning.tsx b/services/web/frontend/js/features/source-editor/components/grammarly-warning.tsx
new file mode 100644
index 0000000000..4d1ca92121
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/components/grammarly-warning.tsx
@@ -0,0 +1,65 @@
+import { useCallback, useEffect, useState } from 'react'
+import { Button } from 'react-bootstrap'
+import customLocalStorage from '../../../infrastructure/local-storage'
+import useScopeValue from '../../../shared/hooks/use-scope-value'
+import grammarlyExtensionPresent from '../../../shared/utils/grammarly'
+import getMeta from '../../../utils/meta'
+
+export default function GrammarlyWarning() {
+ const [show, setShow] = useState(false)
+ const [newSourceEditor] = useScopeValue('editor.newSourceEditor')
+ const [showRichText] = useScopeValue('editor.showRichText')
+ const grammarly = grammarlyExtensionPresent()
+ const hasDismissedGrammarlyWarning = customLocalStorage.getItem(
+ 'editor.has_dismissed_grammarly_warning'
+ )
+
+ useEffect(() => {
+ const showGrammarlyWarning =
+ !hasDismissedGrammarlyWarning &&
+ grammarly &&
+ newSourceEditor &&
+ !showRichText
+
+ if (showGrammarlyWarning) {
+ setShow(true)
+ }
+ }, [grammarly, hasDismissedGrammarlyWarning, newSourceEditor, showRichText])
+
+ const handleClose = useCallback(() => {
+ setShow(false)
+ customLocalStorage.setItem('editor.has_dismissed_grammarly_warning', true)
+ }, [])
+
+ if (!getMeta('ol-showNewSourceEditorOption')) {
+ return null
+ }
+
+ if (!show) {
+ return null
+ }
+
+ return (
+
+ )
+}
diff --git a/services/web/frontend/js/features/source-editor/controllers/grammarly-warning-controller.js b/services/web/frontend/js/features/source-editor/controllers/grammarly-warning-controller.js
new file mode 100644
index 0000000000..e1a9679394
--- /dev/null
+++ b/services/web/frontend/js/features/source-editor/controllers/grammarly-warning-controller.js
@@ -0,0 +1,9 @@
+import App from '../../../base'
+import { react2angular } from 'react2angular'
+import { rootContext } from '../../../shared/context/root-context'
+import GrammarlyWarning from '../components/grammarly-warning'
+
+App.component(
+ 'grammarlyWarning',
+ react2angular(rootContext.use(GrammarlyWarning))
+)
diff --git a/services/web/frontend/js/ide.js b/services/web/frontend/js/ide.js
index b55b2d38de..aebac4eb93 100644
--- a/services/web/frontend/js/ide.js
+++ b/services/web/frontend/js/ide.js
@@ -65,6 +65,7 @@ import './features/pdf-preview/controllers/pdf-preview-controller'
import './features/share-project-modal/controllers/react-share-project-modal-controller'
import './features/source-editor/controllers/editor-switch-controller'
import './features/source-editor/controllers/cm6-switch-away-survey-controller'
+import './features/source-editor/controllers/grammarly-warning-controller'
import { cleanupServiceWorker } from './utils/service-worker-cleanup'
import { reportCM6Perf } from './infrastructure/cm6-performance'
diff --git a/services/web/frontend/stylesheets/app/editor.less b/services/web/frontend/stylesheets/app/editor.less
index 2a296df545..784cc0f18c 100644
--- a/services/web/frontend/stylesheets/app/editor.less
+++ b/services/web/frontend/stylesheets/app/editor.less
@@ -827,3 +827,32 @@ CodeMirror
font-size: @font-size-small;
}
}
+
+.grammarly-warning {
+ width: 500px;
+
+ &.alert.alert-info {
+ padding: @padding-sm;
+ }
+
+ .btn.close {
+ background-color: transparent;
+ color: @white;
+ opacity: 1;
+ }
+
+ .warning-content {
+ padding-right: @alert-padding;
+ font-size: @font-size-small;
+ margin-right: 32px;
+
+ .warning-link {
+ font-weight: 700;
+ text-decoration: none;
+
+ &:hover {
+ text-decoration: underline;
+ }
+ }
+ }
+}
diff --git a/services/web/test/frontend/features/source-editor/components/grammarly-warning.test.js b/services/web/test/frontend/features/source-editor/components/grammarly-warning.test.js
new file mode 100644
index 0000000000..f1b5c1cc1e
--- /dev/null
+++ b/services/web/test/frontend/features/source-editor/components/grammarly-warning.test.js
@@ -0,0 +1,184 @@
+import sinon from 'sinon'
+import fetchMock from 'fetch-mock'
+import { expect } from 'chai'
+import { fireEvent, screen, waitFor } from '@testing-library/react'
+import { renderWithEditorContext } from '../../../helpers/render-with-context'
+import GrammarlyWarning from '../../../../../frontend/js/features/source-editor/components/grammarly-warning'
+import * as grammarlyModule from '../../../../../frontend/js/shared/utils/grammarly'
+import localStorage from '../../../../../frontend/js/infrastructure/local-storage'
+
+describe('', function () {
+ let grammarlyStub
+
+ before(function () {
+ window.metaAttributesCache = new Map()
+ })
+
+ beforeEach(function () {
+ grammarlyStub = sinon.stub(grammarlyModule, 'default')
+ })
+
+ afterEach(function () {
+ window.metaAttributesCache = new Map()
+ grammarlyStub.restore()
+ fetchMock.reset()
+ localStorage.clear()
+ })
+
+ it('shows warning when grammarly is available', async function () {
+ grammarlyStub.returns(true)
+ window.metaAttributesCache.set('ol-showNewSourceEditorOption', true)
+
+ renderWithEditorContext(, {
+ scope: {
+ editor: {
+ newSourceEditor: true,
+ },
+ },
+ })
+
+ await screen.findByText(
+ 'A browser extension, for example Grammarly, may be slowing down Overleaf.'
+ )
+ await screen.findByRole('button', { name: 'Close' })
+ await screen.findByRole('link', { name: 'Find out how to avoid this' })
+ })
+
+ it('does not show warning when grammarly is not available', async function () {
+ grammarlyStub.returns(false)
+ window.metaAttributesCache.set('ol-showNewSourceEditorOption', true)
+
+ renderWithEditorContext(, {
+ scope: {
+ editor: {
+ newSourceEditor: true,
+ },
+ },
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText(
+ 'A browser extension, for example Grammarly, may be slowing down Overleaf.'
+ )
+ ).to.not.exist
+ })
+ })
+
+ it('does not show warning when user has dismissed the warning', async function () {
+ grammarlyStub.returns(true)
+ localStorage.setItem('editor.has_dismissed_grammarly_warning', true)
+ window.metaAttributesCache.set('ol-showNewSourceEditorOption', true)
+
+ renderWithEditorContext(, {
+ scope: {
+ editor: {
+ newSourceEditor: true,
+ },
+ },
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText(
+ 'A browser extension, for example Grammarly, may be slowing down Overleaf.'
+ )
+ ).to.not.exist
+ })
+ })
+
+ it('does not show warning when user does not have CM6', async function () {
+ grammarlyStub.returns(true)
+ window.metaAttributesCache.set('ol-showNewSourceEditorOption', false)
+
+ renderWithEditorContext()
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText(
+ 'A browser extension, for example Grammarly, may be slowing down Overleaf.'
+ )
+ ).to.not.exist
+ })
+ })
+
+ it('does not show warning when user have ace as their preference', async function () {
+ grammarlyStub.returns(true)
+ window.metaAttributesCache.set('ol-showNewSourceEditorOption', true)
+
+ renderWithEditorContext(, {
+ scope: {
+ editor: {
+ newSourceEditor: false,
+ },
+ },
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText(
+ 'A browser extension, for example Grammarly, may be slowing down Overleaf.'
+ )
+ ).to.not.exist
+ })
+ })
+
+ it('does not show warning when user have rich text as their preference', async function () {
+ grammarlyStub.returns(true)
+ window.metaAttributesCache.set('ol-showNewSourceEditorOption', true)
+
+ renderWithEditorContext(, {
+ scope: {
+ editor: {
+ newSourceEditor: true,
+ showRichText: true,
+ },
+ },
+ })
+
+ await waitFor(() => {
+ expect(
+ screen.queryByText(
+ 'A browser extension, for example Grammarly, may be slowing down Overleaf.'
+ )
+ ).to.not.exist
+ })
+ })
+
+ it('hides warning if close button is pressed', async function () {
+ grammarlyStub.returns(true)
+ window.metaAttributesCache.set('ol-showNewSourceEditorOption', true)
+
+ renderWithEditorContext(, {
+ scope: {
+ editor: {
+ newSourceEditor: true,
+ },
+ },
+ })
+
+ const warningText =
+ 'A browser extension, for example Grammarly, may be slowing down Overleaf.'
+
+ await screen.findByText(warningText)
+
+ const hasDismissedGrammarlyWarning = localStorage.getItem(
+ 'editor.has_dismissed_grammarly_warning'
+ )
+
+ expect(hasDismissedGrammarlyWarning).to.equal(null)
+
+ const closeButton = screen.getByRole('button', { name: 'Close' })
+ fireEvent.click(closeButton)
+
+ expect(screen.queryByText(warningText)).to.not.exist
+
+ await waitFor(() => {
+ const hasDismissedGrammarlyWarning = localStorage.getItem(
+ 'editor.has_dismissed_grammarly_warning'
+ )
+
+ expect(hasDismissedGrammarlyWarning).to.equal(true)
+ })
+ })
+})