Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/AppProviders.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
import { resources } from '@/resources'
import { PostHogPageview } from '@/components/analytics/PostHogPageview'
import { PostHogIdentify } from '@/components/analytics/PostHogIdentify'
import { CopyPasteTracking } from '@/components/analytics/CopyPasteTracking'
import { ErrorBoundary } from '@/components/analytics/ErrorBoundary'
import { SearchProvider } from '@/providers/search-provider'

Expand Down Expand Up @@ -99,6 +100,7 @@ export const AppProviders = ({ children }: { children: ReactNode }) => (
<UnsavedChangesNotifier />
<PostHogPageview />
<PostHogIdentify />
<CopyPasteTracking />
<DocumentTitleHandler handler={customTitleHandler} />
<LocalizationProvider dateAdapter={AdapterDayjs}>
<SearchProvider>
Expand Down
29 changes: 27 additions & 2 deletions src/analytics/posthog.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,11 @@ export const initPostHog = () => {
if (el?.type === 'password') {
return '*'.repeat(text.length)
}
// ph-no-mask is PostHog's standard class for opting inputs out of masking.
// Check the element and its ancestors so the class can be set on a wrapper.
if (
el?.hasAttribute?.('data-posthog-unmask-search') ||
el?.closest?.('[data-posthog-unmask-search]')
el?.classList?.contains('ph-no-mask') ||
el?.closest?.('.ph-no-mask')
) {
return text
}
Expand Down Expand Up @@ -109,3 +111,26 @@ export const resetUser = () => {
posthog.reset()
}

export const setPersonProperties = (
properties: Record<string, unknown>
) => {
if (!isEnabled || !initialized) return
posthog.setPersonProperties(properties)
}

/**
* Reads browser-level accessibility and display preferences once per
* session and returns them as PostHog person properties. Called after
* the user is identified so the values are attached to the person record.
*/
export const getAccessibilityProps = (): Record<string, unknown> => {
const rootFontSize = parseFloat(getComputedStyle(document.documentElement).fontSize)
return {
browser_font_size_px: rootFontSize,
font_size_increased: rootFontSize > 16,
device_pixel_ratio: window.devicePixelRatio ?? 1,
prefers_reduced_motion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
prefers_high_contrast: window.matchMedia('(prefers-contrast: more)').matches,
}
}

2 changes: 1 addition & 1 deletion src/components/SearchModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,7 @@ export const SearchModal = ({ open, onClose }: SearchModalProps) => {
sx={{ fontSize: 15 }}
inputProps={{
'aria-label': 'Search',
'data-posthog-unmask-search': true,
className: 'ph-no-mask',
}}
endAdornment={
state.query ? (
Expand Down
10 changes: 10 additions & 0 deletions src/components/analytics/CopyPasteTracking.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { useCopyPasteTracking } from '@/hooks/useCopyPasteTracking'

/**
* Renderless component that wires up copy/cut/paste event tracking for the
* entire app. Mount once inside AppProviders.
*/
export const CopyPasteTracking = () => {
useCopyPasteTracking()
return null
}
3 changes: 2 additions & 1 deletion src/components/analytics/PostHogIdentify.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
import { useEffect } from 'react'
import { useGetIdentity } from '@refinedev/core'
import { AuthentikIdentity } from '@/providers/authentik-provider'
import { identifyUser, resetUser } from '@/analytics/posthog'
import { identifyUser, resetUser, getAccessibilityProps, setPersonProperties } from '@/analytics/posthog'

export const PostHogIdentify = () => {
const { data: identity } = useGetIdentity<AuthentikIdentity>()

useEffect(() => {
if (identity?.id) {
identifyUser(identity.id, { name: identity.name, email: identity.email })
setPersonProperties(getAccessibilityProps())
} else {
resetUser()
}
Expand Down
42 changes: 42 additions & 0 deletions src/hooks/useCopyPasteTracking.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { useEffect } from 'react'
import { captureEvent } from '@/analytics/posthog'

/**
* Attaches document-level listeners for copy, cut, and paste events and
* fires PostHog events for each. The actual copied or pasted text is never
* captured — only the page path and, for copy/cut, the selection length.
* Cut is tracked as text_cut, separate from text_copied.
*/
export const useCopyPasteTracking = () => {
useEffect(() => {
const handleCopy = () => {
captureEvent('text_copied', {
path: window.location.pathname,
selection_length: window.getSelection()?.toString().length ?? 0,
})
}

const handleCut = () => {
captureEvent('text_cut', {
path: window.location.pathname,
selection_length: window.getSelection()?.toString().length ?? 0,
})
}

const handlePaste = () => {
captureEvent('text_pasted', {
path: window.location.pathname,
})
}

document.addEventListener('copy', handleCopy)
document.addEventListener('cut', handleCut)
document.addEventListener('paste', handlePaste)

return () => {
document.removeEventListener('copy', handleCopy)
document.removeEventListener('cut', handleCut)
document.removeEventListener('paste', handlePaste)
}
}, [])
}
Loading