diff --git a/.changeset/survey-live-language-update.md b/.changeset/survey-live-language-update.md new file mode 100644 index 0000000000..415093dbdc --- /dev/null +++ b/.changeset/survey-live-language-update.md @@ -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. diff --git a/PostHog.xcodeproj/project.pbxproj b/PostHog.xcodeproj/project.pbxproj index 3359485253..ef3d3e3b12 100644 --- a/PostHog.xcodeproj/project.pbxproj +++ b/PostHog.xcodeproj/project.pbxproj @@ -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 */; }; @@ -1387,6 +1388,7 @@ DAFEC4562E03F91A0064535E /* PostHogDisplaySurveyQuestion.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDisplaySurveyQuestion.swift; sourceTree = ""; }; DAFEC4582E03F93A0064535E /* PostHogDisplaySurveyAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogDisplaySurveyAppearance.swift; sourceTree = ""; }; DAFF02652D7B1A9000BD5B1D /* SurveysRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveysRootView.swift; sourceTree = ""; }; + 37A547D32B55E849A46CA13A /* PostHogSurveyMatching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogSurveyMatching.swift; sourceTree = ""; }; DAFF026C2D7B2F8D00BD5B1D /* SurveyDisplayController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SurveyDisplayController.swift; sourceTree = ""; }; DB51A204FB088EC14375B43D /* PLCrashReportThreadInfo.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = PLCrashReportThreadInfo.m; path = vendor/PHPLCrashReporter/Source/PLCrashReportThreadInfo.m; sourceTree = ""; }; DB7100022F80000100000001 /* PostHogTracingHeadersIntegration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostHogTracingHeadersIntegration.swift; sourceTree = ""; }; @@ -1890,6 +1892,7 @@ DA263D412D800856004C100D /* SurveyButton.swift */, DA263D392D7F1054004C100D /* Resources.swift */, 9473A633747872EA43FDCA3A /* SurveyTranslationResolver.swift */, + 37A547D32B55E849A46CA13A /* PostHogSurveyMatching.swift */, ); path = Utils; sourceTree = ""; @@ -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 */, diff --git a/PostHog/PostHogRemoteConfig.swift b/PostHog/PostHogRemoteConfig.swift index 1de3ca34bf..359339f720 100644 --- a/PostHog/PostHogRemoteConfig.swift +++ b/PostHog/PostHogRemoteConfig.swift @@ -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() private let dispatchQueue = DispatchQueue(label: "com.posthog.RemoteConfig", target: .global(qos: .utility)) @@ -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(()) } func resetPersonPropertiesForFlags() { @@ -760,6 +769,7 @@ class PostHogRemoteConfig { // Clear from disk storage.setDictionary(forKey: .personPropertiesForFlags, contents: personPropertiesForFlags) } + onPersonPropertiesForFlagsChanged.invoke(()) } func setGroupPropertiesForFlags(_ groupType: String, properties: [String: Any]) { diff --git a/PostHog/Surveys/PostHogSurveyIntegration.swift b/PostHog/Surveys/PostHogSurveyIntegration.swift index af39ea88b7..1623dc9916 100644 --- a/PostHog/Surveys/PostHogSurveyIntegration.swift +++ b/PostHog/Surveys/PostHogSurveyIntegration.swift @@ -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 } + self.activeSurveyLanguage = translations.matchedKey + self.activeSurveyQuestionTranslations = translations.questions + + return activeSurvey.toDisplaySurvey( + surveyTranslation: translations.survey, + questionTranslations: translations.questions + ) + } + + guard let displaySurvey else { return } + + DispatchQueue.main.async { + 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)? { diff --git a/PostHog/Surveys/PostHogSurveysConfig.swift b/PostHog/Surveys/PostHogSurveysConfig.swift index f5b2e9b76d..e973770f54 100644 --- a/PostHog/Surveys/PostHogSurveysConfig.swift +++ b/PostHog/Surveys/PostHogSurveysConfig.swift @@ -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`. + @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() diff --git a/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift b/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift index e3235c275f..867acc4ae0 100644 --- a/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift +++ b/PostHog/Surveys/PostHogSurveysDefaultDelegate.swift @@ -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() } @@ -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() @@ -98,7 +112,10 @@ 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 } @@ -106,7 +123,7 @@ final class PostHogSurveysDefaultDelegate: PostHogSurveysDelegate { self.pendingDisplayWorkItem = nil self.pendingSurvey = nil self.pendingSurveyClosedHandler = nil - displayController.showSurvey(survey) + displayController.showSurvey(pending) } pendingDisplayWorkItem = workItem @@ -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 diff --git a/PostHog/Surveys/SurveyDisplayController.swift b/PostHog/Surveys/SurveyDisplayController.swift index fad2804676..f9538b84b4 100644 --- a/PostHog/Surveys/SurveyDisplayController.swift +++ b/PostHog/Surveys/SurveyDisplayController.swift @@ -29,6 +29,23 @@ onSurveyShown?(survey) } + /// Replaces the content of the survey currently on screen (e.g. with a new translation) + /// without resetting the current question, completion state, or any in-progress answers. + /// + /// No-op if no survey is displayed or the update targets a different survey. + func updateSurvey(_ survey: PostHogDisplaySurvey) { + guard let displayedSurvey else { + hedgeLog("[Surveys] Received an update but no survey is displayed. Skipping") + return + } + guard displayedSurvey.id == survey.id else { + hedgeLog("[Surveys] Received an update for a different survey than the one displayed. Skipping") + return + } + + self.displayedSurvey = survey + } + func onNextQuestion(index: Int, response: PostHogSurveyResponse) { guard let displayedSurvey else { return } guard let next = onSurveyResponse?(displayedSurvey, index, response) else { return } diff --git a/PostHog/Surveys/SurveySheet.swift b/PostHog/Surveys/SurveySheet.swift index 3c13246c8a..cfe5d07155 100644 --- a/PostHog/Surveys/SurveySheet.swift +++ b/PostHog/Surveys/SurveySheet.swift @@ -11,23 +11,28 @@ @available(iOS 15, *) struct SurveySheet: View { - let survey: PostHogDisplaySurvey - let isSurveyCompleted: Bool - let currentQuestionIndex: Int - let onClose: () -> Void - let onNextQuestionClicked: (_ index: Int, _ response: PostHogSurveyResponse) -> Void + // Observed so the sheet re-renders when the displayed survey is updated in place + // (e.g. re-translated after a language change), not just when questions advance. + @ObservedObject var displayManager: SurveyDisplayController + // The `.sheet(item:)` snapshot: keeps content rendered while the sheet animates out + // after dismissal clears `displayedSurvey`. + let fallbackSurvey: PostHogDisplaySurvey @State private var sheetHeight: CGFloat = .zero + private var survey: PostHogDisplaySurvey { + displayManager.displayedSurvey ?? fallbackSurvey + } + var body: some View { - surveyContent - .animation(.linear(duration: 0.25), value: currentQuestionIndex) + surveyContent(for: survey) + .animation(.linear(duration: 0.25), value: displayManager.currentQuestionIndex) .readFrame(in: .named("survey-scroll-view")) { frame in sheetHeight = frame.height } .toolbar { ToolbarItem(placement: .topBarTrailing) { - SurveyDismissButton(action: onClose) + SurveyDismissButton(action: displayManager.dismissSurvey) } } .surveyBottomSheet(height: sheetHeight) @@ -35,31 +40,31 @@ } @ViewBuilder - private var surveyContent: some View { - if isSurveyCompleted, appearance.displayThankYouMessage { - ConfirmationMessage(onClose: onClose) - } else if let currentQuestion { + private func surveyContent(for survey: PostHogDisplaySurvey) -> some View { + if displayManager.isSurveyCompleted, appearance.displayThankYouMessage { + ConfirmationMessage(onClose: displayManager.dismissSurvey) + } else if let currentQuestion = currentQuestion(in: survey) { switch currentQuestion { case let currentQuestion as PostHogDisplayOpenQuestion: OpenTextQuestionView(question: currentQuestion) { resp in - onNextQuestionClicked(currentQuestionIndex, .openEnded(resp)) + displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .openEnded(resp)) } case let currentQuestion as PostHogDisplayLinkQuestion: LinkQuestionView(question: currentQuestion) { resp in - onNextQuestionClicked(currentQuestionIndex, .link(resp)) + displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .link(resp)) } case let currentQuestion as PostHogDisplayRatingQuestion: RatingQuestionView(question: currentQuestion) { resp in - onNextQuestionClicked(currentQuestionIndex, .rating(resp)) + displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .rating(resp)) } case let currentQuestion as PostHogDisplayChoiceQuestion: if currentQuestion.isMultipleChoice { MultipleChoiceQuestionView(question: currentQuestion) { resp in - onNextQuestionClicked(currentQuestionIndex, .multipleChoice(resp)) + displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .multipleChoice(resp)) } } else { SingleChoiceQuestionView(question: currentQuestion) { resp in - onNextQuestionClicked(currentQuestionIndex, .singleChoice(resp)) + displayManager.onNextQuestion(index: displayManager.currentQuestionIndex, response: .singleChoice(resp)) } } default: @@ -68,11 +73,11 @@ } } - private var currentQuestion: PostHogDisplaySurveyQuestion? { - guard currentQuestionIndex <= survey.questions.count - 1 else { + private func currentQuestion(in survey: PostHogDisplaySurvey) -> PostHogDisplaySurveyQuestion? { + guard displayManager.currentQuestionIndex <= survey.questions.count - 1 else { return nil } - return survey.questions[currentQuestionIndex] + return survey.questions[displayManager.currentQuestionIndex] } private var appearance: SwiftUISurveyAppearance { diff --git a/PostHog/Surveys/SurveysRootView.swift b/PostHog/Surveys/SurveysRootView.swift index 7fc4a586a2..ede54adbfa 100644 --- a/PostHog/Surveys/SurveysRootView.swift +++ b/PostHog/Surveys/SurveysRootView.swift @@ -16,14 +16,12 @@ Color.clear .allowsHitTesting(false) .sheet(item: displayBinding) { survey in - SurveySheet( - survey: survey, - isSurveyCompleted: displayManager.isSurveyCompleted, - currentQuestionIndex: displayManager.currentQuestionIndex, - onClose: displayManager.dismissSurvey, - onNextQuestionClicked: displayManager.onNextQuestion - ) - .environment(\.colorScheme, .light) // enforce light theme for now + // Content is driven by the display controller so in-place updates (like a + // survey being re-translated after a language change) render live; the + // `.sheet(item:)` snapshot is the fallback that keeps content on screen + // during the dismiss animation, after `displayedSurvey` is cleared. + SurveySheet(displayManager: displayManager, fallbackSurvey: survey) + .environment(\.colorScheme, .light) // enforce light theme for now } } diff --git a/PostHog/Surveys/Utils/PostHogSurveyMatching.swift b/PostHog/Surveys/Utils/PostHogSurveyMatching.swift new file mode 100644 index 0000000000..57566ed740 --- /dev/null +++ b/PostHog/Surveys/Utils/PostHogSurveyMatching.swift @@ -0,0 +1,135 @@ +// +// PostHogSurveyMatching.swift +// PostHog +// +// Created by PostHog Code on 2026-06-30. +// +// Self-contained helper types used by `PostHogSurveyIntegration` for survey +// state, condition matching, and rating bucketing. Kept in a separate file so +// the integration stays within the file-length limit. +// + +#if os(iOS) || TESTING + + import Foundation + + 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 + } + } + + 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 + } + } + } + + 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 + } + + 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 + } + } + +#endif diff --git a/PostHogTests/PostHogSurveyTranslationsTest.swift b/PostHogTests/PostHogSurveyTranslationsTest.swift index a6b06c74ea..f59e617189 100644 --- a/PostHogTests/PostHogSurveyTranslationsTest.swift +++ b/PostHogTests/PostHogSurveyTranslationsTest.swift @@ -360,6 +360,354 @@ } } + #if os(iOS) + final class SpySurveysDelegate: NSObject, PostHogSurveysDelegate { + var updatedSurveys: [PostHogDisplaySurvey] = [] + + func renderSurvey( + _: PostHogDisplaySurvey, + onSurveyShown _: @escaping OnPostHogSurveyShown, + onSurveyResponse _: @escaping OnPostHogSurveyResponse, + onSurveyClosed _: @escaping OnPostHogSurveyClosed + ) {} + + func updateSurvey(_ survey: PostHogDisplaySurvey) { + updatedSurveys.append(survey) + } + + func cleanupSurveys() {} + } + + /// A delegate that does not implement the optional `updateSurvey`. + final class NoLiveUpdateSurveysDelegate: NSObject, PostHogSurveysDelegate { + func renderSurvey( + _: PostHogDisplaySurvey, + onSurveyShown _: @escaping OnPostHogSurveyShown, + onSurveyResponse _: @escaping OnPostHogSurveyResponse, + onSurveyClosed _: @escaping OnPostHogSurveyClosed + ) {} + + func cleanupSurveys() {} + } + + @Suite("Test live translation updates", .serialized) + class TestLiveTranslationUpdate { + let server: MockPostHogServer + + init() { + server = MockPostHogServer() + server.start() + } + + deinit { + server.stop() + } + + private func getSut() -> PostHogSDK { + let config = PostHogConfig(projectToken: testProjectToken, host: "http://localhost:9090") + config._surveys = true + config.flushAt = 1 + config.disableReachabilityForTesting = true + config.disableQueueTimerForTesting = true + config.disableFlushOnBackgroundForTesting = true + config.captureApplicationLifecycleEvents = false + let storage = PostHogStorage(config) + storage.reset() + return PostHogSDK.with(config) + } + + private func getSurveyIntegration(_ postHog: PostHogSDK) throws -> PostHogSurveyIntegration { + PostHogSurveyIntegration.clearInstalls() + let integration = PostHogSurveyIntegration() + let installResult = integration.install(postHog) + try #require(installResult == .installed) + return integration + } + + private func translatedSurvey() -> PostHogSurvey { + PostHogSurvey( + id: "translated-survey", + name: "Original", + type: .popover, + questions: [.open(PostHogOpenSurveyQuestion( + id: "q1", + question: "Question?", + description: nil, + descriptionContentType: .text, + optional: false, + buttonText: nil, + originalQuestionIndex: 0, + branching: nil, + translations: ["fr": PostHogSurveyQuestionTranslation( + question: "Question FR?", + description: nil, + buttonText: nil, + link: nil, + lowerBoundLabel: nil, + upperBoundLabel: nil, + choices: nil + )] + ))], + featureFlagKeys: nil, + linkedFlagKey: nil, + targetingFlagKey: nil, + internalTargetingFlagKey: nil, + conditions: nil, + appearance: nil, + currentIteration: nil, + currentIterationStartDate: nil, + startDate: Date(), + endDate: nil, + schedule: nil, + translations: ["fr": PostHogSurveyTranslation( + name: "Bonjour", + thankYouMessageHeader: nil, + thankYouMessageDescription: nil, + thankYouMessageCloseButtonText: nil + )] + ) + } + + /// Lets the main-queue work scheduled by the refresh run before asserting. + private func drainMainQueue() async { + await withCheckedContinuation { continuation in + DispatchQueue.main.async { continuation.resume() } + } + } + + @Test("changing the language person property re-translates the active survey") + func languageChangeRetranslatesActiveSurvey() async throws { + let postHog = getSut() + let spy = SpySurveysDelegate() + // Use the backing property directly: the public `surveysConfig` accessor is + // gated to iOS 15+, but the delegate it exposes is not version-specific. + postHog.config._surveysConfig.surveysDelegate = spy + let integration = try getSurveyIntegration(postHog) + + integration.setShownSurvey(translatedSurvey(), language: nil) + postHog.setPersonPropertiesForFlags(["language": "fr"], reloadFeatureFlags: false) + await drainMainQueue() + + #expect(integration.testActiveSurveyLanguage == "fr") + #expect(spy.updatedSurveys.count == 1) + #expect(spy.updatedSurveys.first?.name == "Bonjour") + #expect(spy.updatedSurveys.first?.questions.first?.question == "Question FR?") + + postHog.close() + postHog.reset() + } + + @Test("re-resolving the same language does not push an update") + func sameLanguageIsNoop() async throws { + let postHog = getSut() + let spy = SpySurveysDelegate() + // Use the backing property directly: the public `surveysConfig` accessor is + // gated to iOS 15+, but the delegate it exposes is not version-specific. + postHog.config._surveysConfig.surveysDelegate = spy + let integration = try getSurveyIntegration(postHog) + + // Already showing the French translation + integration.setShownSurvey( + translatedSurvey(), + language: "fr", + questionTranslations: [PostHogSurveyQuestionTranslation( + question: "Question FR?", + description: nil, + buttonText: nil, + link: nil, + lowerBoundLabel: nil, + upperBoundLabel: nil, + choices: nil + )] + ) + + postHog.setPersonPropertiesForFlags(["language": "fr"], reloadFeatureFlags: false) + await drainMainQueue() + + #expect(integration.testActiveSurveyLanguage == "fr") + #expect(spy.updatedSurveys.isEmpty) + + postHog.close() + postHog.reset() + } + + @Test("delegate without updateSurvey keeps the rendered language") + func delegateWithoutUpdateSurveyKeepsState() async throws { + let postHog = getSut() + // Use the backing property directly: the public `surveysConfig` accessor is + // gated to iOS 15+, but the delegate it exposes is not version-specific. + postHog.config._surveysConfig.surveysDelegate = NoLiveUpdateSurveysDelegate() + let integration = try getSurveyIntegration(postHog) + + integration.setShownSurvey(translatedSurvey(), language: nil) + postHog.setPersonPropertiesForFlags(["language": "fr"], reloadFeatureFlags: false) + await drainMainQueue() + + // Internal state must not advance past what was actually rendered. + #expect(integration.testActiveSurveyLanguage == nil) + + postHog.close() + postHog.reset() + } + + @Test("resetting person properties reverts the active survey language") + func resetRevertsActiveSurveyLanguage() async throws { + let postHog = getSut() + let spy = SpySurveysDelegate() + // Use the backing property directly: the public `surveysConfig` accessor is + // gated to iOS 15+, but the delegate it exposes is not version-specific. + postHog.config._surveysConfig.surveysDelegate = spy + let integration = try getSurveyIntegration(postHog) + + integration.setShownSurvey(translatedSurvey(), language: nil) + postHog.setPersonPropertiesForFlags(["language": "fr"], reloadFeatureFlags: false) + await drainMainQueue() + try #require(integration.testActiveSurveyLanguage == "fr") + + postHog.resetPersonPropertiesForFlags(reloadFeatureFlags: false) + await drainMainQueue() + + #expect(integration.testActiveSurveyLanguage == nil) + #expect(spy.updatedSurveys.count == 2) + #expect(spy.updatedSurveys.last?.name == "Original") + + postHog.close() + postHog.reset() + } + + @Test("no active survey means no update is pushed") + func noActiveSurveyIsNoop() async throws { + let postHog = getSut() + let spy = SpySurveysDelegate() + // Use the backing property directly: the public `surveysConfig` accessor is + // gated to iOS 15+, but the delegate it exposes is not version-specific. + postHog.config._surveysConfig.surveysDelegate = spy + _ = try getSurveyIntegration(postHog) + + postHog.setPersonPropertiesForFlags(["language": "fr"], reloadFeatureFlags: false) + await drainMainQueue() + + #expect(spy.updatedSurveys.isEmpty) + + postHog.close() + postHog.reset() + } + } + + @Suite("Test display controller in-place update") + struct TestDisplayControllerUpdate { + private func displaySurvey(name: String) -> PostHogDisplaySurvey { + PostHogDisplaySurvey( + id: "survey-1", + name: name, + questions: [], + appearance: nil, + startDate: nil, + endDate: nil + ) + } + + @MainActor + @Test("update preserves question index and completion state") + func updatePreservesProgress() { + let controller = SurveyDisplayController() + controller.showSurvey(displaySurvey(name: "Original")) + controller.currentQuestionIndex = 2 + controller.isSurveyCompleted = true + + controller.updateSurvey(displaySurvey(name: "Bonjour")) + + #expect(controller.displayedSurvey?.name == "Bonjour") + #expect(controller.currentQuestionIndex == 2) + #expect(controller.isSurveyCompleted == true) + } + + @MainActor + @Test("update for a different survey id is ignored") + func updateDifferentSurveyIgnored() { + let controller = SurveyDisplayController() + controller.showSurvey(displaySurvey(name: "Original")) + + let other = PostHogDisplaySurvey( + id: "survey-2", + name: "Other", + questions: [], + appearance: nil, + startDate: nil, + endDate: nil + ) + controller.updateSurvey(other) + + #expect(controller.displayedSurvey?.name == "Original") + } + + @MainActor + @Test("update with no displayed survey is ignored") + func updateWithNoSurveyIgnored() { + let controller = SurveyDisplayController() + controller.updateSurvey(displaySurvey(name: "Bonjour")) + #expect(controller.displayedSurvey == nil) + } + } + + @Suite("Test default delegate delayed display update", .serialized) + struct TestDefaultDelegateDelayedDisplay { + private func displaySurvey(name: String, delaySeconds: TimeInterval) -> PostHogDisplaySurvey { + PostHogDisplaySurvey( + id: "survey-1", + name: name, + questions: [], + appearance: PostHogDisplaySurveyAppearance( + fontFamily: nil, + backgroundColor: nil, + borderColor: nil, + submitButtonColor: nil, + submitButtonText: nil, + submitButtonTextColor: nil, + textColor: nil, + descriptionTextColor: nil, + ratingButtonColor: nil, + ratingButtonActiveColor: nil, + inputBackground: nil, + inputTextColor: nil, + placeholder: nil, + surveyPopupDelaySeconds: delaySeconds, + displayThankYouMessage: false, + thankYouMessageHeader: nil, + thankYouMessageDescription: nil, + thankYouMessageDescriptionContentType: nil, + thankYouMessageCloseButtonText: nil + ), + startDate: nil, + endDate: nil + ) + } + + @MainActor + @Test("survey updated during its display delay shows the updated copy") + func delayedDisplayShowsUpdatedCopy() async throws { + let delegate = PostHogSurveysDefaultDelegate() + let controller = SurveyDisplayController() + delegate.setDisplayControllerForTesting(controller) + + delegate.renderSurvey( + displaySurvey(name: "Original", delaySeconds: 0.1), + onSurveyShown: { _ in }, + onSurveyResponse: { _, _, _ in nil }, + onSurveyClosed: { _ in } + ) + #expect(controller.displayedSurvey == nil) + + delegate.updateSurvey(displaySurvey(name: "Updated", delaySeconds: 0.1)) + + for _ in 0 ..< 100 where controller.displayedSurvey == nil { + try await Task.sleep(nanoseconds: 20_000_000) + } + #expect(controller.displayedSurvey?.name == "Updated") + } + } + #endif + @Suite("Test display survey with translations") struct TestDisplayTranslation { @Test("display survey applies translation fields with fallback") diff --git a/api/posthog-ios.public-api.txt b/api/posthog-ios.public-api.txt index 795a413722..aca4e1436b 100644 --- a/api/posthog-ios.public-api.txt +++ b/api/posthog-ios.public-api.txt @@ -390,6 +390,7 @@ PostHog | PostHogSurveysConfig.surveysDelegate | property | var surveysDelegate: PostHog | PostHogSurveysDelegate | protocol | @objc protocol PostHogSurveysDelegate | c:@M@PostHog@objc(pl)PostHogSurveysDelegate PostHog | PostHogSurveysDelegate.cleanupSurveys() | method | @objc func cleanupSurveys() | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(im)cleanupSurveys PostHog | PostHogSurveysDelegate.renderSurvey(_:onSurveyShown:onSurveyResponse:onSurveyClosed:) | method | @objc func renderSurvey(_ survey: PostHogDisplaySurvey, onSurveyShown: @escaping OnPostHogSurveyShown, onSurveyResponse: @escaping OnPostHogSurveyResponse, onSurveyClosed: @escaping OnPostHogSurveyClosed) | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(im)renderSurvey:onSurveyShown:onSurveyResponse:onSurveyClosed: +PostHog | PostHogSurveysDelegate.updateSurvey(_:) | method | @objc optional func updateSurvey(_ survey: PostHogDisplaySurvey) | c:@M@PostHog@objc(pl)PostHogSurveysDelegate(im)updateSurvey: PostHog | ReachabilityError | enum | enum ReachabilityError | s:7PostHog17ReachabilityErrorO PostHog | ReachabilityError.failedToCreateWithAddress(_:_:) | enum.case | case failedToCreateWithAddress(sockaddr, Int32) | s:7PostHog17ReachabilityErrorO25failedToCreateWithAddressyACSo8sockaddrV_s5Int32VtcACmF PostHog | ReachabilityError.failedToCreateWithHostname(_:_:) | enum.case | case failedToCreateWithHostname(String, Int32) | s:7PostHog17ReachabilityErrorO26failedToCreateWithHostnameyACSS_s5Int32VtcACmF