Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/survey-live-language-update.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog-ios": minor
---

Surveys now re-translate in place while displayed. When the user's `language` person property changes (via `identify`/`setPersonProperties`) and a matching translation exists, the on-screen survey updates to the new language without restarting, preserving the current question and progress. Custom survey delegates can adopt the new optional `updateSurvey(_:)` to support live updates.
4 changes: 4 additions & 0 deletions PostHog.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,7 @@
DAFEC4572E03F91A0064535E /* PostHogDisplaySurveyQuestion.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFEC4562E03F91A0064535E /* PostHogDisplaySurveyQuestion.swift */; };
DAFEC4592E03F93A0064535E /* PostHogDisplaySurveyAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFEC4582E03F93A0064535E /* PostHogDisplaySurveyAppearance.swift */; };
DAFF026B2D7B1A9800BD5B1D /* SurveysRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFF02652D7B1A9000BD5B1D /* SurveysRootView.swift */; };
557AFD30A7E167D223F89C2C /* PostHogSurveyMatching.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37A547D32B55E849A46CA13A /* PostHogSurveyMatching.swift */; };
DAFF026D2D7B2F9200BD5B1D /* SurveyDisplayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = DAFF026C2D7B2F8D00BD5B1D /* SurveyDisplayController.swift */; };
DB7100012F80000100000001 /* PostHogTracingHeadersIntegration.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7100022F80000100000001 /* PostHogTracingHeadersIntegration.swift */; };
DB7100042F80000100000001 /* PostHogTracingHeadersTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB7100052F80000100000001 /* PostHogTracingHeadersTest.swift */; };
Expand Down Expand Up @@ -1387,6 +1388,7 @@
DAFEC4562E03F91A0064535E /* PostHogDisplaySurveyQuestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDisplaySurveyQuestion.swift; sourceTree = "<group>"; };
DAFEC4582E03F93A0064535E /* PostHogDisplaySurveyAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDisplaySurveyAppearance.swift; sourceTree = "<group>"; };
DAFF02652D7B1A9000BD5B1D /* SurveysRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveysRootView.swift; sourceTree = "<group>"; };
37A547D32B55E849A46CA13A /* PostHogSurveyMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyMatching.swift; sourceTree = "<group>"; };
DAFF026C2D7B2F8D00BD5B1D /* SurveyDisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyDisplayController.swift; sourceTree = "<group>"; };
DB51A204FB088EC14375B43D /* PLCrashReportThreadInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportThreadInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportThreadInfo.m; sourceTree = "<group>"; };
DB7100022F80000100000001 /* PostHogTracingHeadersIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogTracingHeadersIntegration.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -1890,6 +1892,7 @@
DA263D412D800856004C100D /* SurveyButton.swift */,
DA263D392D7F1054004C100D /* Resources.swift */,
9473A633747872EA43FDCA3A /* SurveyTranslationResolver.swift */,
37A547D32B55E849A46CA13A /* PostHogSurveyMatching.swift */,
);
path = Utils;
sourceTree = "<group>";
Expand Down Expand Up @@ -3078,6 +3081,7 @@
buildActionMask = 2147483647;
files = (
DAFF026B2D7B1A9800BD5B1D /* SurveysRootView.swift in Sources */,
557AFD30A7E167D223F89C2C /* PostHogSurveyMatching.swift in Sources */,
DAFEC44D2E03F8D90064535E /* PostHogSurveyResponse.swift in Sources */,
690FF05F2AE7E2D400A0B06B /* Data+Gzip.swift in Sources */,
DA9AE9672D8F1C41002F1B44 /* SurveyPresentationDetentsRepresentable.swift in Sources */,
Expand Down
10 changes: 10 additions & 0 deletions PostHog/PostHogRemoteConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,11 @@ class PostHogRemoteConfig {

let onRemoteConfigLoaded = PostHogMulticastCallback<[String: Any]?>()
let onFeatureFlagsLoaded = PostHogMulticastCallback<[String: Any]?>()
/// Invoked (synchronously, on the calling thread) right after the person properties used
/// for feature flag evaluation change β€” via `identify`, `setPersonProperties`, or
/// `setPersonPropertiesForFlags`. Used internally to re-resolve the language of a survey
/// that is currently on screen so it follows updates to the user's `language` property.
let onPersonPropertiesForFlagsChanged = PostHogMulticastCallback<Void>()

private let dispatchQueue = DispatchQueue(label: "com.posthog.RemoteConfig",
target: .global(qos: .utility))
Expand Down Expand Up @@ -752,6 +757,10 @@ class PostHogRemoteConfig {
// Persist to disk
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
}
// Notify subscribers (e.g. surveys) so a survey already on screen can re-resolve its
// language if the user's `language` property changed. Invoked outside the lock so
// subscribers don't run while we hold it.
onPersonPropertiesForFlagsChanged.invoke(())

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nit] This fires even when the merge changed nothing, so every capture() carrying userProperties re-runs translation resolution under activeSurveyLock while a survey is up. Skipping the invoke when no merged key changed value would keep it off hot paths.

}

