Skip to content

Commit

Permalink
Add FXIOS-6957 [v120] Create CFR for shopping icon (#16593)
Browse files Browse the repository at this point in the history
* Implement CFRs flow for shopping icon

* Use ternary conditional operator and update tests

* Add PR to data reviews

* update hasTimePassed method to use hours instead of seconds, add comments and update tests
  • Loading branch information
PARAIPAN9 authored Sep 27, 2023
1 parent e3b2b0c commit 58671a5
Show file tree
Hide file tree
Showing 20 changed files with 225 additions and 6 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,31 @@ extension BrowserViewController: URLBarDelegate {
navigationHandler?.showFakespotFlow(productURL: productURL)
}

func urlBarPresentCFR(at sourceView: UIView) {
let contextualViewProvider = ContextualHintViewProvider(forHintType: .shoppingExperience,
with: profile)

let contextHintVC = ContextualHintViewController(with: contextualViewProvider)

contextHintVC.configure(
anchor: sourceView,
withArrowDirection: isBottomSearchBar ? .down : .up,
andDelegate: self,
presentedUsing: {
self.present(contextHintVC, animated: true)
TelemetryWrapper.recordEvent(category: .action,
method: .navigate,
object: .shoppingButton,
value: .shoppingCFRsDisplayed)
},
andActionForButton: { [weak self] in
guard let self else { return }
guard let productURL = self.urlBar.currentURL else { return }
self.navigationHandler?.showFakespotFlow(productURL: productURL)
},
overlayState: overlayManager)
}

func urlBarDidPressQRButton(_ urlBar: URLBarView) {
let qrCodeViewController = QRCodeViewController()
qrCodeViewController.qrCodeDelegate = self
Expand Down
24 changes: 23 additions & 1 deletion Client/Frontend/ContextualHint/ContextualHintCopyProvider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Foundation
import Shared
import Common

enum ContextualHintCopyType {
case action, description
Expand All @@ -15,9 +17,12 @@ struct ContextualHintCopyProvider: FeatureFlaggable {

/// Arrow direction infuences toolbar copy, so it exists here.
private var arrowDirection: UIPopoverArrowDirection?
private let prefs: Prefs

init(arrowDirecton: UIPopoverArrowDirection? = nil) {
init(profile: Profile = AppContainer.shared.resolve(),
arrowDirecton: UIPopoverArrowDirection? = nil) {
self.arrowDirection = arrowDirecton
self.prefs = profile.prefs
}

// MARK: - Public interface
Expand Down Expand Up @@ -59,6 +64,9 @@ struct ContextualHintCopyProvider: FeatureFlaggable {

case .toolbarLocation:
return getToolbarDescriptionCopy(with: arrowDirection)

case .shoppingExperience:
descriptionCopy = getShoppingCopy(.description)
}

return descriptionCopy
Expand All @@ -72,6 +80,8 @@ struct ContextualHintCopyProvider: FeatureFlaggable {
actionCopy = CFRStrings.TabsTray.InactiveTabs.Action
case .toolbarLocation:
actionCopy = CFRStrings.Toolbar.SearchBarPlacementButtonText
case .shoppingExperience:
actionCopy = getShoppingCopy(.action)
case .jumpBackIn,
.jumpBackInSyncedTab:
actionCopy = ""
Expand All @@ -96,4 +106,16 @@ struct ContextualHintCopyProvider: FeatureFlaggable {
default: return ""
}
}

private func getShoppingCopy(_ copyType: ContextualHintCopyType) -> String {
let hasOptedIn = prefs.boolForKey(PrefsKeys.Shopping2023OptIn) ?? false
var copy: String
switch copyType {
case .action:
copy = hasOptedIn ? CFRStrings.Shopping.OptedInAction : CFRStrings.Shopping.NotOptedInAction
case .description:
copy = hasOptedIn ? CFRStrings.Shopping.OptedInBody : CFRStrings.Shopping.NotOptedInBody
}
return copy
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
// file, You can obtain one at http://mozilla.org/MPL/2.0/

import Foundation
import Shared

/// Public interface for contextual hint consumers
protocol ContextualHintEligibilityUtilityProtocol {
Expand Down Expand Up @@ -40,6 +41,8 @@ struct ContextualHintEligibilityUtility: ContextualHintEligibilityUtilityProtoco
hintTypeShouldBePresented = isSearchBarLocationFeatureEnabled
case .inactiveTabs:
hintTypeShouldBePresented = true
case .shoppingExperience:
return canPresentShoppingCFR
}

return hintTypeShouldBePresented && !hasAlreadyBeenPresented(hintType)
Expand Down Expand Up @@ -92,6 +95,38 @@ struct ContextualHintEligibilityUtility: ContextualHintEligibilityUtilityProtoco
return shouldCheckToolbarHasShown
}

// There are 2 types of CFRs
//
// Shopping CFR-1: The user has not opted in for the Shopping Experience
// Shopping CFR-2: The user has opted in for the Shopping Experience
private var canPresentShoppingCFR: Bool {
guard !hasAlreadyBeenPresented(.shoppingExperience) else {
// Retrieve the counter for shopping onboarding CFRs
let cfrCounter = profile.prefs.intForKey(PrefsKeys.ContextualHints.shoppingOnboardingCFRsCounterKey.rawValue) ?? 1
// Check if the user has opted in for Shopping Experience
let hasOptedIn = profile.prefs.boolForKey(PrefsKeys.Shopping2023OptIn) ?? false
// Retrieve the last timestamp for Fakespot CFRs
let lastTimestamp = profile.prefs.timestampForKey(PrefsKeys.FakespotLastCFRTimestamp)
// Check if 24 hours have passed since the last timestamp
let hasTimePassed = lastTimestamp != nil ? Date.hasTimePassedBy(hours: 24, lastTimestamp: lastTimestamp!) : false

if cfrCounter == 1, !hasOptedIn, hasTimePassed {
// - Display CFR-1
profile.prefs.setInt(2, forKey: PrefsKeys.ContextualHints.shoppingOnboardingCFRsCounterKey.rawValue)
return true
} else if cfrCounter < 3, hasOptedIn, hasTimePassed {
// - Display CFR-2
profile.prefs.setInt(3, forKey: PrefsKeys.ContextualHints.shoppingOnboardingCFRsCounterKey.rawValue)
return true
}
return false
}
// - Display CFR-1
profile.prefs.setInt(1, forKey: PrefsKeys.ContextualHints.shoppingOnboardingCFRsCounterKey.rawValue)
profile.prefs.setTimestamp(Date.now(), forKey: PrefsKeys.FakespotLastCFRTimestamp)
return true
}

private func hasAlreadyBeenPresented(_ hintType: ContextualHintType) -> Bool {
guard let contextualHintData = profile.prefs.boolForKey(prefsKey(for: hintType)) else { return false }

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ extension ContextualHintPrefsKeysProvider {
case .jumpBackIn: return CFRPrefsKeys.jumpBackinKey.rawValue
case .jumpBackInSyncedTab: return CFRPrefsKeys.jumpBackInSyncedTabKey.rawValue
case .toolbarLocation: return CFRPrefsKeys.toolbarOnboardingKey.rawValue
case .shoppingExperience: return CFRPrefsKeys.shoppingOnboardingKey.rawValue
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ class ContextualHintViewController: UIViewController, OnViewDismissable, Themeab
hintView.configure(viewModel: viewModel)
applyTheme()

if viewProvider.shouldPresentContextualHint() && shouldStartTimer {
if shouldStartTimer {
viewProvider.startTimer()
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ enum ContextualHintType: String {
case jumpBackInSyncedTab = "JumpBackInSyncedTab"
case inactiveTabs = "InactiveTabs"
case toolbarLocation = "ToolbarLocation"
case shoppingExperience = "ShoppingExperience"
}

class ContextualHintViewProvider: ContextualHintPrefsKeysProvider, SearchBarLocationProvider {
Expand Down Expand Up @@ -111,7 +112,8 @@ class ContextualHintViewProvider: ContextualHintPrefsKeysProvider, SearchBarLoca
var isActionType: Bool {
switch hintType {
case .inactiveTabs,
.toolbarLocation:
.toolbarLocation,
.shoppingExperience:
return true

default: return false
Expand Down
1 change: 1 addition & 0 deletions Client/Frontend/Fakespot/FakespotOptInCardViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -116,6 +116,7 @@ struct FakespotOptInCardViewModel {

func onTapMainButton() {
prefs.setBool(true, forKey: PrefsKeys.Shopping2023OptIn)
prefs.setTimestamp(Date.now(), forKey: PrefsKeys.FakespotLastCFRTimestamp)
TelemetryWrapper.recordEvent(category: .action,
method: .tap,
object: .shoppingOptIn)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ class FakespotSettingsCardViewModel {
var dismissViewController: (() -> Void)?

var isReviewQualityCheckOn: Bool {
get { return prefs.boolForKey(PrefsKeys.Shopping2023OptIn) ?? true }
get { return prefs.boolForKey(PrefsKeys.Shopping2023OptIn) ?? false }
set { prefs.setBool(newValue, forKey: PrefsKeys.Shopping2023OptIn) }
}

Expand Down
23 changes: 23 additions & 0 deletions Client/Frontend/Strings.swift
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,29 @@ extension String {
value: "Move the toolbar to the top if that’s more your style.",
comment: "Contextual hints are little popups that appear for the users informing them of new features. This one indicates a user can navigate to the Settings page to move the search bar to the top.")
}

public struct Shopping {
public static let NotOptedInBody = MZLocalizedString(
key: "", // "ContextualHints.Shopping.NotOptedIn.v120"
tableName: "Shopping",
value: "Find out if you can trust this product’s reviews — before you buy.",
comment: "Contextual hints are little popups that appear for the users informing them of new features. This one indicates that a user can tap on the shopping button to start using the Shopping feature.")
public static let NotOptedInAction = MZLocalizedString(
key: "", // "ContextualHints.Shopping.NotOptedInAction.v120"
tableName: "Shopping",
value: "Try review checker",
comment: "Contextual hints are little popups that appear for the users informing them of new features. This one is a call to action for the popup describing the Shopping feature. It indicates that a user can go directly to the Shopping feature by tapping the text of the action.")
public static let OptedInBody = MZLocalizedString(
key: "", // "ContextualHints.Shopping.OptedInBody.v120"
tableName: "Shopping",
value: "Are these reviews reliable? Check now to see an adjusted rating.",
comment: "Contextual hints are little popups that appear for the users informing them of new features. This one appears after the user has opted in and informs him if he wants use the review checker by tapping the Shopping button.")
public static let OptedInAction = MZLocalizedString(
key: "", // "ContextualHints.Shopping.OptedInAction.v120"
tableName: "Shopping",
value: "Open review checker",
comment: "Contextual hints are little popups that appear for the users informing them of new features. This is a call to action for the popup that appears after the user has opted in for the Shopping feature. It indicates that a user can directly open the review checker by tapping the text of the action.")
}
}
}

Expand Down
2 changes: 2 additions & 0 deletions Client/Frontend/Toolbar+URLBar/TabLocationView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ protocol TabLocationViewDelegate: AnyObject {
func tabLocationViewDidBeginDragInteraction(_ tabLocationView: TabLocationView)
func tabLocationViewDidTapShare(_ tabLocationView: TabLocationView, button: UIButton)
func tabLocationViewDidTapShopping(_ tabLocationView: TabLocationView, button: UIButton)
func tabLocationViewPresentCFR(at sourceView: UIView)

/// - returns: whether the long-press was handled by the delegate; i.e. return `false` when the conditions for even starting handling long-press were not satisfied
@discardableResult
Expand Down Expand Up @@ -290,6 +291,7 @@ class TabLocationView: UIView, FeatureFlaggable {
shoppingButton.isHidden = shouldHideButton
if !shouldHideButton {
TelemetryWrapper.recordEvent(category: .action, method: .view, object: .shoppingButton)
delegate?.tabLocationViewPresentCFR(at: shoppingButton)
}
}

Expand Down
5 changes: 5 additions & 0 deletions Client/Frontend/Toolbar+URLBar/URLBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ protocol URLBarDelegate: AnyObject {
func urlBarDidBeginDragInteraction(_ urlBar: URLBarView)
func urlBarDidPressShare(_ urlBar: URLBarView, shareView: UIView)
func urlBarDidPressShopping(_ urlBar: URLBarView, shoppingButton: UIButton)
func urlBarPresentCFR(at sourceView: UIView)
}

protocol URLBarViewProtocol {
Expand Down Expand Up @@ -766,6 +767,10 @@ extension URLBarView: TabLocationViewDelegate {
delegate?.urlBarDidPressShopping(self, shoppingButton: button)
}

func tabLocationViewPresentCFR(at sourceView: UIView) {
delegate?.urlBarPresentCFR(at: sourceView)
}

func tabLocationViewLocationAccessibilityActions(_ tabLocationView: TabLocationView) -> [UIAccessibilityCustomAction]? {
return delegate?.urlBarLocationAccessibilityActions(self)
}
Expand Down
3 changes: 3 additions & 0 deletions Client/Telemetry/TelemetryWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -647,6 +647,7 @@ extension TelemetryWrapper {
case bookmarkItem = "bookmark-item"
case searchSuggestion = "search-suggestion"
case searchHighlights = "search-highlights"
case shoppingCFRsDisplayed = "shopping-cfrs-displayed"
case awesomebarShareTap = "awesomebar-share-tap"
}

Expand Down Expand Up @@ -1068,6 +1069,8 @@ extension TelemetryWrapper {
GleanMetrics.Shopping.surfaceLearnMoreClicked.record()
case (.action, .tap, .shoppingLearnMoreReviewQualityButton, _, _):
GleanMetrics.Shopping.surfaceShowQualityExplainerClicked.record()
case (.action, .navigate, .shoppingButton, .shoppingCFRsDisplayed, _):
GleanMetrics.Shopping.addressBarFeatureCalloutDisplayed.record()

// MARK: Onboarding
case (.action, .view, .onboardingCardView, _, let extras):
Expand Down
12 changes: 12 additions & 0 deletions Client/metrics.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -816,6 +816,18 @@ shopping:
- [email protected]
expires: "2024-09-20"

address_bar_feature_callout_displayed:
type: event
description: |
Records an event everytime a shopping CFR is shown on the screen
bugs:
- https://github.com/mozilla-mobile/firefox-ios/issues/15461
data_reviews:
- https://github.com/mozilla-mobile/firefox-ios/pull/16593
notification_emails:
- [email protected]
expires: "2024-09-25"

# Key Commands
key_commands:
press_key_command_action:
Expand Down
5 changes: 5 additions & 0 deletions Shared/Prefs.swift
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ public struct PrefsKeys {
case jumpBackInSyncedTabConfiguredKey = "JumpBackInSyncedTabConfigured"
case inactiveTabsKey = "ContextualHintInactiveTabs"
case toolbarOnboardingKey = "ContextualHintToolbarOnboardingKey"
case shoppingOnboardingKey = "ShoppingOnboardingCFRKey"
case shoppingOnboardingCFRsCounterKey = "ShoppingOnboardingCFRsCounterKey"
}

// Activity Stream
Expand Down Expand Up @@ -140,6 +142,9 @@ public struct PrefsKeys {
// The last timestamp we polled FxA for missing send tabs
public static let PollCommandsTimestamp = "PollCommandsTimestamp"

// The last recorded CFR timestamp
public static let FakespotLastCFRTimestamp = "FakespotLastCFRTimestamp"

// Representing whether or not the last user session was private
public static let LastSessionWasPrivate = "wasLastSessionPrivate"

Expand Down
14 changes: 14 additions & 0 deletions Shared/TimeConstants.swift
Original file line number Diff line number Diff line change
Expand Up @@ -141,6 +141,20 @@ extension Date {
second: second)
}

/// Checks if a specified amount of time in hours has passed since a given timestamp.
///
/// - Parameters:
/// - hours: The number of hours to check for elapsed time.
/// - lastTimestamp: The timestamp to compare against.
///
/// - Returns: `true` if the specified time in hours has passed since the lastTimestamp; `false` otherwise.
public static func hasTimePassedBy(hours: Timestamp,
lastTimestamp: Timestamp) -> Bool {
let millisecondsInAnHour: Timestamp = 3_600_000 // Convert 1 hour to milliseconds
let timeDifference = Date.now() - lastTimestamp
return timeDifference >= hours * millisecondsInAnHour
}

static func - (lhs: Date, rhs: Date) -> TimeInterval {
return lhs.timeIntervalSinceReferenceDate - rhs.timeIntervalSinceReferenceDate
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ final class FakespotSettingsCardViewModelTests: XCTestCase {

func testInitialViewModelValues() {
XCTAssertEqual(viewModel.areAdsEnabled, true)
XCTAssertEqual(viewModel.isReviewQualityCheckOn, true)
XCTAssertEqual(viewModel.isReviewQualityCheckOn, false)
}

func testGetUserPrefsAfterSettingPrefs() {
Expand All @@ -41,7 +41,7 @@ final class FakespotSettingsCardViewModelTests: XCTestCase {
mockProfile.prefs.setBool(false, forKey: "")

XCTAssertEqual(viewModel.areAdsEnabled, true)
XCTAssertEqual(viewModel.isReviewQualityCheckOn, true)
XCTAssertEqual(viewModel.isReviewQualityCheckOn, false)
}

func testSwitchValueChangedUpdatesPrefs() {
Expand Down
Loading

0 comments on commit 58671a5

Please sign in to comment.