Skip to content

Commit 9bf2e35

Browse files
authored
chore: add device types check (#1698)
1 parent 45b90d6 commit 9bf2e35

File tree

8 files changed

+126
-30
lines changed

8 files changed

+126
-30
lines changed

src/__tests__/request-utils.test.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { getQueryParam, formDataToQuery, isUrlMatchingRegex, maskQueryParams } from '../utils/request-utils'
1+
import { getQueryParam, formDataToQuery, maskQueryParams } from '../utils/request-utils'
2+
import { isMatchingRegex } from '../utils/string-utils'
23

34
describe('request utils', () => {
45
describe('_HTTPBuildQuery', () => {
@@ -147,7 +148,7 @@ describe('request utils', () => {
147148
['does not match route', 'https://example.com', 'example.com/test', false],
148149
['does not match domain', 'https://example.com', 'anotherone.com', false],
149150
])('%s', (_name, url, regex, expected) => {
150-
expect(isUrlMatchingRegex(url, regex)).toEqual(expected)
151+
expect(isMatchingRegex(url, regex)).toEqual(expected)
151152
})
152153
})
153154
})

src/__tests__/surveys.test.ts

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { window } from '../utils/globals'
2222
import { RequestRouter } from '../utils/request-router'
2323
import { assignableWindow } from '../utils/globals'
2424
import { generateSurveys } from '../extensions/surveys'
25+
import * as globals from '../utils/globals'
2526

2627
describe('surveys', () => {
2728
let config: PostHogConfig
@@ -351,6 +352,24 @@ describe('surveys', () => {
351352
start_date: new Date().toISOString(),
352353
end_date: null,
353354
} as unknown as Survey
355+
const surveyWithMobileDeviceType: Survey = {
356+
name: 'survey with device type',
357+
description: 'survey with device type description',
358+
type: SurveyType.Popover,
359+
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with device type?' }],
360+
conditions: { deviceTypes: ['Android', 'iOS', 'Mobile'], deviceTypesMatchType: 'icontains' },
361+
start_date: new Date().toISOString(),
362+
end_date: null,
363+
} as unknown as Survey
364+
const surveyWithWebDeviceType: Survey = {
365+
name: 'survey with device type',
366+
description: 'survey with device type description',
367+
type: SurveyType.Popover,
368+
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with device type?' }],
369+
conditions: { deviceTypes: ['Web'], deviceTypesMatchType: 'icontains' },
370+
start_date: new Date().toISOString(),
371+
end_date: null,
372+
} as unknown as Survey
354373
const surveyWithUrlDoesNotContainRegex: Survey = {
355374
name: 'survey with url does not contain regex',
356375
description: 'survey with url does not contain regex description',
@@ -597,6 +616,42 @@ describe('surveys', () => {
597616
assignableWindow.location = originalWindowLocation
598617
})
599618

619+
it('returns surveys based on device types matching', () => {
620+
surveysResponse = {
621+
surveys: [surveyWithMobileDeviceType],
622+
}
623+
624+
const userAgent =
625+
'Mozilla/5.0 (Linux; U; Android-4.0.3; en-us; Galaxy Nexus Build/IML74K) AppleWebKit/535.7 (KHTML, like Gecko) CrMo/16.0.912.75 Mobile Safari/535.7'
626+
// TS doesn't like it but we can assign userAgent
627+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
628+
// @ts-ignore
629+
globals['userAgent'] = userAgent
630+
631+
// matching
632+
surveys.getActiveMatchingSurveys((data) => {
633+
expect(data).toEqual([surveyWithMobileDeviceType])
634+
})
635+
})
636+
637+
it('returns surveys based on device types not matching', () => {
638+
surveysResponse = {
639+
surveys: [surveyWithWebDeviceType],
640+
}
641+
642+
const userAgent =
643+
'Mozilla/5.0 (Linux; U; Android-4.0.3; en-us; Galaxy Nexus Build/IML74K) AppleWebKit/535.7 (KHTML, like Gecko) CrMo/16.0.912.75 Mobile Safari/535.7'
644+
// TS doesn't like it but we can assign userAgent
645+
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
646+
// @ts-ignore
647+
globals['userAgent'] = userAgent
648+
649+
// matching
650+
surveys.getActiveMatchingSurveys((data) => {
651+
expect(data).toEqual([])
652+
})
653+
})
654+
600655
it('returns surveys based on exclusion conditions', () => {
601656
surveysResponse = {
602657
surveys: [surveyWithUrlDoesNotContain, surveyWithIsNotUrlMatch, surveyWithUrlDoesNotContainRegex],

src/extensions/surveys/action-matcher.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { SimpleEventEmitter } from '../../utils/simple-event-emitter'
44
import { CaptureResult } from '../../types'
55
import { isUndefined } from '../../utils/type-utils'
66
import { window } from '../../utils/globals'
7-
import { isUrlMatchingRegex } from '../../utils/request-utils'
7+
import { isMatchingRegex } from '../../utils/string-utils'
88

99
export class ActionMatcher {
1010
private readonly actionRegistry?: Set<SurveyActionType>
@@ -121,7 +121,7 @@ export class ActionMatcher {
121121
private static matchString(url: string, pattern: string, matching: ActionStepStringMatching): boolean {
122122
switch (matching) {
123123
case 'regex':
124-
return !!window && isUrlMatchingRegex(url, pattern)
124+
return !!window && isMatchingRegex(url, pattern)
125125
case 'exact':
126126
return pattern === url
127127
case 'contains':
@@ -130,7 +130,7 @@ export class ActionMatcher {
130130
const adjustedRegExpStringPattern = ActionMatcher.escapeStringRegexp(pattern)
131131
.replace(/_/g, '.')
132132
.replace(/%/g, '.*')
133-
return isUrlMatchingRegex(url, adjustedRegExpStringPattern)
133+
return isMatchingRegex(url, adjustedRegExpStringPattern)
134134

135135
default:
136136
return false

src/posthog-surveys-types.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export interface SurveyResponse {
123123

124124
export type SurveyCallback = (surveys: Survey[]) => void
125125

126-
export type SurveyUrlMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains'
126+
export type SurveyMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains'
127127

128128
export interface SurveyElement {
129129
text?: string
@@ -165,7 +165,7 @@ export interface Survey {
165165
url?: string
166166
selector?: string
167167
seenSurveyWaitPeriodInDays?: number
168-
urlMatchType?: SurveyUrlMatchType
168+
urlMatchType?: SurveyMatchType
169169
events: {
170170
repeatedActivation?: boolean
171171
values: {
@@ -175,6 +175,8 @@ export interface Survey {
175175
actions: {
176176
values: SurveyActionType[]
177177
} | null
178+
deviceTypes?: string[]
179+
deviceTypesMatchType?: SurveyMatchType
178180
} | null
179181
start_date: string | null
180182
end_date: string | null

src/posthog-surveys.ts

Lines changed: 45 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -6,26 +6,30 @@ import {
66
SurveyCallback,
77
SurveyQuestionBranchingType,
88
SurveyQuestionType,
9-
SurveyUrlMatchType,
9+
SurveyMatchType,
1010
} from './posthog-surveys-types'
1111
import { RemoteConfig } from './types'
12-
import { assignableWindow, document, window } from './utils/globals'
12+
import { Info } from './utils/event-utils'
13+
import { assignableWindow, document, userAgent, window } from './utils/globals'
1314
import { createLogger } from './utils/logger'
14-
import { isUrlMatchingRegex } from './utils/request-utils'
15+
import { isMatchingRegex } from './utils/string-utils'
1516
import { SurveyEventReceiver } from './utils/survey-event-receiver'
1617
import { isNullish } from './utils/type-utils'
1718

1819
const logger = createLogger('[Surveys]')
1920

20-
export const surveyUrlValidationMap: Record<SurveyUrlMatchType, (conditionsUrl: string) => boolean> = {
21-
icontains: (conditionsUrl) =>
22-
!!window && window.location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1,
23-
not_icontains: (conditionsUrl) =>
24-
!!window && window.location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) === -1,
25-
regex: (conditionsUrl) => !!window && isUrlMatchingRegex(window.location.href, conditionsUrl),
26-
not_regex: (conditionsUrl) => !!window && !isUrlMatchingRegex(window.location.href, conditionsUrl),
27-
exact: (conditionsUrl) => window?.location.href === conditionsUrl,
28-
is_not: (conditionsUrl) => window?.location.href !== conditionsUrl,
21+
export const surveyValidationMap: Record<SurveyMatchType, (targets: string[], value: string) => boolean> = {
22+
icontains: (targets, value) => targets.some((target) => value.toLowerCase().includes(target.toLowerCase())),
23+
24+
not_icontains: (targets, value) => targets.every((target) => !value.toLowerCase().includes(target.toLowerCase())),
25+
26+
regex: (targets, value) => targets.some((target) => isMatchingRegex(value, target)),
27+
28+
not_regex: (targets, value) => targets.every((target) => !isMatchingRegex(value, target)),
29+
30+
exact: (targets, value) => targets.some((target) => value === target),
31+
32+
is_not: (targets, value) => targets.every((target) => value !== target),
2933
}
3034

3135
function getRatingBucketForResponseValue(responseValue: number, scale: number) {
@@ -131,13 +135,39 @@ export function getNextSurveyStep(
131135
return nextQuestionIndex
132136
}
133137

138+
function defaultMatchType(matchType?: SurveyMatchType): SurveyMatchType {
139+
return matchType ?? 'icontains'
140+
}
141+
134142
// use urlMatchType to validate url condition, fallback to contains for backwards compatibility
135143
export function doesSurveyUrlMatch(survey: Survey): boolean {
136144
if (!survey.conditions?.url) {
137145
return true
138146
}
147+
// if we dont know the url, assume it is not a match
148+
const href = window?.location?.href
149+
if (!href) {
150+
return false
151+
}
152+
153+
const targets = [survey.conditions.url]
154+
return surveyValidationMap[defaultMatchType(survey.conditions?.urlMatchType)](targets, href)
155+
}
156+
157+
export function doesSurveyDeviceTypesMatch(survey: Survey): boolean {
158+
if (!survey.conditions?.deviceTypes) {
159+
return true
160+
}
161+
// if we dont know the device type, assume it is not a match
162+
if (!userAgent) {
163+
return false
164+
}
139165

140-
return surveyUrlValidationMap[survey.conditions?.urlMatchType ?? 'icontains'](survey.conditions.url)
166+
const deviceType = Info.deviceType(userAgent)
167+
return surveyValidationMap[defaultMatchType(survey.conditions?.deviceTypesMatchType)](
168+
survey.conditions.deviceTypes,
169+
deviceType
170+
)
141171
}
142172

143173
export class PostHogSurveys {
@@ -284,7 +314,8 @@ export class PostHogSurveys {
284314
const selectorCheck = survey.conditions?.selector
285315
? document?.querySelector(survey.conditions.selector)
286316
: true
287-
return urlCheck && selectorCheck
317+
const deviceTypeCheck = doesSurveyDeviceTypesMatch(survey)
318+
return urlCheck && selectorCheck && deviceTypeCheck
288319
})
289320

290321
// get all the surveys that have been activated so far with user actions.

src/utils/request-utils.ts

Lines changed: 1 addition & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { each, isValidRegex } from './'
1+
import { each } from './'
22

33
import { isArray, isFile, isUndefined } from './type-utils'
44
import { logger } from './logger'
@@ -22,11 +22,6 @@ export const convertToURL = (url: string): HTMLAnchorElement | null => {
2222
return location
2323
}
2424

25-
export const isUrlMatchingRegex = function (url: string, pattern: string): boolean {
26-
if (!isValidRegex(pattern)) return false
27-
return new RegExp(pattern).test(url)
28-
}
29-
3025
export const formDataToQuery = function (formdata: Record<string, any> | FormData, arg_separator = '&'): string {
3126
let use_val: string
3227
let use_key: string

src/utils/string-utils.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
import { isValidRegex } from '.'
2+
13
export function includes<T = any>(str: T[] | string, needle: T): boolean {
24
return (str as any).indexOf(needle) !== -1
35
}
@@ -14,3 +16,12 @@ export const stripLeadingDollar = function (s: string): string {
1416
export function isDistinctIdStringLike(value: string): boolean {
1517
return ['distinct_id', 'distinctid'].includes(value.toLowerCase())
1618
}
19+
20+
export const isMatchingRegex = function (value: string, pattern: string): boolean {
21+
if (!isValidRegex(pattern)) return false
22+
try {
23+
return new RegExp(pattern).test(value)
24+
} catch {
25+
return false
26+
}
27+
}

src/web-experiments.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import {
99
} from './web-experiments-types'
1010
import { WEB_EXPERIMENTS } from './constants'
1111
import { isNullish, isString } from './utils/type-utils'
12-
import { getQueryParam, isUrlMatchingRegex } from './utils/request-utils'
12+
import { getQueryParam } from './utils/request-utils'
13+
import { isMatchingRegex } from './utils/string-utils'
1314
import { logger } from './utils/logger'
1415
import { Info } from './utils/event-utils'
1516
import { isLikelyBot } from './utils/blocked-uas'
@@ -22,8 +23,8 @@ export const webExperimentUrlValidationMap: Record<
2223
!!window && location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1,
2324
not_icontains: (conditionsUrl, location) =>
2425
!!window && location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) === -1,
25-
regex: (conditionsUrl, location) => !!window && isUrlMatchingRegex(location.href, conditionsUrl),
26-
not_regex: (conditionsUrl, location) => !!window && !isUrlMatchingRegex(location.href, conditionsUrl),
26+
regex: (conditionsUrl, location) => !!window && isMatchingRegex(location.href, conditionsUrl),
27+
not_regex: (conditionsUrl, location) => !!window && !isMatchingRegex(location.href, conditionsUrl),
2728
exact: (conditionsUrl, location) => location.href === conditionsUrl,
2829
is_not: (conditionsUrl, location) => location.href !== conditionsUrl,
2930
}

0 commit comments

Comments
 (0)