func resetPersonPropertiesForFlags() {
Expand All @@ -760,6 +769,7 @@ class PostHogRemoteConfig {
// Clear from disk
storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags)
}
onPersonPropertiesForFlagsChanged.invoke(())
}

func setGroupPropertiesForFlags(_ groupType: String, properties: [String: Any]) {
Expand Down
185 changes: 64 additions & 121 deletions PostHog/Surveys/PostHogSurveyIntegration.swift
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@
private var didBecomeActiveToken: RegistrationToken?
private var didLayoutViewToken: RegistrationToken?
private var eventCapturedToken: RegistrationToken?
private var personPropertiesChangedToken: RegistrationToken?

private var activeSurveyLock = NSLock()
private var activeSurvey: PostHogSurvey?
Expand All @@ -66,6 +67,14 @@
}

func start() {
// Re-resolve the language of a survey already on screen whenever the person
// properties used for flags change (e.g. the user's `language` is updated). Not
// gated on `os(iOS)` so the resolution logic stays exercised under TESTING; the
// actual UI update is a no-op off iOS.
personPropertiesChangedToken = postHog?.remoteConfig?.onPersonPropertiesForFlagsChanged.subscribe { [weak self] _ in
self?.refreshActiveSurveyTranslations()
}

#if os(iOS)
// Subscribe to event captures
eventCapturedToken = postHog?.onEventCaptured.subscribe { [weak self] event in
Expand All @@ -87,6 +96,7 @@
eventCapturedToken = nil
didBecomeActiveToken = nil
didLayoutViewToken = nil
personPropertiesChangedToken = nil
#if os(iOS)
if #available(iOS 15.0, *) {
config?.surveysConfig.surveysDelegate.cleanupSurveys()
Expand Down Expand Up @@ -318,6 +328,50 @@
#endif
}

/// Re-resolves the language of the survey currently on screen and, if it changed,
/// pushes the freshly translated content to the delegate for an in-place update.
///
/// Triggered when the person properties used for flags change (e.g. the user's
/// `language` property is updated while a survey is displayed). No-op when the
/// delegate doesn't implement `updateSurvey`, no survey is active, or the resolved
/// language is unchanged, so it never re-stamps `$survey_language` or re-renders
/// when nothing visible would change.
private func refreshActiveSurveyTranslations() {
// `updateSurvey` is optional: when the delegate doesn't implement it (e.g. a custom
// delegate that predates live updates), skip the refresh entirely so the tracked
// language never advances past what's actually on screen.
guard #available(iOS 15.0, *),
let updateSurvey = postHog?.config._surveysConfig.surveysDelegate.updateSurvey
else { return }

// Resolve and commit under a single hold of `activeSurveyLock`: with separate
// read/commit sections, overlapping refreshes (e.g. racing `setPersonProperties`
// calls with different languages) could commit out of order and leave the survey
// on a stale language.
let displaySurvey: PostHogDisplaySurvey? = activeSurveyLock.withLock {
guard let activeSurvey = self.activeSurvey else { return nil }

let language = resolveDisplayLanguage()
let translations = resolveSurveyTranslations(survey: activeSurvey, targetLanguage: language)

// Commit only if the update targets a different language than what's currently shown
guard self.activeSurveyLanguage != translations.matchedKey else { return nil }

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] If a refresh lands in the window after setActiveSurvey but before the initial renderSurvey reaches the controller, the language commits here but the dispatched update is dropped (displayedSurvey is nil, no pending survey) β€” and the next refresh no-ops since the language already matches. Tiny window, sticky outcome. Worth reconciling in handleSurveyShown when the shown snapshot's language differs from activeSurveyLanguage?

self.activeSurveyLanguage = translations.matchedKey
Comment thread
ioannisj marked this conversation as resolved.
self.activeSurveyQuestionTranslations = translations.questions

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[question] Once this commits mid-survey, survey sent stamps all $survey_questions (and $survey_language) with the final translations β€” answers given before the switch are reported with question text the user never saw, and survey shown vs sent disagree on the language. Acceptable for analytics, or should we freeze the language after the first response?


return activeSurvey.toDisplaySurvey(
surveyTranslation: translations.survey,
questionTranslations: translations.questions
)
}

guard let displaySurvey else { return }

DispatchQueue.main.async {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] The lock comment above guarantees commit order, but this enqueue happens after the lock is released — two racing refreshes can commit fr→de yet enqueue de→fr, leaving the screen on fr while activeSurveyLanguage (and $survey_language) say de. Moving the DispatchQueue.main.async inside the withLock block makes enqueue order match commit order (async enqueue doesn't block).

updateSurvey(displaySurvey)
}
}

