Merge pull request #31035 from overleaf/mg-context-menu-analytics

Add analytics event for context menu, comment, track changes, and jump to location

GitOrigin-RevId: 8412cc3c8039cd1582ccee20b162b4bef4467dea
This commit is contained in:
Malik Glossop
2026-03-04 13:58:16 +01:00
committed by Copybot
parent ab5be2261b
commit a293474c0c
14 changed files with 203 additions and 48 deletions
@@ -10,6 +10,7 @@ import { Placement } from 'react-bootstrap/types'
import useSynctex from '../hooks/use-synctex'
import { useFeatureFlag } from '@/shared/context/split-test-context'
import OLSpinner from '@/shared/components/ol/ol-spinner'
import { sendMB } from '@/infrastructure/event-tracking'
const GoToCodeButton = memo(function GoToCodeButton({
syncToCode,
@@ -35,6 +36,10 @@ const GoToCodeButton = memo(function GoToCodeButton({
}
const syncToCodeWithButton = useCallback(() => {
sendMB('jump-to-location', {
method: 'arrow',
direction: 'pdf-location-in-code',
})
syncToCode({ visualOffset: 72 })
}, [syncToCode])
@@ -85,6 +90,14 @@ const GoToPdfButton = memo(function GoToPdfButton({
'detach-synctex-control': !!isDetachLayout,
})
const handleSyncToPdf = useCallback(() => {
sendMB('jump-to-location', {
method: 'arrow',
direction: 'code-location-in-pdf',
})
syncToPdf()
}, [syncToPdf])
let buttonIcon = null
if (syncToPdfInFlight) {
buttonIcon = <OLSpinner size="sm" />
@@ -104,7 +117,7 @@ const GoToPdfButton = memo(function GoToPdfButton({
<OLButton
variant="secondary"
size="sm"
onClick={syncToPdf}
onClick={handleSyncToPdf}
disabled={syncToPdfInFlight || !canSyncToPdf}
className={buttonClasses}
aria-label={t('go_to_code_location_in_pdf')}
@@ -28,7 +28,7 @@ function ReviewModeSwitcher() {
const { permissionsLevel } = useIdeReactContext()
const { write, trackedWrite } = usePermissionsContext()
const { features } = useProjectContext()
const { setShowUpgradeModal } = useEditorContext()
const { setUpgradeTrackChangesModal } = useEditorContext()
const showViewOption = permissionsLevel === 'readOnly'
const view = useCodeMirrorViewContext()
@@ -73,7 +73,10 @@ function ReviewModeSwitcher() {
return
}
if (!features.trackChanges) {
setShowUpgradeModal(true)
setUpgradeTrackChangesModal({
show: true,
location: 'review-switcher',
})
} else {
sendMB('editing-mode-change', {
role: permissionsLevel,
@@ -36,6 +36,7 @@ import classNames from 'classnames'
import useEventListener from '@/shared/hooks/use-event-listener'
import useReviewPanelLayout from '../hooks/use-review-panel-layout'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
import { sendMB } from '@/infrastructure/event-tracking'
const EDIT_MODE_SWITCH_WIDGET_HEIGHT = 40
const CM_LINE_RIGHT_PADDING = 8
@@ -110,6 +111,13 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(
>()
const [visible, setVisible] = useState(false)
const handleAddCommentClick = useCallback(() => {
sendMB('add-comment', {
location: 'tooltip',
})
onAddComment()
}, [onAddComment])
const changesInSelection = useMemo(() => {
return (ranges?.changes ?? []).filter(({ op }) => {
const opFrom = op.p
@@ -219,7 +227,7 @@ const ReviewTooltipMenuContent = memo<{ onAddComment: () => void }>(
>
<button
className="review-tooltip-menu-button review-tooltip-add-comment-button"
onClick={onAddComment}
onClick={handleAddCommentClick}
>
<MaterialIcon type="chat" />
{t('add_comment')}
@@ -4,7 +4,8 @@ import { useUserContext } from '@/shared/context/user-context'
import teaserVideo from '../images/teaser-track-changes.mp4'
import teaserImage from '../images/teaser-track-changes.gif'
import { startFreeTrial, upgradePlan } from '@/main/account-upgrade'
import { memo } from 'react'
import { sendMB } from '@/infrastructure/event-tracking'
import { memo, useEffect } from 'react'
import {
OLModal,
OLModalBody,
@@ -22,14 +23,34 @@ function UpgradeTrackChangesModal() {
const { t } = useTranslation()
const { project } = useProjectContext()
const user = useUserContext()
const { showUpgradeModal, setShowUpgradeModal } = useEditorContext()
const {
upgradeTrackChangesModal: { show, location = 'unknown' },
setUpgradeTrackChangesModal,
} = useEditorContext()
if (!showUpgradeModal) {
const handleClose = () => {
sendMB('paywall-dismiss', {
'paywall-type': 'track-changes',
location,
})
setUpgradeTrackChangesModal({ show: false })
}
useEffect(() => {
if (show) {
sendMB('paywall-prompt', {
'paywall-type': 'track-changes',
location,
})
}
}, [show, location])
if (!show) {
return null
}
return (
<OLModal show={showUpgradeModal} onHide={() => setShowUpgradeModal(false)}>
<OLModal show={show} onHide={handleClose}>
<OLModalHeader>
<OLModalTitle>{t('upgrade_to_review')}</OLModalTitle>
</OLModalHeader>
@@ -67,14 +88,24 @@ function UpgradeTrackChangesModal() {
user.allowedFreeTrial ? (
<OLButton
variant="premium"
onClick={() => startFreeTrial('track-changes')}
onClick={() =>
startFreeTrial('track-changes', undefined, {
location,
})
}
>
{t('try_it_for_free')}
</OLButton>
) : (
<OLButton
variant="premium"
onClick={() => upgradePlan('project-sharing')}
onClick={() => {
sendMB('paywall-click', {
'paywall-type': 'track-changes',
location,
})
upgradePlan('project-sharing')
}}
>
{t('upgrade')}
</OLButton>
@@ -92,10 +123,7 @@ function UpgradeTrackChangesModal() {
)}
</OLModalBody>
<OLModalFooter>
<OLButton
variant="secondary"
onClick={() => setShowUpgradeModal(false)}
>
<OLButton variant="secondary" onClick={handleClose}>
{t('close')}
</OLButton>
</OLModalFooter>
@@ -6,9 +6,15 @@ import {
} from '@/shared/components/dropdown/dropdown-menu'
import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
import SplitTestBadge from '@/shared/components/split-test-badge'
import { sendContextMenuEvent } from '../utils/context-menu-analytics'
const FEEDBACK_FORM_URL = 'https://forms.gle/BsbNQeSwGKEwXpxTA'
const handleClick = () => {
function handleClick() {
sendContextMenuEvent('menu-click', {
location: 'editor-context-menu',
item: 'give-feedback',
})
window.open(FEEDBACK_FORM_URL, '_blank', 'noopener,noreferrer')
}
@@ -16,6 +16,7 @@ import { useFeatureFlag } from '@/shared/context/split-test-context'
import { useContextMenuItems } from '../hooks/use-context-menu-items'
import DropdownListItem from '@/shared/components/dropdown/dropdown-list-item'
import { EditorContextMenuFeedback } from './editor-context-menu-feedback'
import { sendContextMenuEvent } from '../utils/context-menu-analytics'
const EditorContextMenu: FC = () => {
const state = useCodeMirrorStateContext()
@@ -40,6 +41,9 @@ const EditorContextMenuContent: FC = memo(function EditorContextMenuContent() {
const menuRef = useRef<any>(null)
useEffect(() => {
sendContextMenuEvent('menu-expand', {
location: 'editor-context-menu',
})
menuRef.current?.focus()
}, [])
@@ -17,6 +17,8 @@ import { useProjectContext } from '@/shared/context/project-context'
import { useEditorPropertiesContext } from '@/features/ide-react/context/editor-properties-context'
import { usePermissionsContext } from '@/features/ide-react/context/permissions-context'
const addCommentFromToolbar = () => commands.addComment('toolbar')
export const ToolbarItems: FC<{
state: EditorState
overflowed?: Set<string>
@@ -135,7 +137,7 @@ export const ToolbarItems: FC<{
id="toolbar-add-comment"
label={t('add_comment')}
disabled={state.selection.main.empty}
command={commands.addComment}
command={addCommentFromToolbar}
icon="add_comment"
/>
)}
@@ -23,6 +23,7 @@ import {
deleteToVisualLineStart,
} from './visual-line-selection'
import { emitShortcutEvent } from '@/features/source-editor/extensions/toolbar/utils/analytics'
import { sendMB } from '@/infrastructure/event-tracking'
const toggleReviewPanel = () => {
window.dispatchEvent(new Event('ui.toggle-review-panel'))
@@ -30,6 +31,9 @@ const toggleReviewPanel = () => {
}
const addNewCommentFromKbdShortcut = () => {
sendMB('add-comment', {
location: 'shortcut',
})
window.dispatchEvent(new Event('add-new-review-comment'))
return true
}
@@ -5,6 +5,7 @@ import {
openSearchPanel,
searchPanelOpen,
} from '@codemirror/search'
import { sendMB } from '@/infrastructure/event-tracking'
import { toggleRanges, wrapRanges } from '../../commands/ranges'
import {
ancestorListType,
@@ -163,7 +164,10 @@ export const toggleSearch: Command = view => {
return true
}
export const addComment = () => {
export const addComment = (location: string) => {
sendMB('add-comment', {
location,
})
window.dispatchEvent(new Event('add-new-review-comment'))
}
@@ -26,6 +26,10 @@ import {
import { isVisual } from '../extensions/visual/visual'
import { useEditorContext } from '@/shared/context/editor-context'
import { useTrackingChangesMode } from '@/shared/hooks/use-tracking-changes-mode'
import {
sendContextMenuEvent,
ContextMenuItemSegmentation,
} from '../utils/context-menu-analytics'
export const useContextMenuItems = () => {
const view = useCodeMirrorViewContext()
@@ -45,7 +49,7 @@ export const useContextMenuItems = () => {
const { shortcuts } = useCommandRegistry()
const { features } = useProjectContext()
const requestedPdfSyncRef = useRef(false)
const { setShowUpgradeModal } = useEditorContext()
const { setUpgradeTrackChangesModal } = useEditorContext()
const trackingChangesMode = useTrackingChangesMode()
const isReview = trackingChangesMode === 'review'
@@ -87,40 +91,58 @@ export const useContextMenuItems = () => {
pdfUrl && pdfViewer !== 'native' && !visualPreviewEnabled && canSyncToPdf
const wrapForContextMenu = useCallback(
(command: () => Promise<boolean> | boolean) => async () => {
const result = await command()
if (result !== false) {
view.focus()
closeMenu()
}
},
(
item: ContextMenuItemSegmentation,
command: () => Promise<boolean> | boolean
) =>
async () => {
sendContextMenuEvent('menu-click', {
location: 'editor-context-menu',
item,
})
const result = await command()
if (result !== false) {
view.focus()
closeMenu()
}
},
[view, closeMenu]
)
const inVisualMode = isVisual(view)
const handleCut = wrapForContextMenu(() => cutSelection(view))
const handleCopy = wrapForContextMenu(() => copySelection(view))
const handlePaste = wrapForContextMenu(() =>
const handleCut = wrapForContextMenu('cut', () => cutSelection(view))
const handleCopy = wrapForContextMenu('copy', () => copySelection(view))
const handlePaste = wrapForContextMenu('paste', () =>
inVisualMode ? pasteWithFormatting(view) : pasteWithoutFormatting(view)
)
const handlePasteSpecial = wrapForContextMenu(() =>
inVisualMode ? pasteWithoutFormatting(view) : pasteWithFormatting(view)
const handlePasteSpecial = wrapForContextMenu(
inVisualMode ? 'paste-without-formatting' : 'paste-with-formatting',
() =>
inVisualMode ? pasteWithoutFormatting(view) : pasteWithFormatting(view)
)
const handleDelete = wrapForContextMenu('delete', () =>
commands.deleteSelection(view)
)
const handleDelete = wrapForContextMenu(() => commands.deleteSelection(view))
const handleToggleTrackChanges = wrapForContextMenu(() => {
// Matching the logic in review toggle to ensure consistency for server pro
if (!features.trackChanges && !isReview) {
setShowUpgradeModal(true)
const handleToggleTrackChanges = wrapForContextMenu(
wantTrackChanges ? 'back-to-editing' : 'suggest-edits',
() => {
// Matching the logic in review toggle to ensure consistency for server pro
if (!features.trackChanges && !isReview) {
setUpgradeTrackChangesModal({
show: true,
location: 'editor-context-menu',
})
return true
}
window.dispatchEvent(new Event('toggle-track-changes'))
return true
}
window.dispatchEvent(new Event('toggle-track-changes'))
return true
})
)
const handleComment = wrapForContextMenu(() => {
commands.addComment()
const handleComment = wrapForContextMenu('comment', () => {
commands.addComment('editor-context-menu')
return true
})
@@ -130,6 +152,15 @@ export const useContextMenuItems = () => {
if (isEditorOnly) {
changeLayout('sideBySide')
}
sendContextMenuEvent('menu-click', {
location: 'editor-context-menu',
item: 'jump-to-location-in-pdf',
})
sendContextMenuEvent('jump-to-location', {
method: 'editor-context-menu',
direction: 'code-location-in-pdf',
})
requestedPdfSyncRef.current = true
syncToPdf()
view.focus()
@@ -175,7 +175,7 @@ export const useToolbarMenuBarEditorCommands = () => {
id: 'comment',
label: t('comment'),
handler: () => {
commands.addComment()
commands.addComment('toolbar')
},
disabled: !comment || state.selection.main.empty,
},
@@ -0,0 +1,44 @@
import { sendMB } from '@/infrastructure/event-tracking'
export type ContextMenuItemSegmentation =
| 'cut'
| 'copy'
| 'paste'
| 'paste-without-formatting'
| 'paste-with-formatting'
| 'give-feedback'
| 'delete'
| 'jump-to-location-in-pdf'
| 'suggest-edits'
| 'back-to-editing'
| 'comment'
export type ContextMenuAnalyticsEvents = {
'menu-expand': {
location: 'editor-context-menu'
}
'menu-click': {
location: 'editor-context-menu'
item: ContextMenuItemSegmentation
}
'jump-to-location': {
method: 'editor-context-menu'
direction: 'code-location-in-pdf'
}
'add-comment': {
location: 'editor-context-menu'
}
'paywall-prompt': {
'paywall-type': 'track-changes'
location: 'editor-context-menu'
}
}
export const sendContextMenuEvent = <
T extends keyof ContextMenuAnalyticsEvents,
>(
eventName: T,
segmentation: ContextMenuAnalyticsEvents[T]
) => {
sendMB(eventName, segmentation)
}
@@ -20,6 +20,11 @@ import { WritefullAPI } from './types/writefull-instance'
import { Cobranding } from '../../../../types/cobranding'
import { SymbolWithCharacter } from '../../../../modules/symbol-palette/frontend/js/data/symbols'
type UpgradeTrackChangesModal = {
show: boolean
location?: string
}
export const EditorContext = createContext<
| {
cobranding?: Cobranding
@@ -35,8 +40,10 @@ export const EditorContext = createContext<
premiumSuggestionResetDate: Date
writefullInstance: WritefullAPI | null
setWritefullInstance: (instance: WritefullAPI) => void
showUpgradeModal: boolean
setShowUpgradeModal: Dispatch<SetStateAction<boolean>>
upgradeTrackChangesModal: UpgradeTrackChangesModal
setUpgradeTrackChangesModal: Dispatch<
SetStateAction<UpgradeTrackChangesModal>
>
}
| undefined
>(undefined)
@@ -88,7 +95,8 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
: new Date()
})
const [showUpgradeModal, setShowUpgradeModal] = useState(false)
const [showUpgradeModal, setShowUpgradeModal] =
useState<UpgradeTrackChangesModal>({ show: false })
const isPendingEditor = useMemo(
() =>
@@ -170,8 +178,8 @@ export const EditorProvider: FC<React.PropsWithChildren> = ({ children }) => {
setPremiumSuggestionResetDate,
writefullInstance,
setWritefullInstance,
showUpgradeModal,
setShowUpgradeModal,
upgradeTrackChangesModal: showUpgradeModal,
setUpgradeTrackChangesModal: setShowUpgradeModal,
}),
[
cobranding,
@@ -292,10 +292,10 @@ export function makeEditorProvider({
setPremiumSuggestionResetDate: () => {},
writefullInstance: null,
setWritefullInstance: () => {},
showUpgradeModal: false,
setShowUpgradeModal: () => {},
cobranding,
isRestrictedTokenMember,
upgradeTrackChangesModal: { show: false },
setUpgradeTrackChangesModal: () => {},
}
return (