-
Notifications
You must be signed in to change notification settings - Fork 84
feat(surveys): re-translate displayed survey on language change #686
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
1187fb4
aa78ad6
dcd38ba
063e23f
f0b334d
f11d037
3a2d560
dbb6435
58ba382
201da09
c7874ee
526e03b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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. |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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? | ||
|
|
@@ -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 | ||
|
|
@@ -87,6 +96,7 @@ | |
| eventCapturedToken = nil | ||
| didBecomeActiveToken = nil | ||
| didLayoutViewToken = nil | ||
| personPropertiesChangedToken = nil | ||
| #if os(iOS) | ||
| if #available(iOS 15.0, *) { | ||
| config?.surveysConfig.surveysDelegate.cleanupSurveys() | ||
|
|
@@ -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 } | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [question] If a refresh lands in the window after |
||
| self.activeSurveyLanguage = translations.matchedKey | ||
|
ioannisj marked this conversation as resolved.
|
||
| self.activeSurveyQuestionTranslations = translations.questions | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. [question] Once this commits mid-survey, |
||
|
|
||
| return activeSurvey.toDisplaySurvey( | ||
| surveyTranslation: translations.survey, | ||
| questionTranslations: translations.questions | ||
| ) | ||
| } | ||
|
|
||
| guard let displaySurvey else { return } | ||
|
|
||
| DispatchQueue.main.async { | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| updateSurvey(displaySurvey) | ||
| } | ||
| } | ||
|
|
||
| /// Returns the computed storage key for a given survey | ||
| private func getSurveySeenKey(_ survey: PostHogSurvey) -> String { | ||
| let surveySeenKey = "\(kSurveySeenKeyPrefix)\(survey.id)" | ||
|
|
@@ -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 { | ||
|
|
@@ -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)? { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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`. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 |
||
| @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() | ||
|
|
||
There was a problem hiding this comment.
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()carryinguserPropertiesre-runs translation resolution underactiveSurveyLockwhile a survey is up. Skipping the invoke when no merged key changed value would keep it off hot paths.