/// Returns the computed storage key for a given survey
private func getSurveySeenKey(_ survey: PostHogSurvey) -> String {
let surveySeenKey = "\(kSurveySeenKeyPrefix)\(survey.id)"
Expand Down Expand Up @@ -935,125 +989,6 @@
}
}

enum NextSurveyQuestion {
case index(Int)
case end
}

extension PostHogSurvey: CustomStringConvertible {
var description: String {
"\(name) [\(id)]"
}
}

extension PostHogSurvey {
var isActive: Bool {
startDate != nil && endDate == nil
}

var hasEvents: Bool {
conditions?.events?.values.count ?? 0 > 0
}

var canActivateRepeatedly: Bool {
(conditions?.events?.repeatedActivation == true && hasEvents) ||
schedule == .always
}
}

private extension PostHogSurveyMatchType {
func matches(targets: [String], value: String) -> Bool {
switch self {
// value contains any of the targets (case-insensitive)
case .iContains:
targets.contains { target in
value.lowercased().contains(target.lowercased())
}
// value contains *none* of the targets (case-insensitive)
case .notIContains:
targets.allSatisfy { target in
!value.lowercased().contains(target.lowercased())
}
// value matches any of the targets as a regex pattern
case .regex:
targets.contains { target in
value.range(of: target, options: .regularExpression) != nil
}
// value matches *none* of the targets as a regex pattern
case .notRegex:
targets.allSatisfy { target in
value.range(of: target, options: .regularExpression) == nil
}
// any of the targets is an exact match
case .exact:
targets.contains { target in
target == value
}
// *none* of the targets is an exact match
case .isNot:
targets.allSatisfy { target in
target != value
}
// any of the targets is numerically less than the value (value > target)
case .gt:
targets.contains { target in
if let targetNum = Double(target), let valueNum = Double(value) {
return valueNum > targetNum
}
return false
}
// any of the targets is numerically greater than the value (value < target)
case .lt:
targets.contains { target in
if let targetNum = Double(target), let valueNum = Double(value) {
return valueNum < targetNum
}
return false
}
case .unknown:
false
}
}
}

private enum RatingBucket {
// Bucket names
static let negative = "negative"
static let neutral = "neutral"
static let positive = "positive"
static let detractors = "detractors"
static let passives = "passives"
static let promoters = "promoters"

// Scale ranges
static let threePointRange = 1 ... 3
static let fivePointRange = 1 ... 5
static let sevenPointRange = 1 ... 7
static let tenPointRange = 0 ... 10
}

private enum BucketThresholds {
enum ThreePoint {
static let negatives = 1 ... 1
static let neutrals = 2 ... 2
}

enum FivePoint {
static let negatives = 1 ... 2
static let neutrals = 3 ... 3
}

enum SevenPoint {
static let negatives = 1 ... 3
static let neutrals = 4 ... 4
}

enum TenPoint {
static let detractors = 0 ... 6
static let passives = 7 ... 8
}
}

