diff --git a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetView.swift b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetView.swift index 2a02cb23321..43de5ec8725 100644 --- a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetView.swift +++ b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetView.swift @@ -6,6 +6,7 @@ final class WMFAltTextExperimentModalSheetView: WMFComponentView { weak var viewModel: WMFAltTextExperimentModalSheetViewModel? weak var delegate: WMFAltTextExperimentModalSheetDelegate? + weak var loggingDelegate: WMFAltTextExperimentModalSheetLoggingDelegate? private lazy var scrollView: UIScrollView = { let scrollView = UIScrollView() @@ -99,9 +100,10 @@ final class WMFAltTextExperimentModalSheetView: WMFComponentView { // MARK: Lifecycle - public init(frame: CGRect, viewModel: WMFAltTextExperimentModalSheetViewModel, delegate: WMFAltTextExperimentModalSheetDelegate?) { + public init(frame: CGRect, viewModel: WMFAltTextExperimentModalSheetViewModel, delegate: WMFAltTextExperimentModalSheetDelegate?, loggingDelegate: WMFAltTextExperimentModalSheetLoggingDelegate?) { self.viewModel = viewModel self.delegate = delegate + self.loggingDelegate = loggingDelegate super.init(frame: frame) textView.delegate = self setup() @@ -224,6 +226,7 @@ extension WMFAltTextExperimentModalSheetView: UITextViewDelegate { func textViewDidBeginEditing(_ textView: UITextView) { placeholder.isHidden = true + loggingDelegate?.didFocusTextView() } func textViewDidEndEditing(_ textView: UITextView) { diff --git a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetViewController.swift b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetViewController.swift index cc01c46e114..209731ca3ad 100644 --- a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetViewController.swift +++ b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Alt Text Experiment/WMFAltTextExperimentModalSheetViewController.swift @@ -4,14 +4,21 @@ public protocol WMFAltTextExperimentModalSheetDelegate: AnyObject { func didTapNext(altText: String) } +public protocol WMFAltTextExperimentModalSheetLoggingDelegate: AnyObject { + func didAppear() + func didFocusTextView() +} + final public class WMFAltTextExperimentModalSheetViewController: WMFCanvasViewController { weak var viewModel: WMFAltTextExperimentModalSheetViewModel? weak var delegate: WMFAltTextExperimentModalSheetDelegate? + weak var loggingDelegate: WMFAltTextExperimentModalSheetLoggingDelegate? - public init(viewModel: WMFAltTextExperimentModalSheetViewModel?, delegate: WMFAltTextExperimentModalSheetDelegate?) { + public init(viewModel: WMFAltTextExperimentModalSheetViewModel?, delegate: WMFAltTextExperimentModalSheetDelegate?, loggingDelegate: WMFAltTextExperimentModalSheetLoggingDelegate?) { self.viewModel = viewModel self.delegate = delegate + self.loggingDelegate = loggingDelegate super.init() } @@ -22,9 +29,13 @@ final public class WMFAltTextExperimentModalSheetViewController: WMFCanvasViewCo override public func viewWillAppear(_ animated: Bool) { super.viewWillAppear(animated) guard let viewModel else { return } - let view = WMFAltTextExperimentModalSheetView(frame: UIScreen.main.bounds, viewModel: viewModel, delegate: delegate) + let view = WMFAltTextExperimentModalSheetView(frame: UIScreen.main.bounds, viewModel: viewModel, delegate: delegate, loggingDelegate: loggingDelegate) addComponent(view, pinToEdges: true) } + public override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + loggingDelegate?.didAppear() + } } diff --git a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewController.swift b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewController.swift index 9ea7436dc47..65301a80254 100644 --- a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewController.swift +++ b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewController.swift @@ -35,6 +35,7 @@ public protocol WMFImageRecommendationsLoggingDelegate: AnyObject { func logEmptyStateDidAppear() func logEmptyStateDidTapBack() func logDialogWarningMessageDidDisplay(fileName: String, recommendationSource: String) + func logAltTextExperimentDidAssignGroup() } fileprivate final class WMFImageRecommendationsHostingViewController: WMFComponentHostingController { @@ -202,6 +203,7 @@ public final class WMFImageRecommendationsViewController: WMFCanvasViewControlle do { try dataController.assignImageRecsExperiment(isLoggedIn: isLoggedIn, project: viewModel.project) + loggingDelegate?.logAltTextExperimentDidAssignGroup() } catch let error { debugPrint(error) return false diff --git a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewModel.swift b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewModel.swift index 11a85a5fbfb..9d1a4dbe336 100644 --- a/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewModel.swift +++ b/WMFComponents/Sources/WMFComponents/Components/Suggested Edits/Image Recommendations/WMFImageRecommendationsViewModel.swift @@ -123,6 +123,7 @@ public final class WMFImageRecommendationsViewModel: ObservableObject { public var imageWikitext: String? public var fullArticleWikitextWithImage: String? public var suggestionAcceptDate: Date? + public var altTextExperimentAcceptDate: Date? public var lastRevisionID: UInt64? public var localizedFileTitle: String? diff --git a/WMFData/Sources/WMFData/Data Controllers/Alt Text/WMFAltTextDataController.swift b/WMFData/Sources/WMFData/Data Controllers/Alt Text/WMFAltTextDataController.swift index 6fff73582d6..50002a92810 100644 --- a/WMFData/Sources/WMFData/Data Controllers/Alt Text/WMFAltTextDataController.swift +++ b/WMFData/Sources/WMFData/Data Controllers/Alt Text/WMFAltTextDataController.swift @@ -20,6 +20,7 @@ public final class WMFAltTextDataController { case invalidDeviceOrOS case invalidDate case unexpectedBucketValue + case alreadyAssignedThisExperiment case alreadyAssignedOtherExperiment } @@ -64,6 +65,10 @@ public final class WMFAltTextDataController { throw WMFAltTextDataControllerError.invalidDate } + if experimentsDataController.bucketForExperiment(.altTextImageRecommendations) != nil { + throw WMFAltTextDataControllerError.alreadyAssignedThisExperiment + } + if let articleEditorExperimentBucket = experimentsDataController.bucketForExperiment(.altTextArticleEditor) { switch articleEditorExperimentBucket { @@ -102,6 +107,10 @@ public final class WMFAltTextDataController { throw WMFAltTextDataControllerError.invalidDate } + if experimentsDataController.bucketForExperiment(.altTextArticleEditor) != nil { + throw WMFAltTextDataControllerError.alreadyAssignedThisExperiment + } + if let imageRecommendationsExperimentBucket = experimentsDataController.bucketForExperiment(.altTextImageRecommendations) { switch imageRecommendationsExperimentBucket { diff --git a/Wikipedia/Code/ArticleViewController+Editing.swift b/Wikipedia/Code/ArticleViewController+Editing.swift index b908c32ed49..108a4a023d1 100644 --- a/Wikipedia/Code/ArticleViewController+Editing.swift +++ b/Wikipedia/Code/ArticleViewController+Editing.swift @@ -275,6 +275,7 @@ extension ArticleViewController: EditorViewControllerDelegate { do { try dataController.assignArticleEditorExperiment(isLoggedIn: isLoggedIn, project: project) + EditInteractionFunnel.shared.logAltTextDidAssignArticleEditorGroup(project: WikimediaProject(wmfProject: project)) } catch let error { DDLogWarn("Error assigning alt text article editor experiment: \(error)") } diff --git a/Wikipedia/Code/ArticleViewController.swift b/Wikipedia/Code/ArticleViewController.swift index f53d308489d..4fb1be963e9 100644 --- a/Wikipedia/Code/ArticleViewController.swift +++ b/Wikipedia/Code/ArticleViewController.swift @@ -493,7 +493,7 @@ class ArticleViewController: ViewController, HintPresenting { messagingController.hideEditPencils() messagingController.scrollToNewImage(filename: altTextExperimentViewModel.filename) - let bottomSheetViewController = WMFAltTextExperimentModalSheetViewController(viewModel: altTextBottomSheetViewModel, delegate: self) + let bottomSheetViewController = WMFAltTextExperimentModalSheetViewController(viewModel: altTextBottomSheetViewModel, delegate: self, loggingDelegate: self) if #available(iOS 16.0, *) { if let sheet = bottomSheetViewController.sheetPresentationController { @@ -1389,8 +1389,39 @@ extension ArticleViewController: UISheetPresentationControllerDelegate { case .medium, .large: webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: view.bounds.height * 0.65, right: oldContentInset.right) default: + logMinimized() webView.scrollView.contentInset = UIEdgeInsets(top: oldContentInset.top, left: oldContentInset.left, bottom: 75, right: oldContentInset.right) } } } + + private func logMinimized() { + guard let siteURL = articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL) else { + return + } + + EditInteractionFunnel.shared.logAltTextInputDidMinimize(project: project) + } +} + +extension ArticleViewController: WMFAltTextExperimentModalSheetLoggingDelegate { + func didAppear() { + + guard let siteURL = articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL) else { + return + } + + EditInteractionFunnel.shared.logAltTextInputDidAppear(project: project) + } + + func didFocusTextView() { + guard let siteURL = articleURL.wmf_site, + let project = WikimediaProject(siteURL: siteURL) else { + return + } + + EditInteractionFunnel.shared.logAltTextInputDidFocus(project: project) + } } diff --git a/Wikipedia/Code/EditInteractionFunnel.swift b/Wikipedia/Code/EditInteractionFunnel.swift index f0aa83ab4cf..0fcdb192a67 100644 --- a/Wikipedia/Code/EditInteractionFunnel.swift +++ b/Wikipedia/Code/EditInteractionFunnel.swift @@ -1,4 +1,5 @@ import Foundation +import WMFData final class EditInteractionFunnel { @@ -28,6 +29,10 @@ final class EditInteractionFunnel { case articleEditPreview = "article_edit_preview" case articleEditSummary = "article_edit_summary" case talkEditSummary = "talk_edit_summary" + + // Alt-Text-Experiment Items + case altTextEditingOnboarding = "alt_text_editing_onboarding" + case altTextEditingInterface = "alt_text_editing_interface" } private enum Action: String { @@ -42,6 +47,17 @@ final class EditInteractionFunnel { case saveAttempt = "save_attempt" case saveSuccess = "save_success" case saveFailure = "save_failure" + + // Alt-Text-Experiment Items + case groupAssignment = "group_assignment" + case launchImpression = "launch_impression" + case launchCloseClick = "launch_close_click" + case addClick = "add_click" + case doNotAddClick = "do_not_add_click" + case addAltTextImpression = "add_alt_text_impression" + case addAltTextInput = "add_alt_text_input" + case altTextEditSuccess = "alt_text_edit_success" + case minimizedImpression = "minimized_impression" } private struct Event: EventInterface { @@ -236,4 +252,90 @@ final class EditInteractionFunnel { let actionData = ["abort_source": ProblemSource.blockedMessageLink.rawValue] logEvent(activeInterface: .talkEditSummary, action: .editCancel, actionData: actionData, project: project) } + + // MARK: Alt-Text-Experiment + + func logAltTextDidAssignImageRecsGroup(project: WikimediaProject) { + + guard let group = WMFAltTextDataController.shared?.assignedAltTextImageRecommendationsGroupForLogging() else { + return + } + + var actionData: [String: String] = [:] + switch group { + case "A": + actionData["exp_b_group"] = "a" + case "B": + actionData["exp_b_group"] = "b" + default: + assertionFailure("Unexpected experiment group") + } + + logEvent(activeInterface: .altTextEditingOnboarding, action: .groupAssignment, actionData: actionData, project: project) + } + + func logAltTextDidAssignArticleEditorGroup(project: WikimediaProject) { + + guard let group = WMFAltTextDataController.shared?.assignedAltTextArticleEditorGroupForLogging() else { + return + } + + var actionData: [String: String] = [:] + switch group { + case "C": + actionData["exp_c_group"] = "c" + case "D": + actionData["exp_c_group"] = "d" + default: + assertionFailure("Unexpected experiment group") + } + + logEvent(activeInterface: .altTextEditingOnboarding, action: .groupAssignment, actionData: actionData, project: project) + } + + func logAltTextPromptDidAppear(project: WikimediaProject) { + logEvent(activeInterface: .altTextEditingOnboarding, action: .launchImpression, project: project) + } + + func logAltTextPromptDidTapClose(project: WikimediaProject) { + logEvent(activeInterface: .altTextEditingOnboarding, action: .launchCloseClick, project: project) + } + + func logAltTextPromptDidTapAdd(project: WikimediaProject) { + logEvent(activeInterface: .altTextEditingOnboarding, action: .addClick, project: project) + } + + func logAltTextPromptDidTapDoNotAdd(project: WikimediaProject) { + logEvent(activeInterface: .altTextEditingOnboarding, action: .doNotAddClick, project: project) + } + + func logAltTextInputDidAppear(project: WikimediaProject) { + logEvent(activeInterface: .altTextEditingInterface, action: .addAltTextImpression, project: project) + } + + func logAltTextInputDidFocus(project: WikimediaProject) { + logEvent(activeInterface: .altTextEditingInterface, action: .addAltTextInput, project: project) + } + + func logAltTextInputDidMinimize(project: WikimediaProject) { + logEvent(activeInterface: .altTextEditingInterface, action: .minimizedImpression, project: project) + } + + func logAltTextDidSuccessfullyPostEdit(timeSpent: Int, revisionID: UInt64, altText: String, articleTitle: String, image: String, username: String, userEditCount: UInt64, registrationDate: String?, project: WikimediaProject) { + + var actionData = ["time_spent": String(timeSpent), + "revision_id": String(revisionID), + "alt_text": altText, + "article_title": articleTitle, + "image": image, + "username": username, + "event_user_revision_count": String(userEditCount)] + + if let registrationDate { + actionData["user_create_date"] = registrationDate + } + + logEvent(activeInterface: .altTextEditingInterface, action: .altTextEditSuccess, actionData: actionData, project: project) + } } + diff --git a/Wikipedia/Code/ExploreViewController.swift b/Wikipedia/Code/ExploreViewController.swift index a6ecef203af..41036961ccd 100644 --- a/Wikipedia/Code/ExploreViewController.swift +++ b/Wikipedia/Code/ExploreViewController.swift @@ -1319,6 +1319,10 @@ extension ExploreViewController: WMFImageRecommendationsDelegate { let sheetLocalizedStrings = WMFAltTextExperimentModalSheetViewModel.LocalizedStrings(title: addAltTextTitle, buttonTitle: CommonStrings.nextTitle, textViewPlaceholder: textViewPlaceholder) let bottomSheetViewModel = WMFAltTextExperimentModalSheetViewModel(altTextViewModel: altTextViewModel, localizedStrings: sheetLocalizedStrings) + + lastRecommendation.altTextExperimentAcceptDate = Date() + + EditInteractionFunnel.shared.logAltTextPromptDidTapAdd(project: WikimediaProject(wmfProject: viewModel.project)) if let siteURL = viewModel.project.siteURL, let articleURL = siteURL.wmf_URL(withTitle: articleTitle), @@ -1326,14 +1330,14 @@ extension ExploreViewController: WMFImageRecommendationsDelegate { self.navigationController?.pushViewController(articleViewController, animated: true) } - - // todo: once alt text is published, bring back up this bottom sheet - // imageRecommendationsViewController.presentImageRecommendationBottomSheet() } } - let secondaryTapHandler: ScrollableEducationPanelButtonTapHandler = { [weak self] _, _ in + let secondaryTapHandler: ScrollableEducationPanelButtonTapHandler = { _, _ in imageRecommendationsViewController.dismiss(animated: true) { + + EditInteractionFunnel.shared.logAltTextPromptDidTapDoNotAdd(project: WikimediaProject(wmfProject: viewModel.project)) + // show survey // once survey is done, bring back up next recommendation imageRecommendationsViewController.presentImageRecommendationBottomSheet() @@ -1345,11 +1349,15 @@ extension ExploreViewController: WMFImageRecommendationsDelegate { case .tappedPrimary, .tappedSecondary: break default: + EditInteractionFunnel.shared.logAltTextPromptDidTapClose(project: WikimediaProject(wmfProject: viewModel.project)) imageRecommendationsViewController.presentImageRecommendationBottomSheet() } } let panel = AltTextExperimentPanelViewController(showCloseButton: true, buttonStyle: .updatedStyle, primaryButtonTapHandler: primaryTapHandler, secondaryButtonTapHandler: secondaryTapHandler, traceableDismissHandler: traceableDismissHandler, theme: self.theme, isFlowB: isFlowB) + + EditInteractionFunnel.shared.logAltTextPromptDidAppear(project: WikimediaProject(wmfProject: viewModel.project)) + imageRecommendationsViewController.present(panel, animated: true) let dataController = WMFAltTextDataController.shared dataController?.markSawAltTextImageRecommendationsPrompt() @@ -1512,7 +1520,17 @@ extension ExploreViewController: WMFFeatureAnnouncing { } extension ExploreViewController: WMFImageRecommendationsLoggingDelegate { - + func logAltTextExperimentDidAssignGroup() { + + guard let imageRecommendationsViewModel else { + return + } + + let project = WikimediaProject(wmfProject: imageRecommendationsViewModel.project) + + EditInteractionFunnel.shared.logAltTextDidAssignImageRecsGroup(project: project) + } + func logOnboardingDidTapPrimaryButton() { ImageRecommendationsFunnel.shared.logOnboardingDidTapContinue() } @@ -1733,13 +1751,15 @@ extension ExploreViewController: AltTextDelegate { } let developerSettings = WMFDeveloperSettingsDataController() + if viewModel.isFlowB && developerSettings.doNotPostImageRecommendationsEdit { navigationController?.popViewController(animated: true) // wait for animation to complete - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.presentAltTextEditPublishedToast() + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.presentAltTextEditPublishedToast() + self?.logAltTextEditSuccess(altText: altText, revisionID: 0) } return @@ -1759,17 +1779,40 @@ extension ExploreViewController: AltTextDelegate { self.navigationController?.popViewController(animated: true) - // wait for animation to complete + guard let fetchedData = result as? [String: Any], + let newRevID = fetchedData["newrevid"] as? UInt64 else { + return + } + if error == nil { - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { - self.presentAltTextEditPublishedToast() + // wait for animation to complete + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { [weak self] in + self?.presentAltTextEditPublishedToast() + self?.logAltTextEditSuccess(altText: altText, revisionID: newRevID) } } } } } + } + + private func logAltTextEditSuccess(altText: String, revisionID: UInt64) { - + guard let imageRecommendationsViewModel, + let lastRecommendation = imageRecommendationsViewModel.lastRecommendation, let acceptDate = lastRecommendation.altTextExperimentAcceptDate, + let siteURL = imageRecommendationsViewModel.project.siteURL else { + return + } + + let articleTitle = lastRecommendation.title + let image = lastRecommendation.imageData.filename + let timeSpent = Int(Date().timeIntervalSince(acceptDate)) + + guard let loggedInUser = dataStore.authenticationManager.getLoggedInUserCache(for: siteURL) else { + return + } + + EditInteractionFunnel.shared.logAltTextDidSuccessfullyPostEdit(timeSpent: timeSpent, revisionID: revisionID, altText: altText, articleTitle: articleTitle, image: image, username: loggedInUser.name, userEditCount: loggedInUser.editCount, registrationDate: loggedInUser.registrationDateString, project: WikimediaProject(wmfProject: imageRecommendationsViewModel.project)) } private func presentAltTextEditPublishedToast() { diff --git a/Wikipedia/Code/WMFAuthenticationManager.swift b/Wikipedia/Code/WMFAuthenticationManager.swift index a8c9c7205f0..6239602bf2c 100644 --- a/Wikipedia/Code/WMFAuthenticationManager.swift +++ b/Wikipedia/Code/WMFAuthenticationManager.swift @@ -63,6 +63,13 @@ import CocoaLumberjackSwift private var isAnonCache: [String: Bool] = [:] private var loggedInUserCache: [String: WMFCurrentlyLoggedInUser] = [:] + public func getLoggedInUserCache(for siteURL: URL) -> WMFCurrentlyLoggedInUser? { + guard let host = siteURL.host else { + return nil + } + return loggedInUserCache[host] + } + @objc func getLoggedInUser(for siteURL: URL, completion: @escaping (WMFCurrentlyLoggedInUser?) -> Void) { getLoggedInUser(for: siteURL) { result in switch result { diff --git a/Wikipedia/Code/WMFCurrentlyLoggedInUserFetcher.swift b/Wikipedia/Code/WMFCurrentlyLoggedInUserFetcher.swift index dd03e87f49e..e3827eecb8d 100644 --- a/Wikipedia/Code/WMFCurrentlyLoggedInUserFetcher.swift +++ b/Wikipedia/Code/WMFCurrentlyLoggedInUserFetcher.swift @@ -22,12 +22,14 @@ public typealias WMFCurrentlyLoggedInUserBlock = (WMFCurrentlyLoggedInUser) -> V @objc public var groups: [String] @objc public var editCount: UInt64 @objc public var isBlocked: Bool - init(userID: Int, name: String, groups: [String], editCount: UInt64, isBlocked: Bool) { + @objc public var registrationDateString: String? + init(userID: Int, name: String, groups: [String], editCount: UInt64, isBlocked: Bool, registrationDateString: String?) { self.userID = userID self.name = name self.groups = groups self.editCount = editCount self.isBlocked = isBlocked + self.registrationDateString = registrationDateString } } @@ -36,7 +38,7 @@ public class WMFCurrentlyLoggedInUserFetcher: Fetcher { let parameters = [ "action": "query", "meta": "userinfo", - "uiprop": "groups|blockinfo|editcount", + "uiprop": "groups|blockinfo|editcount|registrationdate", "format": "json" ] @@ -60,6 +62,7 @@ public class WMFCurrentlyLoggedInUserFetcher: Fetcher { } let editCount = userinfo["editcount"] as? UInt64 ?? 0 + let registrationDateString = userinfo["registrationdate"] as? String var isBlocked = false if userinfo["blockid"] is UInt64 { @@ -70,7 +73,7 @@ public class WMFCurrentlyLoggedInUserFetcher: Fetcher { } let groups = userinfo["groups"] as? [String] ?? [] - success(WMFCurrentlyLoggedInUser.init(userID: userID, name: userName, groups: groups, editCount: editCount, isBlocked: isBlocked)) + success(WMFCurrentlyLoggedInUser.init(userID: userID, name: userName, groups: groups, editCount: editCount, isBlocked: isBlocked, registrationDateString: registrationDateString)) } } }