Skip to content

Commit

Permalink
chore: add device types check (#1698)
Browse files Browse the repository at this point in the history
  • Loading branch information
marandaneto authored Jan 30, 2025
1 parent 45b90d6 commit 9bf2e35
Show file tree
Hide file tree
Showing 8 changed files with 126 additions and 30 deletions.
5 changes: 3 additions & 2 deletions src/__tests__/request-utils.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { getQueryParam, formDataToQuery, isUrlMatchingRegex, maskQueryParams } from '../utils/request-utils'
import { getQueryParam, formDataToQuery, maskQueryParams } from '../utils/request-utils'
import { isMatchingRegex } from '../utils/string-utils'

describe('request utils', () => {
describe('_HTTPBuildQuery', () => {
Expand Down Expand Up @@ -147,7 +148,7 @@ describe('request utils', () => {
['does not match route', 'https://example.com', 'example.com/test', false],
['does not match domain', 'https://example.com', 'anotherone.com', false],
])('%s', (_name, url, regex, expected) => {
expect(isUrlMatchingRegex(url, regex)).toEqual(expected)
expect(isMatchingRegex(url, regex)).toEqual(expected)
})
})
})
55 changes: 55 additions & 0 deletions src/__tests__/surveys.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { window } from '../utils/globals'
import { RequestRouter } from '../utils/request-router'
import { assignableWindow } from '../utils/globals'
import { generateSurveys } from '../extensions/surveys'
import * as globals from '../utils/globals'

describe('surveys', () => {
let config: PostHogConfig
Expand Down Expand Up @@ -351,6 +352,24 @@ describe('surveys', () => {
start_date: new Date().toISOString(),
end_date: null,
} as unknown as Survey
const surveyWithMobileDeviceType: Survey = {
name: 'survey with device type',
description: 'survey with device type description',
type: SurveyType.Popover,
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with device type?' }],
conditions: { deviceTypes: ['Android', 'iOS', 'Mobile'], deviceTypesMatchType: 'icontains' },
start_date: new Date().toISOString(),
end_date: null,
} as unknown as Survey
const surveyWithWebDeviceType: Survey = {
name: 'survey with device type',
description: 'survey with device type description',
type: SurveyType.Popover,
questions: [{ type: SurveyQuestionType.Open, question: 'what is a survey with device type?' }],
conditions: { deviceTypes: ['Web'], deviceTypesMatchType: 'icontains' },
start_date: new Date().toISOString(),
end_date: null,
} as unknown as Survey
const surveyWithUrlDoesNotContainRegex: Survey = {
name: 'survey with url does not contain regex',
description: 'survey with url does not contain regex description',
Expand Down Expand Up @@ -597,6 +616,42 @@ describe('surveys', () => {
assignableWindow.location = originalWindowLocation
})

it('returns surveys based on device types matching', () => {
surveysResponse = {
surveys: [surveyWithMobileDeviceType],
}

const userAgent =
'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'
// TS doesn't like it but we can assign userAgent
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
globals['userAgent'] = userAgent

// matching
surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([surveyWithMobileDeviceType])
})
})

it('returns surveys based on device types not matching', () => {
surveysResponse = {
surveys: [surveyWithWebDeviceType],
}

const userAgent =
'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'
// TS doesn't like it but we can assign userAgent
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
globals['userAgent'] = userAgent

// matching
surveys.getActiveMatchingSurveys((data) => {
expect(data).toEqual([])
})
})

it('returns surveys based on exclusion conditions', () => {
surveysResponse = {
surveys: [surveyWithUrlDoesNotContain, surveyWithIsNotUrlMatch, surveyWithUrlDoesNotContainRegex],
Expand Down
6 changes: 3 additions & 3 deletions src/extensions/surveys/action-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { SimpleEventEmitter } from '../../utils/simple-event-emitter'
import { CaptureResult } from '../../types'
import { isUndefined } from '../../utils/type-utils'
import { window } from '../../utils/globals'
import { isUrlMatchingRegex } from '../../utils/request-utils'
import { isMatchingRegex } from '../../utils/string-utils'

export class ActionMatcher {
private readonly actionRegistry?: Set<SurveyActionType>
Expand Down Expand Up @@ -121,7 +121,7 @@ export class ActionMatcher {
private static matchString(url: string, pattern: string, matching: ActionStepStringMatching): boolean {
switch (matching) {
case 'regex':
return !!window && isUrlMatchingRegex(url, pattern)
return !!window && isMatchingRegex(url, pattern)
case 'exact':
return pattern === url
case 'contains':
Expand All @@ -130,7 +130,7 @@ export class ActionMatcher {
const adjustedRegExpStringPattern = ActionMatcher.escapeStringRegexp(pattern)
.replace(/_/g, '.')
.replace(/%/g, '.*')
return isUrlMatchingRegex(url, adjustedRegExpStringPattern)
return isMatchingRegex(url, adjustedRegExpStringPattern)

default:
return false
Expand Down
6 changes: 4 additions & 2 deletions src/posthog-surveys-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export interface SurveyResponse {

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

export type SurveyUrlMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains'
export type SurveyMatchType = 'regex' | 'not_regex' | 'exact' | 'is_not' | 'icontains' | 'not_icontains'

export interface SurveyElement {
text?: string
Expand Down Expand Up @@ -165,7 +165,7 @@ export interface Survey {
url?: string
selector?: string
seenSurveyWaitPeriodInDays?: number
urlMatchType?: SurveyUrlMatchType
urlMatchType?: SurveyMatchType
events: {
repeatedActivation?: boolean
values: {
Expand All @@ -175,6 +175,8 @@ export interface Survey {
actions: {
values: SurveyActionType[]
} | null
deviceTypes?: string[]
deviceTypesMatchType?: SurveyMatchType
} | null
start_date: string | null
end_date: string | null
Expand Down
59 changes: 45 additions & 14 deletions src/posthog-surveys.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,26 +6,30 @@ import {
SurveyCallback,
SurveyQuestionBranchingType,
SurveyQuestionType,
SurveyUrlMatchType,
SurveyMatchType,
} from './posthog-surveys-types'
import { RemoteConfig } from './types'
import { assignableWindow, document, window } from './utils/globals'
import { Info } from './utils/event-utils'
import { assignableWindow, document, userAgent, window } from './utils/globals'
import { createLogger } from './utils/logger'
import { isUrlMatchingRegex } from './utils/request-utils'
import { isMatchingRegex } from './utils/string-utils'
import { SurveyEventReceiver } from './utils/survey-event-receiver'
import { isNullish } from './utils/type-utils'

const logger = createLogger('[Surveys]')

export const surveyUrlValidationMap: Record<SurveyUrlMatchType, (conditionsUrl: string) => boolean> = {
icontains: (conditionsUrl) =>
!!window && window.location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1,
not_icontains: (conditionsUrl) =>
!!window && window.location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) === -1,
regex: (conditionsUrl) => !!window && isUrlMatchingRegex(window.location.href, conditionsUrl),
not_regex: (conditionsUrl) => !!window && !isUrlMatchingRegex(window.location.href, conditionsUrl),
exact: (conditionsUrl) => window?.location.href === conditionsUrl,
is_not: (conditionsUrl) => window?.location.href !== conditionsUrl,
export const surveyValidationMap: Record<SurveyMatchType, (targets: string[], value: string) => boolean> = {
icontains: (targets, value) => targets.some((target) => value.toLowerCase().includes(target.toLowerCase())),

not_icontains: (targets, value) => targets.every((target) => !value.toLowerCase().includes(target.toLowerCase())),

regex: (targets, value) => targets.some((target) => isMatchingRegex(value, target)),

not_regex: (targets, value) => targets.every((target) => !isMatchingRegex(value, target)),

exact: (targets, value) => targets.some((target) => value === target),

is_not: (targets, value) => targets.every((target) => value !== target),
}

function getRatingBucketForResponseValue(responseValue: number, scale: number) {
Expand Down Expand Up @@ -131,13 +135,39 @@ export function getNextSurveyStep(
return nextQuestionIndex
}

function defaultMatchType(matchType?: SurveyMatchType): SurveyMatchType {
return matchType ?? 'icontains'
}

// use urlMatchType to validate url condition, fallback to contains for backwards compatibility
export function doesSurveyUrlMatch(survey: Survey): boolean {
if (!survey.conditions?.url) {
return true
}
// if we dont know the url, assume it is not a match
const href = window?.location?.href
if (!href) {
return false
}

const targets = [survey.conditions.url]
return surveyValidationMap[defaultMatchType(survey.conditions?.urlMatchType)](targets, href)
}

export function doesSurveyDeviceTypesMatch(survey: Survey): boolean {
if (!survey.conditions?.deviceTypes) {
return true
}
// if we dont know the device type, assume it is not a match
if (!userAgent) {
return false
}

return surveyUrlValidationMap[survey.conditions?.urlMatchType ?? 'icontains'](survey.conditions.url)
const deviceType = Info.deviceType(userAgent)
return surveyValidationMap[defaultMatchType(survey.conditions?.deviceTypesMatchType)](
survey.conditions.deviceTypes,
deviceType
)
}

export class PostHogSurveys {
Expand Down Expand Up @@ -284,7 +314,8 @@ export class PostHogSurveys {
const selectorCheck = survey.conditions?.selector
? document?.querySelector(survey.conditions.selector)
: true
return urlCheck && selectorCheck
const deviceTypeCheck = doesSurveyDeviceTypesMatch(survey)
return urlCheck && selectorCheck && deviceTypeCheck
})

// get all the surveys that have been activated so far with user actions.
Expand Down
7 changes: 1 addition & 6 deletions src/utils/request-utils.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { each, isValidRegex } from './'
import { each } from './'

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

export const isUrlMatchingRegex = function (url: string, pattern: string): boolean {
if (!isValidRegex(pattern)) return false
return new RegExp(pattern).test(url)
}

export const formDataToQuery = function (formdata: Record<string, any> | FormData, arg_separator = '&'): string {
let use_val: string
let use_key: string
Expand Down
11 changes: 11 additions & 0 deletions src/utils/string-utils.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { isValidRegex } from '.'

export function includes<T = any>(str: T[] | string, needle: T): boolean {
return (str as any).indexOf(needle) !== -1
}
Expand All @@ -14,3 +16,12 @@ export const stripLeadingDollar = function (s: string): string {
export function isDistinctIdStringLike(value: string): boolean {
return ['distinct_id', 'distinctid'].includes(value.toLowerCase())
}

export const isMatchingRegex = function (value: string, pattern: string): boolean {
if (!isValidRegex(pattern)) return false
try {
return new RegExp(pattern).test(value)
} catch {
return false
}
}
7 changes: 4 additions & 3 deletions src/web-experiments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ import {
} from './web-experiments-types'
import { WEB_EXPERIMENTS } from './constants'
import { isNullish, isString } from './utils/type-utils'
import { getQueryParam, isUrlMatchingRegex } from './utils/request-utils'
import { getQueryParam } from './utils/request-utils'
import { isMatchingRegex } from './utils/string-utils'
import { logger } from './utils/logger'
import { Info } from './utils/event-utils'
import { isLikelyBot } from './utils/blocked-uas'
Expand All @@ -22,8 +23,8 @@ export const webExperimentUrlValidationMap: Record<
!!window && location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) > -1,
not_icontains: (conditionsUrl, location) =>
!!window && location.href.toLowerCase().indexOf(conditionsUrl.toLowerCase()) === -1,
regex: (conditionsUrl, location) => !!window && isUrlMatchingRegex(location.href, conditionsUrl),
not_regex: (conditionsUrl, location) => !!window && !isUrlMatchingRegex(location.href, conditionsUrl),
regex: (conditionsUrl, location) => !!window && isMatchingRegex(location.href, conditionsUrl),
not_regex: (conditionsUrl, location) => !!window && !isMatchingRegex(location.href, conditionsUrl),
exact: (conditionsUrl, location) => location.href === conditionsUrl,
is_not: (conditionsUrl, location) => location.href !== conditionsUrl,
}
Expand Down

0 comments on commit 9bf2e35

Please sign in to comment.