#if TESTING
extension PostHogSurveyMatchType {
var matchFunction: (_ targets: [String], _ value: String) -> Bool {
Expand All @@ -1066,9 +1001,17 @@
allSurveys = surveys
}

func setShownSurvey(_ survey: PostHogSurvey) {
func setShownSurvey(_ survey: PostHogSurvey, language: String? = nil, questionTranslations: [PostHogSurveyQuestionTranslation?]? = nil) {
clearActiveSurvey()
setActiveSurvey(survey: survey)
setActiveSurvey(survey: survey, language: language, questionTranslations: questionTranslations)
}

var testActiveSurveyLanguage: String? {
activeSurveyLock.withLock { self.activeSurveyLanguage }
}

func testRefreshActiveSurveyTranslations() {
refreshActiveSurveyTranslations()
}

func getNextQuestion(index: Int, response: PostHogSurveyResponse) -> (Int, Bool)? {
Expand Down
12 changes: 12 additions & 0 deletions PostHog/Surveys/PostHogSurveysConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,18 @@ public typealias OnPostHogSurveyClosed = (_ survey: PostHogDisplaySurvey) -> Voi
onSurveyClosed: @escaping OnPostHogSurveyClosed
)

/// Called when the content of the currently displayed survey changes without restarting it,
/// for example when the user's language is updated and a new translation becomes available.
///
/// Implementations should update the on-screen survey in place β€” preserving the current
/// question, progress, and any in-progress answers β€” rather than presenting it again.
/// Optional: delegates that don't support live updates can omit it and the survey simply
/// keeps the language it was first rendered with.
///
/// - Parameter survey: The survey with refreshed (e.g. re-translated) content. Its `id`
/// matches the survey passed to `renderSurvey`.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[suggestion] Two contract points a custom-delegate author needs here: this is always delivered on the main thread, and it can arrive before the survey is visibly shown (e.g. during surveyPopupDelaySeconds β€” that's why the default delegate refreshes pendingSurvey). A delegate with its own display delay will silently drop the update otherwise. Could we add both to the doc comment?

@objc optional func updateSurvey(_ survey: PostHogDisplaySurvey)

/// Called when surveys are stopped to clean up any UI elements and reset the survey display state.
/// This method should handle the dismissal of any active surveys and cleanup of associated resources.
@objc func cleanupSurveys()
Expand Down
32 changes: 29 additions & 3 deletions PostHog/Surveys/PostHogSurveysDefaultDelegate.swift
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate {
#if os(iOS)
guard #available(iOS 15.0, *) else { return }

if surveysWindow == nil {
if displayController == nil {
// setup window for first-time display
setupWindow()
}
Expand All @@ -49,6 +49,20 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate {
#endif
}

func updateSurvey(_ survey: PostHogDisplaySurvey) {
#if os(iOS)
guard #available(iOS 15.0, *) else { return }

// If the survey is still waiting out its display delay, refresh the queued copy so
// it gets shown with the latest content.
if pendingSurvey?.id == survey.id {
pendingSurvey = survey
}

displayController?.updateSurvey(survey)
#endif
}

func cleanupSurveys() {
#if os(iOS)
dismissPendingSurveyIfNeeded()
Expand Down Expand Up @@ -98,15 +112,18 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate {

let workItem = DispatchWorkItem { [weak self] in
guard let self else { return }
guard self.pendingSurvey?.id == survey.id else {
// Read the latest pending copy: `updateSurvey` may have replaced it with a
// re-translated version (same id) while the delay was counting down. Showing
// the captured `survey` here would drop that update.
guard let pending = self.pendingSurvey, pending.id == survey.id else {
// current survey?
return
}

self.pendingDisplayWorkItem = nil
self.pendingSurvey = nil
self.pendingSurveyClosedHandler = nil
displayController.showSurvey(survey)
displayController.showSurvey(pending)
}

pendingDisplayWorkItem = workItem
Expand All @@ -126,3 +143,12 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate {
}
#endif
}

#if os(iOS) && TESTING
extension PostHogSurveysDefaultDelegate {
/// Injects a display controller so tests can drive rendering without a `UIWindowScene`
func setDisplayControllerForTesting(_ controller: SurveyDisplayController) {
displayController = controller
}
}
#endif
Loading
Loading